Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ca553784e | ||
|
|
3d7dc4f875 | ||
|
|
6012166d99 | ||
|
|
0b155c6023 | ||
|
|
d66e90a6d5 | ||
|
|
56eab06dc4 | ||
|
|
68442ec784 | ||
|
|
6ca8cfb059 | ||
|
|
21e09279eb | ||
|
|
3d2fc5a09d | ||
|
|
4fa0f486f5 | ||
|
|
2044a7026d | ||
|
|
b1c5e9caa9 | ||
|
|
7c4941ada4 | ||
|
|
c921826f55 |
24
.gitignore
vendored
24
.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
167
README.md
167
README.md
@@ -8,7 +8,7 @@
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/license-CC0_1.0-blue" alt="License: CC0 1.0" />
|
||||
<img src="https://img.shields.io/badge/version-1.0.0-green" alt="Version 0.1.0" />
|
||||
<img src="https://img.shields.io/badge/version-1.1.0-green" alt="Version 1.1.0" />
|
||||
<img src="https://img.shields.io/badge/platform-Windows-0078D4?logo=windows" alt="Windows" />
|
||||
<img src="https://img.shields.io/badge/portable-no%20install%20needed-brightgreen" alt="Portable" />
|
||||
<img src="https://img.shields.io/badge/built%20with-Tauri%20v2-orange" alt="Built with Tauri v2" />
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
Your productivity tools shouldn't phone home. They shouldn't harvest your habits. They shouldn't stop working when a company pivots to AI or gets acqui-hired.
|
||||
|
||||
OpenPylon is a desktop Kanban application that keeps everything on your machine. Every board, card, and attachment is a plain JSON file in a folder next to the executable. Copy it to a USB drive. Back it up to a NAS. Share it with your team over a local network. The data is yours -- always has been, always will be.
|
||||
OpenPylon is a desktop Kanban application that keeps everything on your machine. Every board, card, and attachment is a plain JSON file in a folder next to the executable. Copy it to a USB drive. Back it up to a NAS. Share it with your team over a local network. The data is yours - always has been, always will be.
|
||||
|
||||
No subscription. No signup. No server between you and your work. No one profits from your productivity except you.
|
||||
|
||||
@@ -33,44 +33,44 @@ No subscription. No signup. No server between you and your work. No one profits
|
||||
### 📋 Boards
|
||||
|
||||
- **Unlimited boards** with custom accent colors and editable titles
|
||||
- **Three built-in templates** -- Blank, Kanban (To Do / In Progress / Done), and Sprint (Backlog / To Do / In Progress / Review / Done)
|
||||
- **Save any board as a reusable template** -- build your own workflows and share them freely
|
||||
- **Three built-in templates** - Blank, Kanban (To Do / In Progress / Done), and Sprint (Backlog / To Do / In Progress / Review / Done)
|
||||
- **Save any board as a reusable template** - build your own workflows and share them freely
|
||||
- **Duplicate entire boards** with all cards, labels, and settings preserved
|
||||
- **Drag-and-drop reordering** in the board list
|
||||
- **Sort boards** by name, last modified, date created, or manual drag order
|
||||
- **Import and export** -- JSON (full fidelity) and CSV (spreadsheet-compatible); imports from Trello JSON too
|
||||
- **Import and export** - JSON (full fidelity) and CSV (spreadsheet-compatible); imports from Trello JSON too
|
||||
|
||||
### 🏛️ Columns
|
||||
|
||||
- **Add, rename, reorder, and delete** columns with drag-and-drop
|
||||
- **Three column widths** -- Narrow, Standard, Wide
|
||||
- **Column colors** -- 10 preset hues or no color
|
||||
- **WIP limits** -- optional per-column capacity limits (3, 5, 7, or 10) with amber/red header warnings when the collective workload exceeds what's sustainable
|
||||
- **Collapse columns** to a narrow vertical strip showing just the title and card count -- keep things tidy without losing context
|
||||
- **Three column widths** - Narrow, Standard, Wide
|
||||
- **Column colors** - 10 preset hues or no color
|
||||
- **WIP limits** - optional per-column capacity limits (3, 5, 7, or 10) with amber/red header warnings when the collective workload exceeds what's sustainable
|
||||
- **Collapse columns** to a narrow vertical strip showing just the title and card count - keep things tidy without losing context
|
||||
|
||||
### 🃏 Cards
|
||||
|
||||
- **Drag-and-drop** cards within and between columns
|
||||
- **Markdown descriptions** -- full GitHub Flavored Markdown with tables, strikethrough, task lists, autolinks, and a live preview toggle
|
||||
- **Checklists** -- add items, check them off, reorder by dragging, track progress with a visual bar
|
||||
- **Labels** -- create labels with custom names and colors, toggle them per card
|
||||
- **Due dates** -- custom calendar picker with relative time display ("in 3 days", "overdue by 2 days")
|
||||
- **Priority levels** -- None, Low, Medium, High, Urgent -- each with a distinct color indicator visible on card thumbnails
|
||||
- **Cover colors** -- 10 preset hues rendered as a colored header bar on the card detail
|
||||
- **File attachments** -- link to files in place or copy them into the board's data directory; open in your system's default application
|
||||
- **Comments** -- timestamped notes on each card, newest first, with add and delete
|
||||
- **Card duplication** -- copy a card within its column
|
||||
- **Card aging** -- cards that haven't been touched in a while gradually fade, so you can see at a glance where work has stalled
|
||||
- **Markdown descriptions** - full GitHub Flavored Markdown with tables, strikethrough, task lists, autolinks, and a live preview toggle
|
||||
- **Checklists** - add items, check them off, reorder by dragging, track progress with a visual bar
|
||||
- **Labels** - create labels with custom names and colors, toggle them per card
|
||||
- **Due dates** - custom calendar picker with relative time display ("in 3 days", "overdue by 2 days")
|
||||
- **Priority levels** - None, Low, Medium, High, Urgent - each with a distinct color indicator visible on card thumbnails
|
||||
- **Cover colors** - 10 preset hues rendered as a colored header bar on the card detail
|
||||
- **File attachments** - link to files in place or copy them into the board's data directory; open in your system's default application
|
||||
- **Comments** - timestamped notes on each card, newest first, with add and delete
|
||||
- **Card duplication** - copy a card within its column
|
||||
- **Card aging** - cards that haven't been touched in a while gradually fade, so you can see at a glance where work has stalled
|
||||
|
||||
### 🔍 Filtering and Search
|
||||
|
||||
- **Filter bar** (press `/`) -- narrow down cards by text search, labels, due date status (overdue, today, this week, no date), and priority level
|
||||
- **Command palette** (`Ctrl+K`) -- fuzzy search across cards in the current board, across all boards, and quick access to app actions like creating a new board or toggling dark mode
|
||||
- **Cross-board search** -- find any card by title or description across every board you have
|
||||
- **Filter bar** (press `/`) - narrow down cards by text search, labels, due date status (overdue, today, this week, no date), and priority level
|
||||
- **Command palette** (`Ctrl+K`) - fuzzy search across cards in the current board, across all boards, and quick access to app actions like creating a new board or toggling dark mode
|
||||
- **Cross-board search** - find any card by title or description across every board you have
|
||||
|
||||
### ⌨️ Keyboard Navigation
|
||||
|
||||
Full keyboard-driven workflow. Vim-style or arrow keys -- your choice.
|
||||
Full keyboard-driven workflow. Vim-style or arrow keys - your choice.
|
||||
|
||||
| Shortcut | Action |
|
||||
|---|---|
|
||||
@@ -86,33 +86,80 @@ Full keyboard-driven workflow. Vim-style or arrow keys -- your choice.
|
||||
|
||||
### 🎨 Appearance
|
||||
|
||||
- **Theme** -- Light, Dark, or follow your system preference
|
||||
- **Accent color** -- 10 hues (Teal, Blue, Purple, Pink, Red, Orange, Yellow, Lime, Cyan, Slate) applied globally across the interface
|
||||
- **UI zoom** -- 75% to 150% in 5% increments
|
||||
- **Density** -- Compact, Comfortable, or Spacious -- adjust how much breathing room the interface gets
|
||||
- **Board backgrounds** -- None, Dots, Grid, or Gradient pattern per board
|
||||
- **Default column width** -- configure what width new columns start at
|
||||
- **Custom scrollbars** -- themed scrollbars throughout, with auto-hide behavior
|
||||
- **Smooth animations** -- staggered entrances, layout transitions, and micro-interactions powered by Framer Motion, with full `prefers-reduced-motion` support
|
||||
- **Theme** - Light, Dark, or follow your system preference
|
||||
- **Accent color** - 10 hues (Teal, Blue, Purple, Pink, Red, Orange, Yellow, Lime, Cyan, Slate) applied globally across the interface
|
||||
- **UI zoom** - 75% to 150% in 5% increments
|
||||
- **Density** - Compact, Comfortable, or Spacious - adjust how much breathing room the interface gets
|
||||
- **Board backgrounds** - None, Dots, Grid, or Gradient pattern per board
|
||||
- **Default column width** - configure what width new columns start at
|
||||
- **Custom scrollbars** - themed scrollbars throughout, with auto-hide behavior
|
||||
- **Smooth animations** - staggered entrances, layout transitions, and micro-interactions powered by Framer Motion, with full `prefers-reduced-motion` support
|
||||
|
||||
### ♿ Accessibility (WCAG 2.2 AAA)
|
||||
|
||||
OpenPylon targets WCAG 2.2 AAA conformance - because productivity tools should work for everyone, not just people with perfect vision and a mouse.
|
||||
|
||||
**Color and Contrast**
|
||||
|
||||
- **7:1 enhanced contrast** on all text and interactive elements, in both light and dark themes
|
||||
- **3:1 non-text contrast** on borders, scrollbar thumbs, and focus indicators
|
||||
- **High-contrast mode** support - `prefers-contrast: more` boosts all tokens further
|
||||
- **Color is never the sole indicator** - priority levels, due date status, and labels all include text or shape cues alongside color
|
||||
|
||||
**Focus and Keyboard**
|
||||
|
||||
- **3px dual-ring focus indicators** visible on every interactive element, against any background
|
||||
- **Skip-to-content link** as the first focusable element on the page
|
||||
- **Full keyboard navigation** - vim keys, arrow keys, tab order, Escape to dismiss
|
||||
- **Shift+F10 context menus** - right-click menus are also reachable via keyboard
|
||||
- **Focus trapping** in all modals and dialogs with focus restore on close
|
||||
- **Hidden interactive elements** (menu buttons, action buttons) become visible on `focus-visible`, not just hover
|
||||
|
||||
**Screen Readers and ARIA**
|
||||
|
||||
- **ARIA live regions** announce card/column creation, deletion, moves, filter changes, and drag-and-drop operations
|
||||
- **Proper dialog semantics** - `role="dialog"`, `aria-modal`, `aria-labelledby` on all modals
|
||||
- **Tab/tabpanel pattern** in settings with `role="tablist"`, `role="tab"`, `aria-selected`
|
||||
- **Calendar grid** with `role="grid"`, `aria-selected` on date cells, labeled navigation
|
||||
- **`aria-label`** on every icon-only button, color swatch, status indicator, and unlabeled input
|
||||
- **`aria-pressed`** on all toggle buttons (theme, density, motion, label chips, priority)
|
||||
- **Screen-reader-only labels** for search inputs, select dropdowns, and range sliders
|
||||
|
||||
**Toasts and Notifications**
|
||||
|
||||
- **8-second auto-dismiss** with pause-on-hover and pause-on-focus
|
||||
- **Visible dismiss button** on every toast
|
||||
- **`aria-live="polite"`** region so screen readers announce toast content without interrupting
|
||||
|
||||
**Motion**
|
||||
|
||||
- **`prefers-reduced-motion`** fully respected - both via CSS media query and an in-app toggle
|
||||
- **No essential information** conveyed through animation alone
|
||||
|
||||
**Page Structure**
|
||||
|
||||
- **Dynamic page titles** - updates to reflect the current board name
|
||||
- **Landmark regions** and semantic HTML throughout
|
||||
- **Minimum touch targets** - 44px interactive area on small buttons via extended hit zones
|
||||
|
||||
### 🛡️ Data Safety
|
||||
|
||||
Your work is protected by multiple layers of redundancy -- because tools that lose your data don't deserve your trust.
|
||||
Your work is protected by multiple layers of redundancy - because tools that lose your data don't deserve your trust.
|
||||
|
||||
- **Auto-save** -- boards save automatically 500ms after every change
|
||||
- **Automatic backups** -- timestamped snapshots every 5 minutes, last 10 retained per board
|
||||
- **Version history** -- browse and restore previous versions from the board settings menu
|
||||
- **Rolling backup** -- the previous save is always preserved as a `.backup.json` file
|
||||
- **Portable storage** -- all data lives in a `data/` folder next to the executable; no registry entries, no AppData, no hidden folders
|
||||
- **Schema validation** -- all data is validated with Zod on every load, with graceful fallback to defaults if a file is corrupted. Forward-compatible: boards from older versions just work.
|
||||
- **Auto-save** - boards save automatically 500ms after every change
|
||||
- **Automatic backups** - timestamped snapshots every 5 minutes, last 10 retained per board
|
||||
- **Version history** - browse and restore previous versions from the board settings menu
|
||||
- **Rolling backup** - the previous save is always preserved as a `.backup.json` file
|
||||
- **Portable storage** - all data lives in a `data/` folder next to the executable; no registry entries, no AppData, no hidden folders
|
||||
- **Schema validation** - all data is validated with Zod on every load, with graceful fallback to defaults if a file is corrupted. Forward-compatible: boards from older versions just work.
|
||||
|
||||
### 🖥️ Desktop Integration
|
||||
|
||||
- **Custom frameless window** -- integrated title bar with minimize, maximize, and close controls, plus a drag region that feels native
|
||||
- **Window state persistence** -- remembers your window position, size, and maximized state between sessions
|
||||
- **Due date notifications** -- OS-level desktop notifications for cards that are due today or overdue, checked hourly
|
||||
- **Custom frameless window** - integrated title bar with minimize, maximize, and close controls, plus a drag region that feels native
|
||||
- **Window state persistence** - remembers your window position, size, and maximized state between sessions
|
||||
- **Due date notifications** - OS-level desktop notifications for cards that are due today or overdue, checked hourly
|
||||
- **Open attachments** directly in your system's default application
|
||||
- **Right-click context menus** -- on boards (duplicate, export, save as template, delete) and on cards (move, duplicate, set priority, delete)
|
||||
- **Right-click context menus** - on boards (duplicate, export, save as template, delete) and on cards (move, duplicate, set priority, delete)
|
||||
|
||||
---
|
||||
|
||||
@@ -122,9 +169,9 @@ Your work is protected by multiple layers of redundancy -- because tools that lo
|
||||
|
||||
Grab `openpylon.exe` from the [Releases](https://git.lashman.live/lashman/openpylon/releases) page. That's it. Unzip, run, done.
|
||||
|
||||
No installer. No admin rights. No registry entries. Runs from anywhere -- your desktop, a USB stick, a shared drive. Put it wherever you want. It's yours.
|
||||
No installer. No admin rights. No registry entries. Runs from anywhere - your desktop, a USB stick, a shared drive. Put it wherever you want. It's yours.
|
||||
|
||||
> 💡 **Fully portable** -- OpenPylon stores all its data in a `data/` folder right next to the executable. Move the folder, move your data. Delete the folder, it's gone. No traces left behind.
|
||||
> 💡 **Fully portable** - OpenPylon stores all its data in a `data/` folder right next to the executable. Move the folder, move your data. Delete the folder, it's gone. No traces left behind.
|
||||
|
||||
### Build from Source
|
||||
|
||||
@@ -182,15 +229,15 @@ data/
|
||||
|
||||
### 📄 Board Format
|
||||
|
||||
Each board is a self-contained JSON file with a Zod-validated schema. New fields added in future versions receive sensible defaults on load -- so older board files never break. You can read, edit, or script against these files with any tool you like. They're just JSON. No proprietary formats, no binary blobs, no vendor lock-in.
|
||||
Each board is a self-contained JSON file with a Zod-validated schema. New fields added in future versions receive sensible defaults on load - so older board files never break. You can read, edit, or script against these files with any tool you like. They're just JSON. No proprietary formats, no binary blobs, no vendor lock-in.
|
||||
|
||||
### 🔄 Backup and Recovery
|
||||
|
||||
If something goes wrong:
|
||||
|
||||
1. **Version History** -- open board settings (⚙️ icon in the top bar) -> "Version History" -> pick a snapshot -> restore. Your current state is backed up first.
|
||||
2. **Manual `.backup.json`** -- every board has a `.backup.json` sibling in the `boards/` folder. Rename it to replace the current file.
|
||||
3. **Timestamped snapshots** -- find the one you want in `data/backups/<board-id>/` and copy it into `data/boards/`.
|
||||
1. **Version History** - open board settings (⚙️ icon in the top bar) -> "Version History" -> pick a snapshot -> restore. Your current state is backed up first.
|
||||
2. **Manual `.backup.json`** - every board has a `.backup.json` sibling in the `boards/` folder. Rename it to replace the current file.
|
||||
3. **Timestamped snapshots** - find the one you want in `data/backups/<board-id>/` and copy it into `data/boards/`.
|
||||
|
||||
---
|
||||
|
||||
@@ -202,10 +249,10 @@ Click the **Import** button on the board list screen and pick a `.json` file. Op
|
||||
|
||||
| Format | What Gets Imported |
|
||||
|---|---|
|
||||
| **OpenPylon JSON** | Everything -- full fidelity round-trip, no data loss |
|
||||
| **OpenPylon JSON** | Everything - full fidelity round-trip, no data loss |
|
||||
| **Trello JSON** | Lists -> columns, cards, labels (with color mapping), checklists. Archived/closed items are skipped. |
|
||||
|
||||
Migrating off Trello? Export your board from Trello (Menu -> Share -> Export as JSON), then import it here. Your data belongs with you -- not with Atlassian.
|
||||
Migrating off Trello? Export your board from Trello (Menu -> Share -> Export as JSON), then import it here. Your data belongs with you - not with Atlassian.
|
||||
|
||||
### Exporting
|
||||
|
||||
@@ -255,7 +302,7 @@ No lock-in. Take your data wherever you want, whenever you want. We'd rather you
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| 🦀 Runtime | [Tauri v2](https://v2.tauri.app/) -- lightweight, native, no Electron bloat |
|
||||
| 🦀 Runtime | [Tauri v2](https://v2.tauri.app/) - lightweight, native, no Electron bloat |
|
||||
| ⚛️ Frontend | [React 19](https://react.dev/) + [TypeScript 5.8](https://www.typescriptlang.org/) |
|
||||
| 🎨 Styling | [Tailwind CSS 4](https://tailwindcss.com/) + [Radix UI](https://www.radix-ui.com/) primitives |
|
||||
| 🧠 State | [Zustand 5](https://zustand.docs.pmnd.rs/) + [Zundo](https://github.com/charkour/zundo) (50-step undo/redo) |
|
||||
@@ -277,9 +324,9 @@ All dependencies are free and open-source. No proprietary tooling. No paid servi
|
||||
openpylon/
|
||||
├── src/ # React frontend
|
||||
│ ├── components/
|
||||
│ │ ├── board/ # Board view -- columns, cards, filter bar, drag overlays
|
||||
│ │ ├── boards/ # Board list -- grid, new board dialog, board cards
|
||||
│ │ ├── card-detail/ # Card modal -- markdown, checklists, labels, priority,
|
||||
│ │ ├── board/ # Board view - columns, cards, filter bar, drag overlays
|
||||
│ │ ├── boards/ # Board list - grid, new board dialog, board cards
|
||||
│ │ ├── card-detail/ # Card modal - markdown, checklists, labels, priority,
|
||||
│ │ │ # due dates, attachments, comments, cover colors
|
||||
│ │ ├── command-palette/ # Ctrl+K fuzzy search across everything
|
||||
│ │ ├── import-export/ # Import/export buttons and file handling
|
||||
@@ -288,7 +335,7 @@ openpylon/
|
||||
│ │ └── ui/ # Shared primitives (button, dialog, tooltip, popover...)
|
||||
│ ├── hooks/ # Keyboard shortcuts, keyboard card navigation
|
||||
│ ├── lib/ # Storage, import/export, board factory, motion presets
|
||||
│ ├── stores/ # Zustand -- app store, board store, toast store
|
||||
│ ├── stores/ # Zustand - app store, board store, toast store
|
||||
│ └── types/ # TypeScript interfaces and type definitions
|
||||
├── src-tauri/ # Tauri / Rust backend
|
||||
│ ├── src/
|
||||
@@ -312,7 +359,7 @@ openpylon/
|
||||
npm run tauri dev
|
||||
|
||||
# Type-check the frontend
|
||||
npx tsc --noEmit
|
||||
npx tsc -noEmit
|
||||
|
||||
# Production build (portable exe)
|
||||
npm run tauri build
|
||||
@@ -322,7 +369,7 @@ The dev server runs on `http://localhost:1420` with Vite HMR. Rust backend chang
|
||||
|
||||
### 🤝 Contributing
|
||||
|
||||
OpenPylon is released into the public domain under CC0 1.0. There's no CLA, no copyright assignment, no gatekeeping. If you want to contribute, just open a PR. If you want to fork it and build something entirely different, go ahead -- no permission needed.
|
||||
OpenPylon is released into the public domain under CC0 1.0. There's no CLA, no copyright assignment, no gatekeeping. If you want to contribute, just open a PR. If you want to fork it and build something entirely different, go ahead - no permission needed.
|
||||
|
||||
Good things happen when tools are shared freely.
|
||||
|
||||
@@ -336,11 +383,11 @@ Good things happen when tools are shared freely.
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**CC0 1.0 Universal -- Public Domain Dedication**
|
||||
**CC0 1.0 Universal - Public Domain Dedication**
|
||||
|
||||
To the extent possible under law, the authors of OpenPylon have waived all copyright and related rights to this software. This work is published from the United States.
|
||||
|
||||
You can copy, modify, distribute, and use this software -- even for commercial purposes -- without asking permission and without owing anyone anything.
|
||||
You can copy, modify, distribute, and use this software - even for commercial purposes - without asking permission and without owing anyone anything.
|
||||
|
||||
See [LICENSE](LICENSE) or https://creativecommons.org/publicdomain/zero/1.0/ for details.
|
||||
|
||||
@@ -350,6 +397,6 @@ See [LICENSE](LICENSE) or https://creativecommons.org/publicdomain/zero/1.0/ for
|
||||
<sub>
|
||||
Made with care. Shared without conditions.
|
||||
<br />
|
||||
Your tools should serve you -- not the other way around.
|
||||
Your tools should serve you - not the other way around.
|
||||
</sub>
|
||||
</p>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# Card Detail Modal Redesign — Design Document
|
||||
|
||||
**Date:** 2026-02-15
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The current card detail modal uses a 60/40 split layout with a massive markdown editor on the left and all metadata (cover, labels, due date, checklist, attachments) crammed into a narrow right sidebar. This is unbalanced — the description field dominates despite being lightly used, while actionable sections like checklist are squeezed.
|
||||
|
||||
## Design Decisions (from brainstorming)
|
||||
|
||||
| Question | Answer |
|
||||
|----------|--------|
|
||||
| Primary use when opening card | Scan everything at once |
|
||||
| Description importance | Light use (short notes) |
|
||||
| Modal size | Go wider |
|
||||
| Layout style | Dashboard grid |
|
||||
| Section prominence | Equal weight |
|
||||
| Long checklist behavior | Scroll within cell |
|
||||
| Title position | Full-width header with cover color |
|
||||
| Attachment display | Compact list |
|
||||
| Description visibility | Always visible cell in grid |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ ██████████████████████████████████████████████████ [×] │ Cover color header
|
||||
│ Card Title (click to edit) │ Inline-editable
|
||||
├────────────────────────────┬─────────────────────────────┤
|
||||
│ LABELS │ DUE DATE │ Row 1: metadata
|
||||
│ [Tag] [Tag] [Tag] [+] │ Feb 20 · 5 days left │
|
||||
├────────────────────────────┼─────────────────────────────┤
|
||||
│ CHECKLIST 2/5 │ DESCRIPTION │ Row 2: content
|
||||
│ ✓ item 1 │ Short notes here... │
|
||||
│ ☐ item 2 (scroll) │ (click to edit) │
|
||||
│ + Add item │ │
|
||||
├────────────────────────────┼─────────────────────────────┤
|
||||
│ COVER │ ATTACHMENTS │ Row 3: secondary
|
||||
│ ○ ○ ○ ○ ○ ○ ○ ○ × │ file.pdf · doc.png │
|
||||
│ │ [+ Add file] │
|
||||
└────────────────────────────┴─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Header
|
||||
- Full-width bar with cover color as background (or neutral `pylon-surface` if no cover)
|
||||
- White text on colored bg, normal text on surface bg
|
||||
- Inline-editable title (click → input → Enter/Escape)
|
||||
- Close [×] button top-right
|
||||
|
||||
### Grid Body
|
||||
- CSS Grid: `grid-template-columns: 1fr 1fr`, `gap: 1rem`
|
||||
- Each cell: `rounded-lg bg-pylon-column/50 p-4`
|
||||
- Section headers: `font-mono text-xs uppercase tracking-widest text-pylon-text-secondary`
|
||||
- Row 1: Labels + Due Date (small metadata)
|
||||
- Row 2: Checklist + Description (main content, max-h ~200px with internal scroll)
|
||||
- Row 3: Cover Color + Attachments (secondary)
|
||||
|
||||
### Modal
|
||||
- Width: `max-w-4xl` (up from `max-w-3xl`)
|
||||
- Max height: `max-h-[85vh]` with body scrollable
|
||||
- Shared layout animation preserved (`layoutId`)
|
||||
|
||||
### Animation
|
||||
- Grid cells stagger in with `fadeSlideUp` + `staggerContainer(0.05)`
|
||||
- Backdrop blur + fade (existing)
|
||||
- Escape/click-outside to close (existing)
|
||||
|
||||
## Files to Modify
|
||||
- `src/components/card-detail/CardDetailModal.tsx` — Full rewrite of layout
|
||||
- `src/components/card-detail/MarkdownEditor.tsx` — Remove min-h-200, adapt for grid cell
|
||||
- `src/components/card-detail/ChecklistSection.tsx` — Add max-height + scroll
|
||||
- Minor: LabelPicker, DueDatePicker, AttachmentSection — No structural changes needed
|
||||
@@ -1,467 +0,0 @@
|
||||
# Card Detail Modal Redesign — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the 60/40 split card detail modal with a 2-column dashboard grid where every section gets equal weight.
|
||||
|
||||
**Architecture:** Full rewrite of `CardDetailModal.tsx` to use CSS Grid (2 cols, 3 rows) under a cover-color header. Sub-components (`MarkdownEditor`, `ChecklistSection`) get minor tweaks for cell sizing. `CoverColorPicker` moves from inline private component to its own grid cell. Framer Motion stagger preserved.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Framer Motion 12, Tailwind 4, Zustand
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Rewrite CardDetailModal — header + grid shell
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/card-detail/CardDetailModal.tsx` (full rewrite, lines 1-245)
|
||||
|
||||
**Step 1: Replace the entire file with the new layout**
|
||||
|
||||
Replace the full contents of `CardDetailModal.tsx` with:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
import { useBoardStore } from "@/stores/board-store";
|
||||
import { MarkdownEditor } from "@/components/card-detail/MarkdownEditor";
|
||||
import { ChecklistSection } from "@/components/card-detail/ChecklistSection";
|
||||
import { LabelPicker } from "@/components/card-detail/LabelPicker";
|
||||
import { DueDatePicker } from "@/components/card-detail/DueDatePicker";
|
||||
import { AttachmentSection } from "@/components/card-detail/AttachmentSection";
|
||||
import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion";
|
||||
|
||||
interface CardDetailModalProps {
|
||||
cardId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
|
||||
const card = useBoardStore((s) =>
|
||||
cardId ? s.board?.cards[cardId] ?? null : null
|
||||
);
|
||||
const boardLabels = useBoardStore((s) => s.board?.labels ?? []);
|
||||
const updateCard = useBoardStore((s) => s.updateCard);
|
||||
|
||||
const open = cardId != null && card != null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && card && cardId && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
layoutId={`card-${cardId}`}
|
||||
className="relative w-full max-w-4xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl"
|
||||
transition={springs.gentle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EscapeHandler onClose={onClose} />
|
||||
<span className="sr-only">Card detail editor</span>
|
||||
|
||||
{/* Header: cover color background + title + close */}
|
||||
<div
|
||||
className="relative flex items-center gap-3 px-6 py-4"
|
||||
style={{
|
||||
backgroundColor: card.coverColor
|
||||
? `oklch(55% 0.12 ${card.coverColor})`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<InlineTitle
|
||||
cardId={cardId}
|
||||
title={card.title}
|
||||
updateCard={updateCard}
|
||||
hasColor={card.coverColor != null}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`absolute right-4 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors ${
|
||||
card.coverColor
|
||||
? "text-white/70 hover:bg-white/20 hover:text-white"
|
||||
: "text-pylon-text-secondary hover:bg-pylon-column hover:text-pylon-text"
|
||||
}`}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dashboard grid body */}
|
||||
<motion.div
|
||||
className="grid max-h-[calc(85vh-4rem)] grid-cols-2 gap-4 overflow-y-auto p-5"
|
||||
variants={staggerContainer(0.05)}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* Row 1: Labels + Due Date */}
|
||||
<motion.div
|
||||
className="rounded-lg bg-pylon-column/50 p-4"
|
||||
variants={fadeSlideUp}
|
||||
transition={springs.bouncy}
|
||||
>
|
||||
<LabelPicker
|
||||
cardId={cardId}
|
||||
cardLabelIds={card.labels}
|
||||
boardLabels={boardLabels}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="rounded-lg bg-pylon-column/50 p-4"
|
||||
variants={fadeSlideUp}
|
||||
transition={springs.bouncy}
|
||||
>
|
||||
<DueDatePicker cardId={cardId} dueDate={card.dueDate} />
|
||||
</motion.div>
|
||||
|
||||
{/* Row 2: Checklist + Description */}
|
||||
<motion.div
|
||||
className="rounded-lg bg-pylon-column/50 p-4"
|
||||
variants={fadeSlideUp}
|
||||
transition={springs.bouncy}
|
||||
>
|
||||
<ChecklistSection
|
||||
cardId={cardId}
|
||||
checklist={card.checklist}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="rounded-lg bg-pylon-column/50 p-4"
|
||||
variants={fadeSlideUp}
|
||||
transition={springs.bouncy}
|
||||
>
|
||||
<MarkdownEditor cardId={cardId} value={card.description} />
|
||||
</motion.div>
|
||||
|
||||
{/* Row 3: Cover + Attachments */}
|
||||
<motion.div
|
||||
className="rounded-lg bg-pylon-column/50 p-4"
|
||||
variants={fadeSlideUp}
|
||||
transition={springs.bouncy}
|
||||
>
|
||||
<CoverColorPicker
|
||||
cardId={cardId}
|
||||
coverColor={card.coverColor}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="rounded-lg bg-pylon-column/50 p-4"
|
||||
variants={fadeSlideUp}
|
||||
transition={springs.bouncy}
|
||||
>
|
||||
<AttachmentSection
|
||||
cardId={cardId}
|
||||
attachments={card.attachments}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Escape key handler ---------- */
|
||||
|
||||
function EscapeHandler({ onClose }: { onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- Inline editable title ---------- */
|
||||
|
||||
interface InlineTitleProps {
|
||||
cardId: string;
|
||||
title: string;
|
||||
updateCard: (cardId: string, updates: { title: string }) => void;
|
||||
hasColor: boolean;
|
||||
}
|
||||
|
||||
function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(title);
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
function handleSave() {
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed && trimmed !== title) {
|
||||
updateCard(cardId, { title: trimmed });
|
||||
} else {
|
||||
setDraft(title);
|
||||
}
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
setDraft(title);
|
||||
setEditing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const textColor = hasColor ? "text-white" : "text-pylon-text";
|
||||
const hoverColor = hasColor ? "hover:text-white/80" : "hover:text-pylon-accent";
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`flex-1 font-heading text-xl bg-transparent outline-none border-b pb-0.5 ${
|
||||
hasColor ? "text-white border-white/50" : "text-pylon-text border-pylon-accent"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<h2
|
||||
onClick={() => setEditing(true)}
|
||||
className={`flex-1 cursor-pointer font-heading text-xl transition-colors ${textColor} ${hoverColor}`}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Cover color picker ---------- */
|
||||
|
||||
function CoverColorPicker({
|
||||
cardId,
|
||||
coverColor,
|
||||
}: {
|
||||
cardId: string;
|
||||
coverColor: string | null;
|
||||
}) {
|
||||
const updateCard = useBoardStore((s) => s.updateCard);
|
||||
const presets = [
|
||||
{ hue: "160", label: "Teal" },
|
||||
{ hue: "240", label: "Blue" },
|
||||
{ hue: "300", label: "Purple" },
|
||||
{ hue: "350", label: "Pink" },
|
||||
{ hue: "25", label: "Red" },
|
||||
{ hue: "55", label: "Orange" },
|
||||
{ hue: "85", label: "Yellow" },
|
||||
{ hue: "130", label: "Lime" },
|
||||
{ hue: "200", label: "Cyan" },
|
||||
{ hue: "0", label: "Slate" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
||||
Cover
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => updateCard(cardId, { coverColor: null })}
|
||||
className="flex size-6 items-center justify-center rounded-full border border-border text-xs text-pylon-text-secondary hover:bg-pylon-column"
|
||||
title="None"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{presets.map(({ hue, label }) => (
|
||||
<button
|
||||
key={hue}
|
||||
onClick={() => updateCard(cardId, { coverColor: hue })}
|
||||
className="size-6 rounded-full transition-transform hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: `oklch(55% 0.12 ${hue})`,
|
||||
outline:
|
||||
coverColor === hue ? "2px solid currentColor" : "none",
|
||||
outlineOffset: "1px",
|
||||
}}
|
||||
title={label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/card-detail/CardDetailModal.tsx
|
||||
git commit -m "feat: rewrite card detail modal as 2-column dashboard grid"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Adapt MarkdownEditor for grid cell
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/card-detail/MarkdownEditor.tsx` (lines 93-103)
|
||||
|
||||
**Step 1: Replace `min-h-[200px]` with cell-friendly sizing**
|
||||
|
||||
In `MarkdownEditor.tsx`, change the textarea className (line 99):
|
||||
|
||||
```
|
||||
Old: className="min-h-[200px] w-full resize-y rounded-md ...
|
||||
New: className="min-h-[100px] max-h-[160px] w-full resize-y rounded-md ...
|
||||
```
|
||||
|
||||
And change the preview container className (line 103):
|
||||
|
||||
```
|
||||
Old: className="min-h-[200px] cursor-pointer rounded-md ...
|
||||
New: className="min-h-[100px] max-h-[160px] overflow-y-auto cursor-pointer rounded-md ...
|
||||
```
|
||||
|
||||
**Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/card-detail/MarkdownEditor.tsx
|
||||
git commit -m "feat: adapt markdown editor sizing for dashboard grid cell"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add scroll containment to ChecklistSection
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/card-detail/ChecklistSection.tsx` (lines 52-64)
|
||||
|
||||
**Step 1: Add max-height + overflow to the checklist items container**
|
||||
|
||||
In `ChecklistSection.tsx`, change the items container (line 53):
|
||||
|
||||
```
|
||||
Old: <div className="flex flex-col gap-1">
|
||||
New: <div className="flex max-h-[160px] flex-col gap-1 overflow-y-auto">
|
||||
```
|
||||
|
||||
Also add a small progress bar under the header. Change lines 39-50 (the header section) to:
|
||||
|
||||
```tsx
|
||||
{/* Header + progress */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
||||
Checklist
|
||||
</h4>
|
||||
{checklist.length > 0 && (
|
||||
<span className="font-mono text-xs text-pylon-text-secondary">
|
||||
{checked}/{checklist.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{checklist.length > 0 && (
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-pylon-column">
|
||||
<div
|
||||
className="h-full rounded-full bg-pylon-accent transition-all duration-300"
|
||||
style={{ width: `${(checked / checklist.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/card-detail/ChecklistSection.tsx
|
||||
git commit -m "feat: add scroll containment and progress bar to checklist"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Remove unused Separator import + visual verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/card-detail/CardDetailModal.tsx` (verify no stale imports)
|
||||
|
||||
**Step 1: Verify the file has no unused imports**
|
||||
|
||||
The rewrite in Task 1 already removed the `Separator` import. Confirm the import block does NOT include:
|
||||
- `import { Separator } from "@/components/ui/separator";`
|
||||
|
||||
If it's still there, delete it.
|
||||
|
||||
**Step 2: Run TypeScript check**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Run the dev server and visually verify**
|
||||
|
||||
Run: `npm run dev` (or `npx tauri dev` if checking in Tauri)
|
||||
|
||||
Verify:
|
||||
- Card detail modal opens on card click
|
||||
- Full-width header shows cover color (or neutral bg)
|
||||
- Title is editable (click to edit, Enter/Escape)
|
||||
- Close button [x] works
|
||||
- 2x3 grid: Labels | Due Date / Checklist | Description / Cover | Attachments
|
||||
- Each cell has rounded-lg background
|
||||
- Checklist scrolls when > ~6 items
|
||||
- Description shows compact preview
|
||||
- All animations stagger in
|
||||
- Escape closes modal
|
||||
- Click outside closes modal
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: card detail modal dashboard grid redesign complete"
|
||||
```
|
||||
@@ -1,87 +0,0 @@
|
||||
# Custom Date Picker — Design Document
|
||||
|
||||
**Date:** 2026-02-15
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The DueDatePicker uses a native `<input type="date">` which looks out of place in the app's custom dark theme with OKLCH colors. Need a fully custom calendar widget that matches the design language.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Question | Answer |
|
||||
|----------|--------|
|
||||
| Trigger | Click the entire due date grid cell |
|
||||
| Calendar position | Popover floating below the cell |
|
||||
| Navigation | Month + year dropdown selectors |
|
||||
| Today button | Yes, at bottom of calendar |
|
||||
| Past dates | Selectable but dimmed |
|
||||
| Clear action | Both: x on cell display AND Clear button in calendar footer |
|
||||
| Approach | Fully custom (date-fns + Radix Popover, no new deps) |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
┌─ Due Date Grid Cell ──────────────────────┐
|
||||
│ DUE DATE [×] │
|
||||
│ Feb 20, 2026 · in 5 days │
|
||||
└───────────────────────────────────────────┘
|
||||
│
|
||||
▼ popover (280px wide)
|
||||
┌───────────────────────────────────────┐
|
||||
│ ◀ [February ▾] [2026 ▾] ▶ │
|
||||
├───────────────────────────────────────┤
|
||||
│ Mo Tu We Th Fr Sa Su │
|
||||
│ ·· ·· ·· ·· ·· 1 2 │
|
||||
│ 3 4 5 6 7 8 9 │
|
||||
│ 10 11 12 13 14 ⬤15 16 │
|
||||
│ 17 18 19 ■20 21 22 23 │
|
||||
│ 24 25 26 27 28 ·· ·· │
|
||||
├───────────────────────────────────────┤
|
||||
│ [Today] [Clear] │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### DueDatePicker (modified)
|
||||
- Remove `<input type="date">` entirely
|
||||
- Cell display: formatted date + relative time, or placeholder
|
||||
- × clear button in section header (visible when date is set)
|
||||
- Clicking cell body opens CalendarPopover
|
||||
- Overdue dates in `text-pylon-danger`
|
||||
|
||||
### CalendarPopover (new)
|
||||
- Radix Popover anchored below the cell
|
||||
- 280px wide, `bg-pylon-surface rounded-xl shadow-2xl`
|
||||
|
||||
#### Header
|
||||
- Left/right arrow buttons for prev/next month
|
||||
- Clickable month name → month selector (3×4 grid of month names)
|
||||
- Clickable year → year selector (grid of years, current ±5)
|
||||
|
||||
#### Day Grid
|
||||
- 7 columns (Mo-Su), 6 rows max
|
||||
- Day cells: `size-9 text-sm rounded-lg`
|
||||
- Selected: `bg-pylon-accent text-white`
|
||||
- Today: `ring-1 ring-pylon-accent`
|
||||
- Past: `opacity-50`
|
||||
- Other month days: hidden (empty cells)
|
||||
- Hover: `bg-pylon-column`
|
||||
|
||||
#### Footer
|
||||
- "Today" button (left) — jumps to and selects today
|
||||
- "Clear" button (right) — removes due date
|
||||
|
||||
### Animation
|
||||
- Popover: `scaleIn` + `springs.snappy`
|
||||
- Month/year selector: `AnimatePresence mode="wait"` crossfade
|
||||
|
||||
## Files
|
||||
- Create: `src/components/card-detail/CalendarPopover.tsx`
|
||||
- Modify: `src/components/card-detail/DueDatePicker.tsx`
|
||||
|
||||
## Dependencies
|
||||
- date-fns v4 (already installed): `startOfMonth`, `endOfMonth`, `startOfWeek`, `endOfWeek`, `eachDayOfInterval`, `format`, `isSameDay`, `isSameMonth`, `isToday`, `isPast`, `addMonths`, `subMonths`, `setMonth`, `setYear`, `getYear`
|
||||
- Radix Popover (already installed via `src/components/ui/popover.tsx`)
|
||||
- Framer Motion (already installed)
|
||||
@@ -1,456 +0,0 @@
|
||||
# Custom Date Picker — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the native HTML date input with a fully custom calendar widget that matches the app's dark OKLCH theme.
|
||||
|
||||
**Architecture:** Create a new `CalendarPopover` component (calendar grid + month/year selectors + footer) using date-fns for date math and Radix Popover for positioning. Rewrite `DueDatePicker` to use it instead of `<input type="date">`. No new dependencies.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, date-fns v4, Framer Motion 12, Tailwind 4, Radix Popover
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create CalendarPopover component
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/card-detail/CalendarPopover.tsx`
|
||||
|
||||
**Step 1: Create the file with the complete component**
|
||||
|
||||
Create `src/components/card-detail/CalendarPopover.tsx` with:
|
||||
|
||||
```tsx
|
||||
import { useState, useMemo } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
format,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isToday as isTodayFn,
|
||||
isPast,
|
||||
addMonths,
|
||||
subMonths,
|
||||
setMonth,
|
||||
setYear,
|
||||
getYear,
|
||||
getMonth,
|
||||
} from "date-fns";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { springs } from "@/lib/motion";
|
||||
|
||||
interface CalendarPopoverProps {
|
||||
selectedDate: Date | null;
|
||||
onSelect: (date: Date) => void;
|
||||
onClear: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
type ViewMode = "days" | "months" | "years";
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||||
|
||||
export function CalendarPopover({
|
||||
selectedDate,
|
||||
onSelect,
|
||||
onClear,
|
||||
children,
|
||||
}: CalendarPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [viewDate, setViewDate] = useState(() => selectedDate ?? new Date());
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("days");
|
||||
|
||||
// Reset view when opening
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen) {
|
||||
setViewDate(selectedDate ?? new Date());
|
||||
setViewMode("days");
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
}
|
||||
|
||||
function handleSelectDate(date: Date) {
|
||||
onSelect(date);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function handleToday() {
|
||||
const today = new Date();
|
||||
onSelect(today);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
onClear();
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
// Build the 6×7 grid of days for the current viewDate month
|
||||
const calendarDays = useMemo(() => {
|
||||
const monthStart = startOfMonth(viewDate);
|
||||
const monthEnd = endOfMonth(viewDate);
|
||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
|
||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
||||
}, [viewDate]);
|
||||
|
||||
// Year range for year selector: current year ± 5
|
||||
const yearRange = useMemo(() => {
|
||||
const center = getYear(viewDate);
|
||||
const years: number[] = [];
|
||||
for (let y = center - 5; y <= center + 5; y++) {
|
||||
years.push(y);
|
||||
}
|
||||
return years;
|
||||
}, [viewDate]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-[280px] bg-pylon-surface p-0 rounded-xl shadow-2xl border-border"
|
||||
>
|
||||
{/* Navigation header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setViewDate((d) => subMonths(d, 1))}
|
||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === "months" ? "days" : "months")}
|
||||
className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
|
||||
>
|
||||
{format(viewDate, "MMMM")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === "years" ? "days" : "years")}
|
||||
className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
|
||||
>
|
||||
{format(viewDate, "yyyy")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setViewDate((d) => addMonths(d, 1))}
|
||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body: days / months / years */}
|
||||
<div className="p-3">
|
||||
<AnimatePresence mode="wait">
|
||||
{viewMode === "days" && (
|
||||
<motion.div
|
||||
key="days"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{/* Weekday headers */}
|
||||
<div className="mb-1 grid grid-cols-7">
|
||||
{WEEKDAYS.map((wd) => (
|
||||
<div
|
||||
key={wd}
|
||||
className="flex h-8 items-center justify-center font-mono text-[10px] uppercase tracking-wider text-pylon-text-secondary"
|
||||
>
|
||||
{wd}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{calendarDays.map((day) => {
|
||||
const inMonth = isSameMonth(day, viewDate);
|
||||
const today = isTodayFn(day);
|
||||
const selected = selectedDate != null && isSameDay(day, selectedDate);
|
||||
const past = isPast(day) && !today;
|
||||
|
||||
if (!inMonth) {
|
||||
return <div key={day.toISOString()} className="h-9" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => handleSelectDate(day)}
|
||||
className={`flex h-9 items-center justify-center rounded-lg text-sm transition-colors
|
||||
${selected
|
||||
? "bg-pylon-accent font-medium text-white"
|
||||
: today
|
||||
? "font-medium text-pylon-accent ring-1 ring-pylon-accent"
|
||||
: past
|
||||
? "text-pylon-text opacity-50 hover:bg-pylon-column hover:opacity-75"
|
||||
: "text-pylon-text hover:bg-pylon-column"
|
||||
}`}
|
||||
>
|
||||
{format(day, "d")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{viewMode === "months" && (
|
||||
<motion.div
|
||||
key="months"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="grid grid-cols-3 gap-1"
|
||||
>
|
||||
{MONTH_NAMES.map((name, i) => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => {
|
||||
setViewDate((d) => setMonth(d, i));
|
||||
setViewMode("days");
|
||||
}}
|
||||
className={`rounded-lg py-2 text-sm transition-colors ${
|
||||
getMonth(viewDate) === i
|
||||
? "bg-pylon-accent font-medium text-white"
|
||||
: "text-pylon-text hover:bg-pylon-column"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{viewMode === "years" && (
|
||||
<motion.div
|
||||
key="years"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="grid grid-cols-3 gap-1"
|
||||
>
|
||||
{yearRange.map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
onClick={() => {
|
||||
setViewDate((d) => setYear(d, year));
|
||||
setViewMode("days");
|
||||
}}
|
||||
className={`rounded-lg py-2 text-sm transition-colors ${
|
||||
getYear(viewDate) === year
|
||||
? "bg-pylon-accent font-medium text-white"
|
||||
: "text-pylon-text hover:bg-pylon-column"
|
||||
}`}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-border px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleToday}
|
||||
className="text-pylon-accent hover:text-pylon-accent"
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleClear}
|
||||
className="text-pylon-text-secondary hover:text-pylon-danger"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/card-detail/CalendarPopover.tsx
|
||||
git commit -m "feat: create custom CalendarPopover component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rewrite DueDatePicker to use CalendarPopover
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/card-detail/DueDatePicker.tsx` (full rewrite)
|
||||
|
||||
**Step 1: Replace the entire file**
|
||||
|
||||
Replace `src/components/card-detail/DueDatePicker.tsx` with:
|
||||
|
||||
```tsx
|
||||
import { format, formatDistanceToNow, isPast, isToday } from "date-fns";
|
||||
import { X } from "lucide-react";
|
||||
import { useBoardStore } from "@/stores/board-store";
|
||||
import { CalendarPopover } from "@/components/card-detail/CalendarPopover";
|
||||
|
||||
interface DueDatePickerProps {
|
||||
cardId: string;
|
||||
dueDate: string | null;
|
||||
}
|
||||
|
||||
export function DueDatePicker({ cardId, dueDate }: DueDatePickerProps) {
|
||||
const updateCard = useBoardStore((s) => s.updateCard);
|
||||
|
||||
const dateObj = dueDate ? new Date(dueDate) : null;
|
||||
const overdue = dateObj != null && isPast(dateObj) && !isToday(dateObj);
|
||||
|
||||
function handleSelect(date: Date) {
|
||||
updateCard(cardId, { dueDate: format(date, "yyyy-MM-dd") });
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
updateCard(cardId, { dueDate: null });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Header with clear button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
||||
Due Date
|
||||
</h4>
|
||||
{dueDate && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="rounded p-0.5 text-pylon-text-secondary transition-colors hover:bg-pylon-danger/10 hover:text-pylon-danger"
|
||||
aria-label="Clear due date"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clickable date display → opens calendar */}
|
||||
<CalendarPopover
|
||||
selectedDate={dateObj}
|
||||
onSelect={handleSelect}
|
||||
onClear={handleClear}
|
||||
>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-1 py-1 text-left transition-colors hover:bg-pylon-column/60">
|
||||
{dateObj ? (
|
||||
<>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
overdue ? "text-pylon-danger" : "text-pylon-text"
|
||||
}`}
|
||||
>
|
||||
{format(dateObj, "MMM d, yyyy")}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
overdue ? "text-pylon-danger" : "text-pylon-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{overdue
|
||||
? `overdue by ${formatDistanceToNow(dateObj)}`
|
||||
: isToday(dateObj)
|
||||
? "today"
|
||||
: `in ${formatDistanceToNow(dateObj)}`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm italic text-pylon-text-secondary/60">
|
||||
Click to set date...
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</CalendarPopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/card-detail/DueDatePicker.tsx
|
||||
git commit -m "feat: rewrite DueDatePicker to use custom CalendarPopover"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Visual verification and final commit
|
||||
|
||||
**Step 1: Run TypeScript check**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 2: Run the dev server**
|
||||
|
||||
Run: `npx tauri dev`
|
||||
|
||||
Verify:
|
||||
- Open a card → Due Date cell shows "Click to set date..." or the current date
|
||||
- Click the cell → calendar popover appears below
|
||||
- Calendar shows correct month with today highlighted (ring)
|
||||
- Click a date → it's selected (filled accent), popover closes, cell shows formatted date
|
||||
- Click month name → month selector grid appears, click a month → returns to days
|
||||
- Click year → year selector grid appears, click a year → returns to days
|
||||
- Left/right arrows navigate months
|
||||
- "Today" button selects today and closes
|
||||
- "Clear" button in popover footer removes the date and closes
|
||||
- × button in cell header clears the date without opening calendar
|
||||
- Past dates are dimmed but clickable
|
||||
- Overdue dates show in red
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: custom date picker with calendar popover complete"
|
||||
```
|
||||
@@ -1,188 +0,0 @@
|
||||
# Motion, Dark Mode & Custom Titlebar Design
|
||||
|
||||
**Date:** 2026-02-15
|
||||
**Goal:** Add playful bouncy animations everywhere, lighten dark mode for HDR monitors, and implement custom window decorations merged into the TopBar
|
||||
**Approach:** Centralized motion system + CSS variable tuning + Tauri decoration override
|
||||
|
||||
---
|
||||
|
||||
## 1. Motion System Foundation
|
||||
|
||||
Create `src/lib/motion.ts` — shared animation config imported by all components.
|
||||
|
||||
### Spring Presets (Bouncy Profile)
|
||||
- **bouncy** — stiffness: 400, damping: 15, mass: 0.8 (main preset, visible overshoot)
|
||||
- **snappy** — stiffness: 500, damping: 20 (micro-interactions — buttons, toggles)
|
||||
- **gentle** — stiffness: 200, damping: 20 (larger elements — modals, page transitions)
|
||||
- **wobbly** — stiffness: 300, damping: 10 (playful emphasis — toasts, notifications)
|
||||
|
||||
### Reusable Variants
|
||||
- `fadeSlideUp` — enters from below with opacity fade (cards, list items)
|
||||
- `fadeSlideDown` — enters from above (dropdowns, menus)
|
||||
- `scaleIn` — scales from 0.9 to 1 with bounce (modals, popovers)
|
||||
- `staggerContainer` — parent variant that staggers children
|
||||
|
||||
### Stagger Helper
|
||||
`staggerChildren(delay = 0.04)` — generates parent transition variants for cascading entrances.
|
||||
|
||||
---
|
||||
|
||||
## 2. Component-by-Component Motion Rollout
|
||||
|
||||
### Page Transitions (App.tsx)
|
||||
- Wrap view switch in `AnimatePresence mode="wait"`
|
||||
- Board-list exits with fade+slide-left, board enters with fade+slide-right
|
||||
- Uses `gentle` spring
|
||||
|
||||
### Board List (BoardList.tsx)
|
||||
- Board cards stagger in on mount using `staggerContainer`
|
||||
- Each BoardCard uses `fadeSlideUp` entrance
|
||||
- Empty state fades in
|
||||
|
||||
### Board View (BoardView.tsx)
|
||||
- Columns stagger in from left to right on mount (0.06s delay each)
|
||||
- Cards within each column stagger in (0.03s delay)
|
||||
|
||||
### Card Thumbnails (CardThumbnail.tsx)
|
||||
- Migrate existing spring to shared `bouncy` preset
|
||||
- `whileHover` scale 1.02 with shadow elevation
|
||||
- `whileTap` scale 0.98
|
||||
|
||||
### Card Detail Modal (CardDetailModal.tsx)
|
||||
- **Shared layout animation** — CardThumbnail gets `layoutId={card-${card.id}}`, modal wrapper gets same layoutId
|
||||
- Card morphs into the modal on open — hero transition
|
||||
- Backdrop blurs in with animated opacity + backdropFilter
|
||||
- Modal content sections stagger in after layout animation
|
||||
|
||||
### Column Header (ColumnHeader.tsx)
|
||||
- Dropdown menu items stagger in with `fadeSlideDown`
|
||||
|
||||
### TopBar
|
||||
- Buttons have `whileHover` and `whileTap` micro-animations
|
||||
- Saving status text fades in/out with AnimatePresence
|
||||
|
||||
### Toast Notifications (ToastContainer.tsx)
|
||||
- Migrate to `wobbly` spring for extra personality
|
||||
- Exit slides down + fades
|
||||
|
||||
### Settings Dialog
|
||||
- Tab content crossfades with AnimatePresence
|
||||
- Accent color swatches have `whileHover` scale pulse
|
||||
|
||||
### Command Palette
|
||||
- Results stagger in as you type
|
||||
|
||||
---
|
||||
|
||||
## 3. Gesture-Reactive Drag & Drop
|
||||
|
||||
Override dnd-kit's default drag overlay with Framer Motion-powered custom overlay.
|
||||
|
||||
- **On drag start:** Card lifts with `scale: 1.05`, box-shadow, slight rotate based on grab offset
|
||||
- **During drag:** Card tilts based on pointer velocity (useMotionValue + useTransform). Max tilt: ~5 degrees
|
||||
- **On drop:** Spring back to `scale: 1, rotate: 0`. Target column cards spring apart using `layout` prop
|
||||
- dnd-kit handles position/sorting logic; we layer gesture transforms on top
|
||||
|
||||
---
|
||||
|
||||
## 4. Dark Mode — Subtle Lift for HDR
|
||||
|
||||
### Pylon Dark Variables (in `.dark {}`)
|
||||
|
||||
| Variable | Current | New |
|
||||
|----------|---------|-----|
|
||||
| `--pylon-bg` | `oklch(18% 0.01 50)` | `oklch(25% 0.012 50)` |
|
||||
| `--pylon-surface` | `oklch(22% 0.01 50)` | `oklch(29% 0.012 50)` |
|
||||
| `--pylon-column` | `oklch(20% 0.012 50)` | `oklch(27% 0.014 50)` |
|
||||
| `--pylon-text` | `oklch(90% 0.01 50)` | `oklch(92% 0.01 50)` |
|
||||
| `--pylon-text-secondary` | `oklch(55% 0.01 50)` | `oklch(58% 0.01 50)` |
|
||||
| `--pylon-accent` | `oklch(60% 0.12 160)` | `oklch(62% 0.13 160)` |
|
||||
| `--pylon-danger` | `oklch(60% 0.18 25)` | `oklch(62% 0.18 25)` |
|
||||
|
||||
### Shadcn Dark Variables
|
||||
|
||||
| Variable | Current | New |
|
||||
|----------|---------|-----|
|
||||
| `--background` | `oklch(0.145 0 0)` | `oklch(0.22 0 0)` |
|
||||
| `--card`, `--popover` | `oklch(0.205 0 0)` | `oklch(0.27 0 0)` |
|
||||
| `--secondary`, `--muted`, `--accent` | `oklch(0.269 0 0)` | `oklch(0.32 0 0)` |
|
||||
| `--border` | `oklch(1 0 0 / 10%)` | `oklch(1 0 0 / 12%)` |
|
||||
| `--input` | `oklch(1 0 0 / 15%)` | `oklch(1 0 0 / 18%)` |
|
||||
| `--sidebar` | `oklch(0.205 0 0)` | `oklch(0.27 0 0)` |
|
||||
| `--sidebar-accent` | `oklch(0.269 0 0)` | `oklch(0.32 0 0)` |
|
||||
|
||||
Color scheme (hue 50 warmth) preserved. Slight chroma bump for HDR vibrancy.
|
||||
|
||||
---
|
||||
|
||||
## 5. Custom Window Titlebar
|
||||
|
||||
### Tauri Configuration
|
||||
Set `"decorations": false` in `tauri.conf.json` to remove native OS titlebar.
|
||||
|
||||
### TopBar Integration
|
||||
Window controls added to far right of existing TopBar, after a thin vertical separator:
|
||||
|
||||
```
|
||||
[Back] .......... [Board Title] .......... [Undo][Redo][Settings][Save][Search][Gear] | [—][□][×]
|
||||
```
|
||||
|
||||
### WindowControls Component
|
||||
Inline in TopBar or extracted to `src/components/layout/WindowControls.tsx`.
|
||||
|
||||
**Buttons:**
|
||||
- Minimize: `Minus` icon from Lucide → `getCurrentWindow().minimize()`
|
||||
- Maximize/Restore: `Square` / `Copy` icon → `getCurrentWindow().toggleMaximize()`
|
||||
- Close: `X` icon → `getCurrentWindow().close()`
|
||||
|
||||
**Styling:**
|
||||
- 32x32px hit area, 16x16px icons
|
||||
- Default: `text-pylon-text-secondary`
|
||||
- Hover: Background with accent at 10% opacity
|
||||
- Close hover: `pylon-danger` at 15% opacity (red highlight — convention)
|
||||
- All have Framer Motion `whileHover` and `whileTap` springs
|
||||
|
||||
**State tracking:**
|
||||
- Listen to `getCurrentWindow().onResized()` for maximize state
|
||||
- Query `isMaximized()` on mount for initial icon
|
||||
- `data-tauri-drag-region` stays on header; window buttons do NOT propagate drag
|
||||
|
||||
---
|
||||
|
||||
## Files Affected
|
||||
|
||||
### New Files
|
||||
- `src/lib/motion.ts` — shared spring presets, variants, helpers
|
||||
|
||||
### Modified Files
|
||||
- `src/App.tsx` — AnimatePresence page transitions
|
||||
- `src/index.css` — dark mode color value updates
|
||||
- `src/components/layout/TopBar.tsx` — window controls, motion on buttons
|
||||
- `src/components/layout/AppShell.tsx` — support page transition wrapper
|
||||
- `src/components/boards/BoardList.tsx` — stagger animation on board cards
|
||||
- `src/components/boards/BoardCard.tsx` — fadeSlideUp entrance, hover/tap
|
||||
- `src/components/board/BoardView.tsx` — column stagger, gesture-reactive drag overlay
|
||||
- `src/components/board/KanbanColumn.tsx` — card stagger, layout animation for reorder
|
||||
- `src/components/board/CardThumbnail.tsx` — shared layoutId, bouncy preset, hover/tap
|
||||
- `src/components/board/ColumnHeader.tsx` — dropdown animation
|
||||
- `src/components/card-detail/CardDetailModal.tsx` — shared layout animation (hero), content stagger
|
||||
- `src/components/toast/ToastContainer.tsx` — wobbly spring
|
||||
- `src/components/settings/SettingsDialog.tsx` — tab crossfade, swatch hover
|
||||
- `src/components/command-palette/CommandPalette.tsx` — result stagger
|
||||
- `src/components/shortcuts/ShortcutHelpModal.tsx` — entrance animation
|
||||
- `src-tauri/tauri.conf.json` — decorations: false
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Motion system foundation (`src/lib/motion.ts`)
|
||||
2. Dark mode CSS variable updates
|
||||
3. Custom titlebar (Tauri config + WindowControls)
|
||||
4. Page transitions (App.tsx + AnimatePresence)
|
||||
5. Board list animations (stagger + BoardCard)
|
||||
6. Board view column stagger + card stagger
|
||||
7. Card thumbnail hover/tap + shared layoutId
|
||||
8. Card detail modal shared layout animation
|
||||
9. Gesture-reactive drag overlay
|
||||
10. Micro-interactions (TopBar, ColumnHeader dropdowns)
|
||||
11. Toast, Settings, Command Palette, ShortcutHelp animations
|
||||
12. Polish pass — verify all springs feel cohesive, test reduced-motion
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,345 +0,0 @@
|
||||
# OpenPylon — Local-First Kanban Board Design Document
|
||||
|
||||
**Date:** 2026-02-15
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
OpenPylon is a local-first Kanban board desktop app for personal projects and task management. No account required, no cloud sync — a fast, drag-and-drop board that saves to local JSON files. Replaces Trello ($5-10/mo), Asana ($11/mo), Monday.com ($9/mo).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Tauri (Rust backend, system webview, ~5MB bundle)
|
||||
- **Frontend:** React + TypeScript
|
||||
- **State:** Zustand (monolithic store per board, debounced JSON persistence)
|
||||
- **Styling:** Tailwind CSS + shadcn/ui
|
||||
- **Drag & Drop:** dnd-kit
|
||||
- **Undo/Redo:** zundo (Zustand temporal middleware)
|
||||
|
||||
## Architecture: Monolithic State Store
|
||||
|
||||
Single Zustand store per board, loaded entirely into memory from JSON on open. All mutations go through the store and auto-save back to disk with debounced writes (500ms). Board data is small (even 500 cards is ~1MB of JSON), so full in-memory loading is fine.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
~/.openpylon/
|
||||
├── settings.json # Global app settings
|
||||
├── boards/
|
||||
│ ├── board-<ulid>.json # One file per board
|
||||
│ └── board-<ulid>.json
|
||||
└── attachments/
|
||||
└── board-<ulid>/ # Copied attachments (when setting enabled)
|
||||
└── <ulid>-filename.png
|
||||
```
|
||||
|
||||
### Schema
|
||||
|
||||
```typescript
|
||||
interface Board {
|
||||
id: string; // ULID
|
||||
title: string;
|
||||
color: string; // Accent color stripe for board list
|
||||
createdAt: string; // ISO 8601
|
||||
updatedAt: string;
|
||||
columns: Column[];
|
||||
cards: Record<string, Card>; // Flat map, referenced by columns
|
||||
labels: Label[]; // Board-level label definitions
|
||||
settings: BoardSettings; // Per-board settings (attachment mode, etc.)
|
||||
}
|
||||
|
||||
interface Column {
|
||||
id: string;
|
||||
title: string;
|
||||
cardIds: string[]; // Ordered references
|
||||
width: "narrow" | "standard" | "wide"; // Collapsible widths
|
||||
}
|
||||
|
||||
interface Card {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string; // Markdown
|
||||
labels: string[]; // Label IDs
|
||||
checklist: ChecklistItem[];
|
||||
dueDate: string | null;
|
||||
attachments: Attachment[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Label {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
interface Attachment {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string; // Absolute (link mode) or relative (copy mode)
|
||||
mode: "link" | "copy";
|
||||
}
|
||||
```
|
||||
|
||||
**Key decisions:**
|
||||
- ULIDs instead of UUIDs — sortable by creation time, no collisions
|
||||
- Cards stored flat (`cards: Record<string, Card>`) with columns referencing via `cardIds[]` — drag-and-drop reordering is a simple array splice
|
||||
- Labels defined at board level, referenced by ID on cards
|
||||
|
||||
---
|
||||
|
||||
## State Management & Persistence
|
||||
|
||||
### Stores
|
||||
|
||||
- `useBoardStore` — active board's full state + all mutation actions
|
||||
- `useAppStore` — global app state: theme, recent boards, settings, current view
|
||||
|
||||
### Persistence Flow
|
||||
|
||||
1. Board open: Tauri `fs.readTextFile()` → parse JSON → validate with Zod → hydrate Zustand store
|
||||
2. On mutation: store subscribes to itself, debounces writes at 500ms
|
||||
3. On board close / app quit: immediate flush via Tauri `window.onCloseRequested`
|
||||
|
||||
### Auto-Backup
|
||||
|
||||
On every successful save, rotate previous version to `board-<ulid>.backup.json` (one backup per board).
|
||||
|
||||
### Undo/Redo
|
||||
|
||||
zundo (Zustand temporal middleware) tracks state history. Ctrl+Z / Ctrl+Shift+Z. Capped at ~50 steps.
|
||||
|
||||
### Search
|
||||
|
||||
Global search reads all board JSON files from disk and searches card titles + descriptions. For personal Kanban (5-20 boards), this is instant. No index needed.
|
||||
|
||||
---
|
||||
|
||||
## UI Design
|
||||
|
||||
### Aesthetic Direction: Industrial Utility with Warmth
|
||||
|
||||
"Pylon" evokes infrastructure and strength. The app should feel like a well-made tool — a carpenter's organized workshop, not an IKEA showroom.
|
||||
|
||||
### Color Palette (OKLCH)
|
||||
|
||||
**Light mode:**
|
||||
- Background: `oklch(97% 0.005 80)` — warm off-white
|
||||
- Surface/cards: `oklch(99% 0.003 80)` — barely-there warmth
|
||||
- Column background: `oklch(95% 0.008 80)` — subtle sand
|
||||
- Primary accent: `oklch(55% 0.12 160)` — muted teal-green
|
||||
- Text primary: `oklch(25% 0.015 50)` — warm near-black
|
||||
- Text secondary: `oklch(55% 0.01 50)` — warm gray
|
||||
- Danger/overdue: `oklch(55% 0.18 25)` — terracotta red
|
||||
|
||||
**Dark mode:**
|
||||
- Background: `oklch(18% 0.01 50)` — warm dark
|
||||
- Surface: `oklch(22% 0.01 50)`
|
||||
- Cards: `oklch(25% 0.012 50)`
|
||||
|
||||
### Typography
|
||||
|
||||
- **Headings:** Instrument Serif — heritage serif with personality
|
||||
- **Body/cards:** Satoshi — clean geometric sans, readable at small sizes
|
||||
- **Metadata (labels, dates, counts):** Geist Mono — reinforces "tool" identity
|
||||
|
||||
**Scale:**
|
||||
- Board title: `clamp(1.25rem, 2vw, 1.5rem)`, bold
|
||||
- Column headers: `0.8rem` uppercase, `letter-spacing: 0.08em`, weight 600
|
||||
- Card titles: `0.875rem`, weight 500
|
||||
- Card metadata: `0.75rem` monospace
|
||||
|
||||
### App Shell Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ ← Boards Sprint Planning ⌘K ⚙ │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ TO DO IN PROGRESS DONE │
|
||||
│ ───── 4 ─────────── 2 ──── 3 │
|
||||
│ │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ Card title │ │ Card title │ │ Card title │ │
|
||||
│ │ 🟢🔵 Feb28 ▮▮▯│ │ 🟢 ▮▮▮▮│ │ ▮▮▯ │ │
|
||||
│ └────────────┘ └────────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ + Add card + Add card + Add card │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key layout decisions:**
|
||||
- No vertical column dividers — whitespace gaps (24-32px) instead
|
||||
- Column headers: uppercase, tracked-out, small — like section dividers
|
||||
- Card count as quiet number beside underline, not a badge
|
||||
- Command palette (`Ctrl+K`) replaces search icon
|
||||
- Theme toggle lives in settings, not top bar
|
||||
- Board title is click-to-edit inline, no `[edit]` button
|
||||
|
||||
### Card Design
|
||||
|
||||
- **Label dots:** 8px colored circles in a row, hover for tooltip with name
|
||||
- **Due date:** Monospace, right-aligned, no icon. Overdue turns terracotta with subtle tint.
|
||||
- **Checklist:** Tiny progress bar (filled/unfilled blocks), not "3/4" text
|
||||
- **No card borders.** Subtle shadow (`0 1px 3px oklch(0% 0 0 / 0.06)`) for separation.
|
||||
- **Hover:** `translateY(-1px)` lift with faint shadow deepening, spring physics, 150ms
|
||||
- **Drag ghost:** 5-degree rotation, `scale(1.03)`, `opacity(0.9)`, elevated shadow
|
||||
|
||||
### Column Widths
|
||||
|
||||
Columns support three widths: narrow (titles only), standard, wide (active focus). Double-click header to cycle. Adds spatial meaning.
|
||||
|
||||
### Card Detail Modal (Two-Panel)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Fix auth token refresh ✕ │
|
||||
│ │
|
||||
│ ┌─────────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ │ │ LABELS │ │
|
||||
│ │ Markdown description │ │ 🟢 Bug 🔵 Backend │ │
|
||||
│ │ with live preview │ │ │ │
|
||||
│ │ │ │ DUE DATE │ │
|
||||
│ │ │ │ Feb 28, 2026 │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ CHECKLIST 3/4 │ │
|
||||
│ │ │ │ ✓ Research APIs │ │
|
||||
│ │ │ │ ✓ Write tests │ │
|
||||
│ │ │ │ ✓ Implement │ │
|
||||
│ │ │ │ ○ Code review │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ ATTACHMENTS │ │
|
||||
│ │ │ │ spec.pdf │ │
|
||||
│ └─────────────────────────┘ └────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Left panel (60%): Title (inline edit) + markdown description (edit/preview toggle)
|
||||
- Right sidebar (40%): Labels, due date, checklist, attachments. Each collapsible.
|
||||
- No save button — auto-persist with subtle "Saved" indicator
|
||||
- **Card-to-modal morph animation** via Framer Motion `layoutId` — modal grows from card position
|
||||
|
||||
### Command Palette (`Ctrl+K`)
|
||||
|
||||
Using shadcn's `cmdk` component:
|
||||
- Search all cards across all boards by title/description
|
||||
- Switch between boards
|
||||
- Create new cards/boards
|
||||
- Toggle theme
|
||||
- Open settings
|
||||
- Navigate to specific column
|
||||
- Filter current board by label/date
|
||||
|
||||
### Board List (Home Screen)
|
||||
|
||||
Grid of board cards with:
|
||||
- Color accent stripe at top (user-chosen per board)
|
||||
- Title, card count, column count
|
||||
- Relative time ("2 min ago", "Yesterday")
|
||||
- Right-click context menu: Duplicate, Export, Delete, Change color
|
||||
- Empty state: "Create your first board" + single button
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Global
|
||||
|
||||
| Action | Shortcut |
|
||||
|---|---|
|
||||
| Command palette | `Ctrl+K` |
|
||||
| New card in focused column | `N` |
|
||||
| New board | `Ctrl+N` |
|
||||
| Undo | `Ctrl+Z` |
|
||||
| Redo | `Ctrl+Shift+Z` |
|
||||
| Settings | `Ctrl+,` |
|
||||
| Close modal / cancel | `Escape` |
|
||||
| Save & close card detail | `Ctrl+Enter` |
|
||||
|
||||
### Board Navigation
|
||||
|
||||
- `Arrow Left/Right` — focus prev/next column
|
||||
- `Arrow Up/Down` — focus prev/next card in column
|
||||
- `Enter` — open focused card detail
|
||||
- `Space` — quick-toggle first unchecked checklist item
|
||||
- `D` — set/edit due date on focused card
|
||||
- `L` — open label picker on focused card
|
||||
|
||||
### Drag-and-Drop Keyboard
|
||||
|
||||
dnd-kit keyboard sensor: `Space` to pick up, arrows to move, `Space` to drop, `Escape` to cancel. Movements announced via `aria-live` region.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
- All interactive elements reachable via Tab
|
||||
- Focus indicators: `2px solid` accent color, `2px offset`, visible in both themes
|
||||
- Modal focus trapping
|
||||
- Column/card counts via `aria-label`
|
||||
- `prefers-reduced-motion`: all animations collapse to instant
|
||||
- `prefers-contrast`: increased shadow intensity, subtle borders restored
|
||||
- Minimum touch target: 44x44px on all buttons
|
||||
|
||||
---
|
||||
|
||||
## Import/Export
|
||||
|
||||
### Export
|
||||
|
||||
- **JSON:** The board file itself is the export. Save As dialog.
|
||||
- **CSV:** Flattened — one row per card with all fields.
|
||||
- **ZIP:** For boards with copy-mode attachments — board JSON + attachments folder.
|
||||
|
||||
### Import
|
||||
|
||||
- **OpenPylon JSON:** Drop file onto board list or use File > Import. Schema validation + preview before importing.
|
||||
- **CSV:** Import wizard — map columns, preview rows, choose target board.
|
||||
- **Trello JSON:** Dedicated adapter mapping Trello schema to OpenPylon.
|
||||
- **Drag-and-drop import:** Dropping `.json` or `.csv` anywhere triggers import flow.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Corrupted board file:** Recovery dialog — inspect in file explorer or restore from `.backup.json`
|
||||
- **Data directory inaccessible:** Dialog to choose new directory on startup
|
||||
- **Disk full:** Inline toast, changes preserved in memory, retry every 30s
|
||||
- **File locked:** Warning dialog
|
||||
- **Schema migration:** On load, validate with Zod, add missing fields with defaults, preserve unknown fields
|
||||
- **Drag edge cases:** Empty column droppable, drop outside cancels with spring return
|
||||
|
||||
## Micro-Interactions
|
||||
|
||||
| Interaction | Animation | Duration |
|
||||
|---|---|---|
|
||||
| Card appears (new) | Fade in + slide down | 200ms, spring |
|
||||
| Card drag start | Lift + rotate + shadow | 150ms |
|
||||
| Card drop | Settle with slight bounce | 250ms, spring |
|
||||
| Column add | Slide in from right | 300ms |
|
||||
| Card detail open | Morph from card position | 250ms |
|
||||
| Card detail close | Reverse morph to card | 200ms |
|
||||
| Checklist check | Strikethrough sweep + fill | 200ms |
|
||||
| Board switch | Crossfade | 300ms |
|
||||
|
||||
All animations respect `prefers-reduced-motion`.
|
||||
|
||||
---
|
||||
|
||||
## Empty States
|
||||
|
||||
- **No boards:** "Create your first board" + button + minimal illustration
|
||||
- **Empty column:** Dashed border area + "Drag cards here or click + to add"
|
||||
- **No search results:** "No matches" + suggestion to broaden
|
||||
- **No labels:** "Create your first label" + color swatches
|
||||
@@ -1,235 +0,0 @@
|
||||
# OpenPylon Visual Glow-Up Design
|
||||
|
||||
**Date:** 2026-02-15
|
||||
**Goal:** Transform OpenPylon from functional-but-bare to visually polished and delightful
|
||||
**Approach:** Settings-first foundation — build the settings infrastructure, then layer visual features on top
|
||||
|
||||
---
|
||||
|
||||
## 1. Settings Model & Infrastructure
|
||||
|
||||
Expand `AppSettings` in `src/types/settings.ts`:
|
||||
|
||||
```typescript
|
||||
export interface AppSettings {
|
||||
theme: "light" | "dark" | "system";
|
||||
dataDirectory: string | null;
|
||||
recentBoardIds: string[];
|
||||
|
||||
// Appearance
|
||||
accentColor: string; // OKLCH hue (0-360), default "160" (teal)
|
||||
uiZoom: number; // 0.75-1.5, default 1.0
|
||||
density: "compact" | "comfortable" | "spacious";
|
||||
|
||||
// Board defaults
|
||||
defaultColumnWidth: ColumnWidth; // default "standard"
|
||||
}
|
||||
```
|
||||
|
||||
### Zoom
|
||||
Set `font-size` on `<html>` to `uiZoom * 16px`. Everything uses `rem` via Tailwind, so the entire UI scales proportionally.
|
||||
|
||||
### Accent Color
|
||||
Store an OKLCH hue value. On apply, regenerate `--pylon-accent` as `oklch(55% 0.12 {hue})` (light) / `oklch(60% 0.12 {hue})` (dark).
|
||||
|
||||
### Density
|
||||
Set CSS custom property `--density-factor` (compact=0.75, comfortable=1.0, spacious=1.25). Use it to scale padding on columns, cards, and gaps.
|
||||
|
||||
### App Store Changes
|
||||
Add `setAccentColor`, `setUiZoom`, `setDensity`, `setDefaultColumnWidth` actions to `app-store.ts`. Each saves immediately (no Save button). Add `applyAppearance()` function that applies zoom, accent, and density to the DOM — called on init and on any change.
|
||||
|
||||
---
|
||||
|
||||
## 2. Settings Panel UI
|
||||
|
||||
Transform `SettingsDialog.tsx` from a tiny modal into a tabbed panel.
|
||||
|
||||
- Widen to `sm:max-w-lg`
|
||||
- 4 tabs: **Appearance** | **Boards** | **Keyboard Shortcuts** | **About**
|
||||
- Simple button-based tab navigation (no library needed)
|
||||
|
||||
### Appearance Tab
|
||||
- **Theme** — existing 3-button toggle (unchanged)
|
||||
- **UI Zoom** — slider 75%-150% in 5% steps, live preview, reset button, shows current %
|
||||
- **Accent Color** — 10 preset OKLCH hue swatches: teal/160, blue/240, purple/300, pink/350, red/25, orange/55, yellow/85, lime/130, cyan/200, slate/achromatic. Click to apply immediately.
|
||||
- **Density** — 3-button toggle: Compact / Comfortable / Spacious
|
||||
|
||||
### Boards Tab
|
||||
- **Default column width** — 3-button toggle: Narrow / Standard / Wide
|
||||
|
||||
### Keyboard Shortcuts Tab
|
||||
- Read-only reference table, two-column: key combo (mono font) | description
|
||||
- All shortcuts: Ctrl+K, Ctrl+Z, Ctrl+Y, Ctrl+N, ?, etc.
|
||||
|
||||
### About Tab
|
||||
- App name, version, tagline
|
||||
- Link to repo (opens via Tauri shell)
|
||||
|
||||
---
|
||||
|
||||
## 3. Board Color Applied to UI
|
||||
|
||||
Currently `board.color` only shows on BoardCard in the home screen.
|
||||
|
||||
- **TopBar:** 2px bottom border in board color when viewing a board. Color dot next to board title.
|
||||
- **Column headers:** 3px top-border in board color at 30% opacity.
|
||||
- **No full background tinting** — structural accents only (borders, dots).
|
||||
|
||||
---
|
||||
|
||||
## 4. Column Colors
|
||||
|
||||
Extend `Column` interface:
|
||||
|
||||
```typescript
|
||||
export interface Column {
|
||||
id: string;
|
||||
title: string;
|
||||
cardIds: string[];
|
||||
width: ColumnWidth;
|
||||
color: string | null; // optional OKLCH hue, null = use board color
|
||||
}
|
||||
```
|
||||
|
||||
- Set via "Color" submenu in ColumnHeader dropdown (same 10 swatches + "None")
|
||||
- Column's 3px top-border uses column color when set, falls back to board color
|
||||
- Column background stays neutral
|
||||
|
||||
---
|
||||
|
||||
## 5. Card Cover Colors
|
||||
|
||||
Extend `Card` interface:
|
||||
|
||||
```typescript
|
||||
export interface Card {
|
||||
// ...existing
|
||||
coverColor: string | null; // OKLCH hue for color strip
|
||||
}
|
||||
```
|
||||
|
||||
- No image uploads for v1 — just a color bar
|
||||
- 4px colored bar at top of CardThumbnail
|
||||
- Set via swatch picker in CardDetailModal
|
||||
- Simple CSS, no layout disruption
|
||||
|
||||
---
|
||||
|
||||
## 6. Richer Card Thumbnails
|
||||
|
||||
Add to existing CardThumbnail footer row:
|
||||
|
||||
- **Attachment indicator** — paperclip icon + count (if `attachments.length > 0`)
|
||||
- **Description indicator** — text-lines icon (if `description` is non-empty)
|
||||
- **Cover color bar** — from Section 5
|
||||
|
||||
No priority badges or assignees — keeping thumbnails clean.
|
||||
|
||||
---
|
||||
|
||||
## 7. Toast Notification System
|
||||
|
||||
- `useToastStore` — Zustand store: `{ id, message, type }[]`
|
||||
- `<ToastContainer>` in App.tsx — fixed bottom-right, pills with auto-dismiss (3s + fade)
|
||||
- Types: `success` (green), `error` (red), `info` (neutral)
|
||||
- Fires on: board deleted, board exported, board imported, import failed, save error
|
||||
|
||||
---
|
||||
|
||||
## 8. Undo/Redo Buttons in TopBar
|
||||
|
||||
- Two icon buttons: RotateCcw (undo) and RotateCw (redo)
|
||||
- Placed in TopBar right section, before command palette button
|
||||
- Disabled when at start/end of history
|
||||
- Only visible in board view
|
||||
- Tooltips show keyboard shortcuts (Ctrl+Z / Ctrl+Y)
|
||||
|
||||
---
|
||||
|
||||
## 9. Keyboard Shortcut Help Modal
|
||||
|
||||
- Triggered by `?` key (when not in an input/textarea)
|
||||
- Two-column grid grouped by category: Navigation, Board, Cards
|
||||
- Same data as Settings keyboard shortcuts tab
|
||||
- Lightweight modal, dismissible with Escape or clicking outside
|
||||
|
||||
---
|
||||
|
||||
## 10. Board Backgrounds
|
||||
|
||||
Extend `BoardSettings`:
|
||||
|
||||
```typescript
|
||||
export interface BoardSettings {
|
||||
attachmentMode: "link" | "copy";
|
||||
background: "none" | "dots" | "grid" | "gradient";
|
||||
}
|
||||
```
|
||||
|
||||
- **none** — plain (current)
|
||||
- **dots** — subtle radial-gradient dot pattern, 5% opacity
|
||||
- **grid** — subtle grid lines via CSS
|
||||
- **gradient** — soft gradient using board color at 3-5% opacity
|
||||
- Set via board settings dropdown (gear icon in TopBar when viewing a board)
|
||||
|
||||
---
|
||||
|
||||
## 11. Onboarding / Empty States
|
||||
|
||||
- **First launch (zero boards):** Upgraded empty state — welcoming message, prominent "Create Board" button, secondary "Import Board" option
|
||||
- **Empty column:** Dashed-border area with "Drop or add a card" text
|
||||
- **Empty description:** "Click to add a description..." placeholder
|
||||
- **Empty checklist:** "Add your first item..." when empty
|
||||
|
||||
---
|
||||
|
||||
## 12. Polish Pass
|
||||
|
||||
- Consistent hover transitions (200ms ease) across all interactive elements
|
||||
- Verify focus rings work with all accent colors
|
||||
- Test Framer Motion springs at different zoom levels
|
||||
- Dark mode testing for all new features (column colors, card covers, backgrounds)
|
||||
- Thin, themed scrollbars on column scroll areas
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Settings model + app store actions + CSS variable application
|
||||
2. Settings panel UI (tabbed, all sections)
|
||||
3. UI zoom
|
||||
4. Accent color
|
||||
5. Density toggle
|
||||
6. Board color in UI (TopBar + column headers)
|
||||
7. Column colors
|
||||
8. Card cover colors
|
||||
9. Richer card thumbnails
|
||||
10. Toast notification system
|
||||
11. Undo/redo buttons
|
||||
12. Keyboard shortcut help modal
|
||||
13. Board backgrounds
|
||||
14. Onboarding / empty states
|
||||
15. Polish pass
|
||||
|
||||
## Files Affected
|
||||
|
||||
- `src/types/settings.ts` — expanded AppSettings
|
||||
- `src/types/board.ts` — Column.color, Card.coverColor, BoardSettings.background
|
||||
- `src/stores/app-store.ts` — new actions, applyAppearance()
|
||||
- `src/components/settings/SettingsDialog.tsx` — full rewrite (tabbed)
|
||||
- `src/index.css` — density variables, zoom hook, background patterns
|
||||
- `src/components/layout/TopBar.tsx` — board color, undo/redo buttons, board settings gear
|
||||
- `src/components/board/KanbanColumn.tsx` — column color border
|
||||
- `src/components/board/ColumnHeader.tsx` — color submenu
|
||||
- `src/components/board/CardThumbnail.tsx` — cover bar, attachment/description indicators
|
||||
- `src/components/card-detail/CardDetailModal.tsx` — cover color picker
|
||||
- `src/components/board/BoardView.tsx` — background patterns
|
||||
- `src/App.tsx` — ToastContainer, shortcut help modal, appearance init
|
||||
- `src/stores/toast-store.ts` — NEW
|
||||
- `src/components/toast/ToastContainer.tsx` — NEW
|
||||
- `src/components/shortcuts/ShortcutHelpModal.tsx` — NEW
|
||||
- `src/stores/board-store.ts` — new actions for column color, card cover
|
||||
- `src/lib/board-factory.ts` — defaults for new fields
|
||||
- `src/lib/schemas.ts` — migration for new fields
|
||||
- `src/components/boards/BoardList.tsx` — upgraded empty state
|
||||
- `src/hooks/useKeyboardShortcuts.ts` — ? key handler
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,296 +0,0 @@
|
||||
# OpenPylon: 15 Improvements Design
|
||||
|
||||
## Overview
|
||||
|
||||
15 improvements to OpenPylon organized into 5 phases (0-4), designed for incremental delivery. Each phase builds on the previous. You can ship after any phase and have a coherent improvement.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Phasing**: 4 phases (quick wins first, progressively bigger features)
|
||||
- **Data compatibility**: New fields use Zod `.default()` values. No migration code. Old boards load cleanly.
|
||||
- **Templates storage**: JSON files in `data/templates/`
|
||||
- **Backup storage**: Timestamped files in `data/backups/{boardId}/`, keep last 10
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Data Model Foundation
|
||||
|
||||
All type/schema changes that later features depend on. Done first so everything builds on a stable base.
|
||||
|
||||
### Card type additions
|
||||
|
||||
```typescript
|
||||
// Added to Card interface + cardSchema
|
||||
priority: "none" | "low" | "medium" | "high" | "urgent"; // default: "none"
|
||||
comments: Comment[]; // default: []
|
||||
|
||||
// New type + schema
|
||||
interface Comment {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Column type additions
|
||||
|
||||
```typescript
|
||||
// Added to Column interface + columnSchema
|
||||
collapsed: boolean; // default: false
|
||||
wipLimit: number | null; // default: null
|
||||
```
|
||||
|
||||
### Files touched
|
||||
|
||||
- `src/types/board.ts` — Add fields to Card, Column interfaces. Add Comment interface.
|
||||
- `src/lib/schemas.ts` — Add Zod fields with defaults to cardSchema, columnSchema. Add commentSchema.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Quick Wins
|
||||
|
||||
Minimal changes, high value. ~4 files each.
|
||||
|
||||
### #8 — Consume defaultColumnWidth Setting
|
||||
|
||||
In `board-store.ts`, `addColumn` reads `useAppStore.getState().settings.defaultColumnWidth` instead of hardcoding `"standard"`.
|
||||
|
||||
**Files**: `src/stores/board-store.ts` (1 line change)
|
||||
|
||||
### #4 — Due Date Visual Indicators
|
||||
|
||||
Replace binary overdue/not logic in `CardThumbnail` with 4-tier color system:
|
||||
|
||||
| Status | Condition | Color |
|
||||
|--------|-----------|-------|
|
||||
| Overdue | past + not today | `pylon-danger` (red) |
|
||||
| Approaching | due within 2 days | amber `oklch(65% 0.15 70)` |
|
||||
| Comfortable | due but >2 days | green `oklch(55% 0.12 145)` |
|
||||
| No date | null | `pylon-text-secondary` (gray) |
|
||||
|
||||
Helper function `getDueDateStatus(dueDate: string | null)` returns `{ color, label }`.
|
||||
|
||||
**Files**: `src/components/board/CardThumbnail.tsx`
|
||||
|
||||
### #9 — Card Aging Visualization
|
||||
|
||||
Compute days since `card.updatedAt`. Apply opacity:
|
||||
|
||||
| Days stale | Opacity |
|
||||
|------------|---------|
|
||||
| 0-7 | 1.0 |
|
||||
| 7-14 | 0.85 |
|
||||
| 14-30 | 0.7 |
|
||||
| 30+ | 0.55 |
|
||||
|
||||
Applied as inline `opacity` on the card `motion.button`.
|
||||
|
||||
**Files**: `src/components/board/CardThumbnail.tsx`
|
||||
|
||||
### #12 — Open Attachments
|
||||
|
||||
Add "Open" button to each attachment in `AttachmentSection`. Uses `open()` from `@tauri-apps/plugin-opener` (already registered).
|
||||
|
||||
**Files**: `src/components/card-detail/AttachmentSection.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Card Interactions & UI Enhancements
|
||||
|
||||
5 features that transform how cards feel to use.
|
||||
|
||||
### #2 — Card Priority Levels
|
||||
|
||||
**Thumbnail indicator**: Colored dot in footer row. Color map:
|
||||
- `none`: hidden
|
||||
- `low`: blue
|
||||
- `medium`: yellow
|
||||
- `high`: orange
|
||||
- `urgent`: red with pulse animation
|
||||
|
||||
**Detail modal**: Priority picker section in left column (like LabelPicker). Row of 5 clickable chips with colors.
|
||||
|
||||
**Files**: `CardThumbnail.tsx`, `CardDetailModal.tsx` (new PriorityPicker component inline or separate), `board-store.ts` (no new action needed — `updateCard` handles it)
|
||||
|
||||
### #5 — Card Context Menu
|
||||
|
||||
Wrap `CardThumbnail` in Radix `ContextMenu`.
|
||||
|
||||
**Menu items**:
|
||||
- Move to → (submenu listing columns except current)
|
||||
- Set priority → (submenu with 5 options)
|
||||
- Duplicate (new card with same fields, `(copy)` suffix, new ID, inserted below original)
|
||||
- Separator
|
||||
- Delete (confirmation dialog)
|
||||
|
||||
**New store action**: `duplicateCard(cardId): string` — clones card, inserts after original in same column.
|
||||
|
||||
**Files**: `CardThumbnail.tsx`, `board-store.ts`
|
||||
|
||||
### #10 — WIP Limits
|
||||
|
||||
**Column header display**: Shows `3/5` when wipLimit set. Background tint:
|
||||
- Under limit: normal
|
||||
- At limit: amber tint `oklch(75% 0.08 70 / 15%)`
|
||||
- Over limit: red tint `oklch(70% 0.08 25 / 15%)`
|
||||
|
||||
**Setting UI**: New "Set WIP Limit" item in ColumnHeader dropdown menu. Preset choices: None / 3 / 5 / 7 / 10 / Custom.
|
||||
|
||||
**New store action**: `setColumnWipLimit(columnId: string, limit: number | null)`
|
||||
|
||||
**Files**: `ColumnHeader.tsx`, `KanbanColumn.tsx`, `board-store.ts`
|
||||
|
||||
### #3 — Column Collapse/Expand
|
||||
|
||||
When `collapsed`, render a 40px-wide strip instead of full column:
|
||||
- Vertical text via `writing-mode: vertical-rl; rotate: 180deg`
|
||||
- Card count badge
|
||||
- Click to expand
|
||||
|
||||
Animate width from full to 40px using existing `animate={{ width }}` on outer `motion.div` with `springs.bouncy`.
|
||||
|
||||
**New store action**: `toggleColumnCollapse(columnId: string)`
|
||||
|
||||
**Collapse button**: Added to ColumnHeader dropdown menu + a small chevron icon on the collapsed strip.
|
||||
|
||||
**Files**: `KanbanColumn.tsx`, `ColumnHeader.tsx`, `board-store.ts`
|
||||
|
||||
### #11 — Checklist Item Reordering
|
||||
|
||||
Wrap checklist `<ul>` in `ChecklistSection` with `DndContext` + `SortableContext` (vertical strategy). Each `ChecklistRow` becomes sortable.
|
||||
|
||||
**Drag handle**: `GripVertical` icon on left of each item, visible on hover.
|
||||
|
||||
**Drop indicator**: Horizontal glow line (same as card drag — vertical list so horizontal line is correct).
|
||||
|
||||
**New store action**: `reorderChecklistItems(cardId: string, fromIndex: number, toIndex: number)`
|
||||
|
||||
**Files**: `ChecklistSection.tsx`, `board-store.ts`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Navigation & Power User Features
|
||||
|
||||
Features that make power users fall in love.
|
||||
|
||||
### #1 — Card Filtering & Quick Search
|
||||
|
||||
**Filter bar**: Slides down below TopBar. Triggered by filter icon in TopBar or `/` keyboard shortcut.
|
||||
|
||||
**Filter controls** (horizontal row):
|
||||
- Text input (debounced 200ms, title search)
|
||||
- Label multi-select dropdown (ANY match)
|
||||
- Due date dropdown: All / Overdue / Due this week / Due today / No date
|
||||
- Priority dropdown: All / Urgent / High / Medium / Low
|
||||
- Clear all button
|
||||
|
||||
**State**: Local state in `BoardView` (not persisted — filters are ephemeral).
|
||||
|
||||
**Rendering**: `KanbanColumn` receives filtered card IDs. Non-matching cards fade out. Column counts show `3 of 7` when filtering.
|
||||
|
||||
**Files**: New `FilterBar.tsx` component, `BoardView.tsx`, `KanbanColumn.tsx`, `TopBar.tsx` (filter button)
|
||||
|
||||
### #7 — Keyboard Card Navigation
|
||||
|
||||
**State**: `focusedCardId` in `BoardView` local state.
|
||||
|
||||
**Key bindings** (when no input/textarea focused):
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `J` / `ArrowDown` | Focus next card in column |
|
||||
| `K` / `ArrowUp` | Focus previous card in column |
|
||||
| `H` / `ArrowLeft` | Focus same-index card in previous column |
|
||||
| `L` / `ArrowRight` | Focus same-index card in next column |
|
||||
| `Enter` | Open focused card detail |
|
||||
| `Escape` | Clear focus / close modal |
|
||||
|
||||
**Visual**: Focused card gets `ring-2 ring-pylon-accent ring-offset-2`. Column auto-scrolls via `scrollIntoView({ block: "nearest" })`.
|
||||
|
||||
**Implementation**: `useKeyboardNavigation` hook. Passes `isFocused` through `KanbanColumn` to `CardThumbnail`.
|
||||
|
||||
**Files**: New `useKeyboardNavigation.ts` hook, `BoardView.tsx`, `KanbanColumn.tsx`, `CardThumbnail.tsx`
|
||||
|
||||
### #6 — Desktop Notifications for Due Dates
|
||||
|
||||
**Plugin**: Add `tauri-plugin-notification` to `Cargo.toml` and capabilities.
|
||||
|
||||
**Trigger**: On `useAppStore.init()`, after loading boards, scan all cards:
|
||||
- Cards due today → "You have X cards due today"
|
||||
- Cards overdue → "You have X overdue cards"
|
||||
|
||||
Batched (one notification per category). Store `lastNotificationCheck` in settings to skip if checked within last hour.
|
||||
|
||||
**Files**: `src-tauri/Cargo.toml`, `src-tauri/capabilities/default.json`, `src/stores/app-store.ts`, `src/types/settings.ts` (add `lastNotificationCheck`), `src/lib/schemas.ts`
|
||||
|
||||
### #13 — Card Comments / Activity Log
|
||||
|
||||
**UI in CardDetailModal**: New section in right column below description.
|
||||
- Scrollable comment list (newest first)
|
||||
- Each: text, relative timestamp, delete button (hover)
|
||||
- Add input: textarea + "Add" button. Enter submits, Shift+Enter newline.
|
||||
|
||||
**Store actions**: `addComment(cardId, text)`, `deleteComment(cardId, commentId)`. Comments get ULID IDs and `createdAt`.
|
||||
|
||||
**Files**: New `CommentsSection.tsx`, `CardDetailModal.tsx`, `board-store.ts`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: System Features & Infrastructure
|
||||
|
||||
Deeper features touching storage and templates.
|
||||
|
||||
### #14 — Board Templates & Saved Structures
|
||||
|
||||
**Template type**:
|
||||
```typescript
|
||||
interface BoardTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
columns: { title: string; width: ColumnWidth; color: string | null; wipLimit: number | null }[];
|
||||
labels: Label[];
|
||||
settings: BoardSettings;
|
||||
}
|
||||
```
|
||||
|
||||
**Saving**: Context menu item on `BoardCard` — "Save as Template". Prompts for name. Strips cards/timestamps. Writes to `data/templates/{id}.json`.
|
||||
|
||||
**Creating**: `NewBoardDialog` shows built-in templates (Blank, Kanban, Sprint) + user templates below a separator. Delete button (X) on user templates.
|
||||
|
||||
**Storage functions**: `listTemplates()`, `saveTemplate()`, `deleteTemplate()` in `storage.ts`. `board-factory.ts` gets `createBoardFromTemplate()`.
|
||||
|
||||
**Files**: `storage.ts`, `board-factory.ts`, `NewBoardDialog.tsx`, `BoardCard.tsx`, new `src/types/template.ts`
|
||||
|
||||
### #15 — Auto-Backup & Version History
|
||||
|
||||
**Storage**: `data/backups/{boardId}/` directory. Timestamped files: `{boardId}-{ISO timestamp}.json`.
|
||||
|
||||
**Save flow** in `board-store.ts`:
|
||||
1. Read current file as previous version
|
||||
2. Write new board to `{boardId}.json`
|
||||
3. Write previous version to `data/backups/{boardId}/{boardId}-{timestamp}.json`
|
||||
4. Prune backups beyond 10
|
||||
|
||||
**UI — Version History dialog**: Accessible from board settings dropdown menu ("Version History"). Shows:
|
||||
- List of backups sorted newest-first
|
||||
- Each entry: relative timestamp, card count, column count
|
||||
- "Restore" button with confirmation dialog
|
||||
- Current board auto-backed-up before restore (restore is reversible)
|
||||
|
||||
**Storage functions**: `listBackups(boardId)`, `restoreBackup(boardId, filename)`, `pruneBackups(boardId, keep)`.
|
||||
|
||||
**Files**: `storage.ts`, `board-store.ts`, new `VersionHistoryDialog.tsx`, `TopBar.tsx` (menu item)
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Phase 0 (data model)
|
||||
└── Phase 1 (quick wins) — no deps on Phase 0 except #8
|
||||
└── Phase 2 (card interactions) — needs priority + collapsed + wipLimit from Phase 0
|
||||
└── Phase 3 (power user) — needs priority for filtering, context menu patterns from Phase 2
|
||||
└── Phase 4 (infrastructure) — needs wipLimit in templates from Phase 0+2
|
||||
```
|
||||
|
||||
Phase 1 can actually run in parallel with Phase 0 since its features don't touch the new fields. Phases 2-4 are strictly sequential.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenPylon</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openpylon",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -2360,7 +2360,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openpylon"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openpylon"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "openpylon",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"identifier": "com.openpylon.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
10
src/App.tsx
10
src/App.tsx
@@ -13,12 +13,15 @@ import { SettingsDialog } from "@/components/settings/SettingsDialog";
|
||||
import { ToastContainer } from "@/components/toast/ToastContainer";
|
||||
import { ShortcutHelpModal } from "@/components/shortcuts/ShortcutHelpModal";
|
||||
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
|
||||
import { useAnnounceStore } from "@/hooks/useAnnounce";
|
||||
|
||||
export default function App() {
|
||||
const initialized = useAppStore((s) => s.initialized);
|
||||
const init = useAppStore((s) => s.init);
|
||||
const view = useAppStore((s) => s.view);
|
||||
const reduceMotion = useAppStore((s) => s.settings.reduceMotion);
|
||||
const boardTitle = useBoardStore((s) => s.board?.title);
|
||||
const announcement = useAnnounceStore((s) => s.message);
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
|
||||
@@ -111,6 +114,10 @@ export default function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = boardTitle ? `${boardTitle} - OpenPylon` : "OpenPylon";
|
||||
}, [boardTitle, view]);
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
setSettingsOpen(true);
|
||||
}, []);
|
||||
@@ -129,6 +136,9 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion={reduceMotion ? "always" : "user"}>
|
||||
<div aria-live="assertive" aria-atomic="true" className="sr-only">
|
||||
{announcement}
|
||||
</div>
|
||||
<AppShell>
|
||||
<AnimatePresence mode="wait">
|
||||
{view.type === "board-list" ? (
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -44,6 +44,7 @@ export function AddCardInput({ columnId, onClose }: AddCardInputProps) {
|
||||
onBlur={() => {
|
||||
if (!value.trim()) onClose();
|
||||
}}
|
||||
aria-label="Card title"
|
||||
placeholder="Card title..."
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-lg bg-pylon-surface p-3 text-sm text-pylon-text shadow-sm outline-none placeholder:text-pylon-text-secondary focus:ring-1 focus:ring-pylon-accent"
|
||||
|
||||
@@ -388,7 +388,7 @@ export function BoardView() {
|
||||
const columnIds = board.columns.map((c) => c.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Visually hidden live region for drag-and-drop announcements */}
|
||||
<div
|
||||
aria-live="polite"
|
||||
@@ -421,7 +421,7 @@ export function BoardView() {
|
||||
>
|
||||
<OverlayScrollbarsComponent
|
||||
ref={osRef}
|
||||
className="h-full"
|
||||
className="min-h-0 flex-1"
|
||||
options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { y: "hidden" } }}
|
||||
defer
|
||||
>
|
||||
@@ -511,6 +511,6 @@ export function BoardView() {
|
||||
cardId={selectedCardId}
|
||||
onClose={() => { setSelectedCardId(null); }}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,9 +46,9 @@ function getDueDateStatus(dueDate: string | null): { color: string; bgColor: str
|
||||
function getAgingOpacity(updatedAt: string): number {
|
||||
const days = (Date.now() - new Date(updatedAt).getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (days <= 7) return 1.0;
|
||||
if (days <= 14) return 0.85;
|
||||
if (days <= 30) return 0.7;
|
||||
return 0.55;
|
||||
if (days <= 14) return 0.9;
|
||||
if (days <= 30) return 0.8;
|
||||
return 0.7;
|
||||
}
|
||||
|
||||
/* ---------- Priority colors ---------- */
|
||||
@@ -175,13 +175,14 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick, isFocu
|
||||
<span
|
||||
className={`size-2.5 shrink-0 rounded-full ${card.priority === "urgent" ? "animate-pulse" : ""}`}
|
||||
style={{ backgroundColor: PRIORITY_COLORS[card.priority] }}
|
||||
title={`Priority: ${card.priority}`}
|
||||
aria-label={`Priority: ${card.priority}`}
|
||||
role="img"
|
||||
/>
|
||||
)}
|
||||
{dueDateStatus && card.dueDate && (
|
||||
<span
|
||||
className={`font-mono text-xs rounded px-1 py-0.5 ${dueDateStatus.color} ${dueDateStatus.bgColor}`}
|
||||
title={dueDateStatus.label}
|
||||
aria-label={`Due: ${format(new Date(card.dueDate), "MMM d")} - ${dueDateStatus.label}`}
|
||||
>
|
||||
{format(new Date(card.dueDate), "MMM d")}
|
||||
</span>
|
||||
@@ -193,7 +194,7 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick, isFocu
|
||||
<DescriptionPreview description={card.description} />
|
||||
)}
|
||||
{card.attachments.length > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-pylon-text-secondary">
|
||||
<span className="flex items-center gap-0.5 text-pylon-text-secondary" aria-label={`${card.attachments.length} attachment${card.attachments.length !== 1 ? "s" : ""}`} role="img">
|
||||
<Paperclip className="size-3" />
|
||||
<span className="font-mono text-xs">{card.attachments.length}</span>
|
||||
</span>
|
||||
@@ -343,6 +344,8 @@ function DescriptionPreview({ description }: { description: string }) {
|
||||
ref={iconRef}
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
aria-label="Has description"
|
||||
role="img"
|
||||
>
|
||||
<AlignLeft className="size-3 text-pylon-text-secondary" />
|
||||
{createPortal(
|
||||
|
||||
@@ -8,7 +8,7 @@ export function ChecklistBar({ checklist }: ChecklistBarProps) {
|
||||
if (checklist.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-px">
|
||||
<div className="flex items-center gap-px" aria-label={`Checklist: ${checklist.filter(i => i.checked).length} of ${checklist.length} complete`} role="img">
|
||||
{checklist.map((item) => (
|
||||
<span
|
||||
key={item.id}
|
||||
|
||||
@@ -88,6 +88,7 @@ export function ColumnHeader({ column, cardCount, filteredCount }: ColumnHeaderP
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label="Column title"
|
||||
className="h-5 w-full bg-transparent font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary outline-none"
|
||||
/>
|
||||
) : (
|
||||
@@ -117,7 +118,8 @@ export function ColumnHeader({ column, cardCount, filteredCount }: ColumnHeaderP
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0 text-pylon-text-secondary opacity-0 transition-opacity group-hover/column:opacity-100 hover:text-pylon-text"
|
||||
className="shrink-0 text-pylon-text-secondary opacity-0 transition-opacity group-hover/column:opacity-100 focus-visible:opacity-100 hover:text-pylon-text"
|
||||
aria-label="Column options"
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
@@ -166,6 +168,7 @@ export function ColumnHeader({ column, cardCount, filteredCount }: ColumnHeaderP
|
||||
outlineOffset: "1px",
|
||||
}}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,7 @@ export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBar
|
||||
value={textDraft}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
placeholder="Search cards..."
|
||||
aria-label="Search cards"
|
||||
className="h-5 w-40 bg-transparent text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60"
|
||||
/>
|
||||
</div>
|
||||
@@ -90,6 +91,8 @@ export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBar
|
||||
<button
|
||||
key={label.id}
|
||||
onClick={() => toggleLabel(label.id)}
|
||||
aria-label={`Filter by label: ${label.name}`}
|
||||
aria-pressed={filters.labels.includes(label.id)}
|
||||
className={`rounded-full px-2 py-0.5 text-xs transition-all ${
|
||||
filters.labels.includes(label.id)
|
||||
? "text-white"
|
||||
@@ -107,6 +110,7 @@ export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBar
|
||||
<select
|
||||
value={filters.dueDate}
|
||||
onChange={(e) => onChange({ ...filters, dueDate: e.target.value as FilterState["dueDate"] })}
|
||||
aria-label="Filter by due date"
|
||||
className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
|
||||
>
|
||||
<option value="all">All dates</option>
|
||||
@@ -120,6 +124,7 @@ export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBar
|
||||
<select
|
||||
value={filters.priority}
|
||||
onChange={(e) => onChange({ ...filters, priority: e.target.value as FilterState["priority"] })}
|
||||
aria-label="Filter by priority"
|
||||
className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
|
||||
>
|
||||
<option value="all">All priorities</option>
|
||||
@@ -137,7 +142,7 @@ export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBar
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon-xs" onClick={onClose} className="text-pylon-text-secondary hover:text-pylon-text">
|
||||
<Button variant="ghost" size="icon-xs" onClick={onClose} aria-label="Close filter bar" className="text-pylon-text-secondary hover:text-pylon-text">
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,8 @@ export function LabelDots({ labelIds, boardLabels }: LabelDotsProps) {
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: label.color }}
|
||||
aria-label={label.name}
|
||||
role="img"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label.name}</TooltipContent>
|
||||
|
||||
@@ -183,6 +183,7 @@ export function BoardCard({ board, sortable = false }: BoardCardProps) {
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
aria-label={`Open board: ${board.title} - ${board.cardCount} cards, ${board.columnCount} columns, updated ${relativeTime}`}
|
||||
className="group flex w-full flex-col rounded-lg bg-pylon-surface shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md text-left"
|
||||
>
|
||||
{/* Color accent stripe */}
|
||||
|
||||
@@ -170,6 +170,7 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
|
||||
variant={template === t && !selectedUserTemplate ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => { setTemplate(t); setSelectedUserTemplate(null); }}
|
||||
aria-pressed={template === t && !selectedUserTemplate}
|
||||
className="capitalize"
|
||||
>
|
||||
{t}
|
||||
@@ -182,6 +183,7 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
|
||||
variant={selectedUserTemplate?.id === ut.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => { setSelectedUserTemplate(ut); setColor(ut.color); }}
|
||||
aria-pressed={selectedUserTemplate?.id === ut.id}
|
||||
>
|
||||
<span
|
||||
className="inline-block size-2 rounded-full shrink-0"
|
||||
@@ -193,7 +195,7 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
|
||||
type="button"
|
||||
onClick={() => handleDeleteTemplate(ut.id)}
|
||||
className="rounded p-0.5 text-pylon-text-secondary opacity-0 hover:opacity-100 hover:bg-pylon-danger/10 hover:text-pylon-danger transition-opacity"
|
||||
title="Delete template"
|
||||
aria-label="Delete template"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
|
||||
@@ -52,6 +52,7 @@ export function AttachmentSection({
|
||||
size="icon-xs"
|
||||
onClick={handleAdd}
|
||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||
aria-label="Add attachment"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
@@ -71,14 +72,14 @@ export function AttachmentSection({
|
||||
</span>
|
||||
<button
|
||||
onClick={() => openPath(att.path)}
|
||||
className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-accent/10 hover:text-pylon-accent group-hover/att:opacity-100"
|
||||
className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-accent/10 hover:text-pylon-accent group-hover/att:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Open attachment"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeAttachment(cardId, att.id)}
|
||||
className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/att:opacity-100"
|
||||
className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/att:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<X className="size-3" />
|
||||
|
||||
@@ -109,6 +109,7 @@ export function CalendarPopover({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Previous month"
|
||||
onClick={() => setViewDate((d) => subMonths(d, 1))}
|
||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||
>
|
||||
@@ -118,12 +119,14 @@ export function CalendarPopover({
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === "months" ? "days" : "months")}
|
||||
aria-label="Select month"
|
||||
className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
|
||||
>
|
||||
{format(viewDate, "MMMM")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === "years" ? "days" : "years")}
|
||||
aria-label="Select year"
|
||||
className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
|
||||
>
|
||||
{format(viewDate, "yyyy")}
|
||||
@@ -133,6 +136,7 @@ export function CalendarPopover({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Next month"
|
||||
onClick={() => setViewDate((d) => addMonths(d, 1))}
|
||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||
>
|
||||
@@ -164,7 +168,7 @@ export function CalendarPopover({
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
<div className="grid grid-cols-7" role="grid" aria-label="Calendar days">
|
||||
{calendarDays.map((day) => {
|
||||
const inMonth = isSameMonth(day, viewDate);
|
||||
const today = isTodayFn(day);
|
||||
@@ -179,6 +183,8 @@ export function CalendarPopover({
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => handleSelectDate(day)}
|
||||
aria-selected={selected}
|
||||
aria-label={format(day, "EEEE, MMMM d, yyyy")}
|
||||
className={`flex h-9 items-center justify-center rounded-lg text-sm transition-colors
|
||||
${selected
|
||||
? "bg-pylon-accent font-medium text-white"
|
||||
|
||||
@@ -28,6 +28,22 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const instant = { duration: 0 };
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
triggerRef.current = document.activeElement;
|
||||
const timer = setTimeout(() => {
|
||||
modalRef.current?.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
} else if (triggerRef.current instanceof HTMLElement) {
|
||||
triggerRef.current.focus();
|
||||
triggerRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && card && cardId && (
|
||||
@@ -48,6 +64,11 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
ref={modalRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="card-detail-title"
|
||||
layoutId={prefersReducedMotion ? undefined : `card-${cardId}`}
|
||||
className="relative w-full max-w-4xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl"
|
||||
initial={prefersReducedMotion ? { opacity: 0 } : undefined}
|
||||
@@ -259,6 +280,7 @@ function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps)
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label="Card title"
|
||||
className={`flex-1 font-heading text-xl bg-transparent outline-none border-b pb-0.5 ${
|
||||
hasColor ? "text-white border-white/50" : "text-pylon-text border-pylon-accent"
|
||||
}`}
|
||||
@@ -268,6 +290,7 @@ function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps)
|
||||
|
||||
return (
|
||||
<h2
|
||||
id="card-detail-title"
|
||||
onClick={() => setEditing(true)}
|
||||
className={`flex-1 cursor-pointer font-heading text-xl transition-colors ${textColor} ${hoverColor}`}
|
||||
>
|
||||
@@ -309,6 +332,7 @@ function CoverColorPicker({
|
||||
onClick={() => updateCard(cardId, { coverColor: null })}
|
||||
className="flex size-6 items-center justify-center rounded-full border border-border text-xs text-pylon-text-secondary hover:bg-pylon-column"
|
||||
title="None"
|
||||
aria-label="No cover color"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -324,6 +348,7 @@ function CoverColorPicker({
|
||||
outlineOffset: "1px",
|
||||
}}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -121,6 +121,7 @@ export function ChecklistSection({ cardId, checklist }: ChecklistSectionProps) {
|
||||
onChange={(e) => setNewItemText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add item..."
|
||||
aria-label="New checklist item"
|
||||
className="h-7 flex-1 rounded-md bg-pylon-column px-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:ring-1 focus:ring-pylon-accent"
|
||||
/>
|
||||
</div>
|
||||
@@ -176,7 +177,8 @@ function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps)
|
||||
{...attributes}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 cursor-grab text-pylon-text-secondary opacity-0 group-hover/item:opacity-100"
|
||||
className="shrink-0 cursor-grab text-pylon-text-secondary opacity-0 group-hover/item:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Reorder item"
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-3" />
|
||||
@@ -185,6 +187,7 @@ function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps)
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onChange={onToggle}
|
||||
aria-label={`Mark "${item.text}" as ${item.checked ? "incomplete" : "complete"}`}
|
||||
className="size-3.5 shrink-0 cursor-pointer accent-pylon-accent"
|
||||
/>
|
||||
|
||||
@@ -215,7 +218,7 @@ function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps)
|
||||
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/item:opacity-100"
|
||||
className="shrink-0 rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/item:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Delete item"
|
||||
>
|
||||
<X className="size-3" />
|
||||
|
||||
@@ -47,6 +47,7 @@ export function CommentsSection({ cardId, comments }: CommentsSectionProps) {
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a comment... (Enter to send, Shift+Enter for newline)"
|
||||
rows={2}
|
||||
aria-label="Add a comment"
|
||||
className="flex-1 resize-none rounded-md bg-pylon-column px-2 py-1.5 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:ring-1 focus:ring-pylon-accent"
|
||||
/>
|
||||
<Button
|
||||
@@ -82,7 +83,7 @@ export function CommentsSection({ cardId, comments }: CommentsSectionProps) {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteComment(cardId, comment.id)}
|
||||
className="shrink-0 self-start rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/comment:opacity-100"
|
||||
className="shrink-0 self-start rounded p-0.5 text-pylon-text-secondary opacity-0 transition-opacity hover:bg-pylon-danger/10 hover:text-pylon-danger group-hover/comment:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Delete comment"
|
||||
>
|
||||
<X className="size-3" />
|
||||
|
||||
@@ -70,6 +70,7 @@ export function LabelPicker({
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||
aria-label="Manage labels"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
@@ -95,6 +96,8 @@ export function LabelPicker({
|
||||
key={label.id}
|
||||
onClick={() => toggleCardLabel(cardId, label.id)}
|
||||
className="flex items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-pylon-column"
|
||||
aria-pressed={isSelected}
|
||||
aria-label={`${isSelected ? "Remove" : "Add"} label: ${label.name}`}
|
||||
>
|
||||
<span
|
||||
className="size-3 shrink-0 rounded-full"
|
||||
@@ -122,6 +125,7 @@ export function LabelPicker({
|
||||
onChange={(e) => setNewLabelName(e.target.value)}
|
||||
onKeyDown={handleCreateKeyDown}
|
||||
placeholder="Label name..."
|
||||
aria-label="New label name"
|
||||
className="h-7 rounded-md bg-pylon-column px-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60 focus:ring-1 focus:ring-pylon-accent"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -130,6 +134,7 @@ export function LabelPicker({
|
||||
key={color}
|
||||
onClick={() => setNewLabelColor(color)}
|
||||
className="size-5 rounded-full transition-transform hover:scale-110"
|
||||
aria-label={`Color ${color}`}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
outline:
|
||||
|
||||
@@ -76,6 +76,7 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
|
||||
variant={mode === "edit" ? "secondary" : "ghost"}
|
||||
size="xs"
|
||||
onClick={() => setMode("edit")}
|
||||
aria-pressed={mode === "edit"}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
Edit
|
||||
@@ -83,6 +84,7 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
|
||||
<Button
|
||||
variant={mode === "preview" ? "secondary" : "ghost"}
|
||||
size="xs"
|
||||
aria-pressed={mode === "preview"}
|
||||
onClick={() => {
|
||||
// Save before switching to preview
|
||||
if (mode === "edit") {
|
||||
@@ -113,6 +115,7 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder="Add a description... (Markdown supported)"
|
||||
aria-label="Card description (Markdown)"
|
||||
className="min-h-[100px] w-full resize-none overflow-hidden bg-transparent px-3 py-2 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60"
|
||||
/>
|
||||
</OverlayScrollbarsComponent>
|
||||
|
||||
@@ -27,6 +27,7 @@ export function PriorityPicker({ cardId, priority }: PriorityPickerProps) {
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => updateCard(cardId, { priority: value })}
|
||||
aria-pressed={priority === value}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||
priority === value
|
||||
? "text-white shadow-sm"
|
||||
|
||||
@@ -10,8 +10,14 @@ export function AppShell({ children }: AppShellProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex h-screen flex-col bg-pylon-bg">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[200] focus:rounded-md focus:bg-pylon-accent focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white focus:shadow-lg"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-hidden">{children}</main>
|
||||
<main id="main-content" className="flex-1 overflow-hidden">{children}</main>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -163,6 +163,7 @@ export function TopBar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Undo"
|
||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||
onClick={() => useBoardStore.temporal.getState().undo()}
|
||||
>
|
||||
@@ -178,6 +179,7 @@ export function TopBar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Redo"
|
||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||
onClick={() => useBoardStore.temporal.getState().redo()}
|
||||
>
|
||||
@@ -193,6 +195,7 @@ export function TopBar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Filter cards"
|
||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||
onClick={() => document.dispatchEvent(new CustomEvent("toggle-filter-bar"))}
|
||||
>
|
||||
@@ -211,6 +214,7 @@ export function TopBar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Board settings"
|
||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||
>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
@@ -256,6 +260,7 @@ export function TopBar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Command palette"
|
||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||
onClick={() =>
|
||||
document.dispatchEvent(new CustomEvent("open-command-palette"))
|
||||
@@ -275,6 +280,7 @@ export function TopBar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Settings"
|
||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||
onClick={() =>
|
||||
document.dispatchEvent(new CustomEvent("open-settings-dialog"))
|
||||
|
||||
@@ -132,10 +132,12 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
</DialogHeader>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-border pb-2">
|
||||
<div className="flex gap-1 border-b border-border pb-2" role="tablist" aria-label="Settings sections">
|
||||
{TABS.map((t) => (
|
||||
<Button
|
||||
key={t.value}
|
||||
role="tab"
|
||||
aria-selected={tab === t.value}
|
||||
variant={tab === t.value ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setTab(t.value)}
|
||||
@@ -150,6 +152,8 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div
|
||||
key={tab}
|
||||
role="tabpanel"
|
||||
aria-label={`${tab} settings`}
|
||||
className="flex flex-col gap-5"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -166,6 +170,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={settings.theme === value}
|
||||
variant={settings.theme === value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setTheme(value)}
|
||||
@@ -193,6 +198,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setUiZoom(1)}
|
||||
aria-label="Reset zoom"
|
||||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
@@ -207,6 +213,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
step="0.05"
|
||||
value={settings.uiZoom}
|
||||
onChange={(e) => setUiZoom(parseFloat(e.target.value))}
|
||||
aria-label="UI Zoom level"
|
||||
className="w-full accent-pylon-accent"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between font-mono text-[10px] text-pylon-text-secondary">
|
||||
@@ -259,6 +266,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={settings.density === value}
|
||||
variant={settings.density === value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setDensity(value)}
|
||||
@@ -283,6 +291,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
<Button
|
||||
key={String(value)}
|
||||
type="button"
|
||||
aria-pressed={settings.reduceMotion === value}
|
||||
variant={settings.reduceMotion === value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setReduceMotion(value)}
|
||||
@@ -304,6 +313,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={settings.defaultColumnWidth === value}
|
||||
variant={settings.defaultColumnWidth === value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setDefaultColumnWidth(value)}
|
||||
@@ -333,7 +343,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
<div className="space-y-2 text-sm text-pylon-text">
|
||||
<p className="font-heading text-lg">OpenPylon</p>
|
||||
<p className="text-pylon-text-secondary">
|
||||
v0.1.0 · Local-first Kanban board
|
||||
v1.1.0 · Local-first Kanban board
|
||||
</p>
|
||||
<p className="text-pylon-text-secondary">
|
||||
Built with Tauri, React, and TypeScript.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
import { springs } from "@/lib/motion";
|
||||
import { useToastStore } from "@/stores/toast-store";
|
||||
|
||||
@@ -10,9 +11,17 @@ const TYPE_STYLES = {
|
||||
|
||||
export function ToastContainer() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const removeToast = useToastStore((s) => s.removeToast);
|
||||
const pauseToast = useToastStore((s) => s.pauseToast);
|
||||
const resumeToast = useToastStore((s) => s.resumeToast);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{toasts.map((toast) => (
|
||||
<motion.div
|
||||
@@ -21,9 +30,20 @@ export function ToastContainer() {
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
transition={springs.wobbly}
|
||||
className={`pointer-events-auto rounded-lg border px-4 py-2 text-sm shadow-md ${TYPE_STYLES[toast.type]}`}
|
||||
className={`pointer-events-auto flex items-center gap-2 rounded-lg border px-4 py-2 text-sm shadow-md ${TYPE_STYLES[toast.type]}`}
|
||||
onMouseEnter={() => pauseToast(toast.id)}
|
||||
onMouseLeave={() => resumeToast(toast.id)}
|
||||
onFocus={() => pauseToast(toast.id)}
|
||||
onBlur={() => resumeToast(toast.id)}
|
||||
>
|
||||
{toast.message}
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -5,13 +5,13 @@ import { Slot } from "radix-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium leading-none transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 [&_svg]:-translate-y-px outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium leading-none transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 [&_svg]:-translate-y-px outline-none focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/60 dark:focus-visible:ring-destructive/80 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
|
||||
@@ -68,7 +68,7 @@ function DialogContent({
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
className="ring-offset-background focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
|
||||
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
19
src/hooks/useAnnounce.ts
Normal file
19
src/hooks/useAnnounce.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface AnnounceState {
|
||||
message: string;
|
||||
announce: (text: string) => void;
|
||||
}
|
||||
|
||||
export const useAnnounceStore = create<AnnounceState>((set) => ({
|
||||
message: "",
|
||||
announce: (text) => {
|
||||
// Clear first to ensure re-announcement of identical messages
|
||||
set({ message: "" });
|
||||
requestAnimationFrame(() => set({ message: text }));
|
||||
},
|
||||
}));
|
||||
|
||||
export function useAnnounce() {
|
||||
return useAnnounceStore((s) => s.announce);
|
||||
}
|
||||
@@ -70,11 +70,11 @@
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--muted-foreground: oklch(0.40 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--border: oklch(0.75 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
@@ -95,7 +95,7 @@
|
||||
--pylon-column: oklch(95% 0.008 80);
|
||||
--pylon-accent: oklch(55% 0.12 160);
|
||||
--pylon-text: oklch(25% 0.015 50);
|
||||
--pylon-text-secondary: oklch(55% 0.01 50);
|
||||
--pylon-text-secondary: oklch(42% 0.01 50);
|
||||
--pylon-danger: oklch(55% 0.18 25);
|
||||
}
|
||||
|
||||
@@ -111,11 +111,11 @@
|
||||
--secondary: oklch(0.32 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.32 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--muted-foreground: oklch(0.75 0 0);
|
||||
--accent: oklch(0.32 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--border: oklch(1 0 0 / 25%);
|
||||
--input: oklch(1 0 0 / 18%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
@@ -136,13 +136,13 @@
|
||||
--pylon-column: oklch(27% 0.014 50);
|
||||
--pylon-accent: oklch(62% 0.13 160);
|
||||
--pylon-text: oklch(92% 0.01 50);
|
||||
--pylon-text-secondary: oklch(58% 0.01 50);
|
||||
--pylon-text-secondary: oklch(72% 0.01 50);
|
||||
--pylon-danger: oklch(62% 0.18 25);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@apply border-border outline-ring;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(50% 0 0 / 20%) transparent;
|
||||
}
|
||||
@@ -161,16 +161,16 @@
|
||||
font-family: "Epilogue", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--pylon-accent);
|
||||
outline: 3px solid var(--pylon-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* OverlayScrollbars custom theme */
|
||||
.os-theme-pylon {
|
||||
--os-handle-bg: oklch(50% 0 0 / 22%);
|
||||
--os-handle-bg-hover: oklch(50% 0 0 / 40%);
|
||||
--os-handle-bg-active: oklch(50% 0 0 / 55%);
|
||||
--os-handle-bg: oklch(50% 0 0 / 45%);
|
||||
--os-handle-bg-hover: oklch(50% 0 0 / 60%);
|
||||
--os-handle-bg-active: oklch(50% 0 0 / 75%);
|
||||
--os-size: 8px;
|
||||
--os-handle-border-radius: 9999px;
|
||||
--os-padding-perpendicular: 2px;
|
||||
@@ -178,15 +178,22 @@
|
||||
--os-handle-min-size: 30px;
|
||||
}
|
||||
.dark .os-theme-pylon {
|
||||
--os-handle-bg: oklch(80% 0 0 / 18%);
|
||||
--os-handle-bg-hover: oklch(80% 0 0 / 35%);
|
||||
--os-handle-bg-active: oklch(80% 0 0 / 50%);
|
||||
--os-handle-bg: oklch(80% 0 0 / 35%);
|
||||
--os-handle-bg-hover: oklch(80% 0 0 / 55%);
|
||||
--os-handle-bg-active: oklch(80% 0 0 / 70%);
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--pylon-text: oklch(10% 0.02 50);
|
||||
--pylon-text-secondary: oklch(35% 0.01 50);
|
||||
--pylon-text-secondary: oklch(30% 0.01 50);
|
||||
--muted-foreground: oklch(0.30 0 0);
|
||||
--border: oklch(0.55 0 0);
|
||||
}
|
||||
.dark {
|
||||
--pylon-text-secondary: oklch(85% 0.01 50);
|
||||
--muted-foreground: oklch(0.85 0 0);
|
||||
--border: oklch(1 0 0 / 50%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,28 @@ interface ToastState {
|
||||
toasts: Toast[];
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
removeToast: (id: string) => void;
|
||||
pauseToast: (id: string) => void;
|
||||
resumeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const remaining = new Map<string, number>();
|
||||
const startTimes = new Map<string, number>();
|
||||
|
||||
const TOAST_DURATION = 8000;
|
||||
|
||||
function startTimer(id: string, duration: number, set: (fn: (s: ToastState) => Partial<ToastState>) => void) {
|
||||
startTimes.set(id, Date.now());
|
||||
remaining.set(id, duration);
|
||||
const timer = setTimeout(() => {
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||
timers.delete(id);
|
||||
remaining.delete(id);
|
||||
startTimes.delete(id);
|
||||
}, duration);
|
||||
timers.set(id, timer);
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
@@ -22,12 +41,33 @@ export const useToastStore = create<ToastState>((set) => ({
|
||||
addToast: (message, type = "info") => {
|
||||
const id = String(++nextId);
|
||||
set((s) => ({ toasts: [...s.toasts, { id, message, type }] }));
|
||||
setTimeout(() => {
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||
}, 3000);
|
||||
startTimer(id, TOAST_DURATION, set);
|
||||
},
|
||||
|
||||
removeToast: (id) => {
|
||||
const timer = timers.get(id);
|
||||
if (timer) clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
remaining.delete(id);
|
||||
startTimes.delete(id);
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
|
||||
},
|
||||
|
||||
pauseToast: (id) => {
|
||||
const timer = timers.get(id);
|
||||
const start = startTimes.get(id);
|
||||
const rem = remaining.get(id);
|
||||
if (timer && start != null && rem != null) {
|
||||
clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
remaining.set(id, rem - (Date.now() - start));
|
||||
}
|
||||
},
|
||||
|
||||
resumeToast: (id) => {
|
||||
const rem = remaining.get(id);
|
||||
if (rem != null && rem > 0) {
|
||||
startTimer(id, rem, set);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user