Compare commits

19 Commits

Author SHA1 Message Date
7075f90d15 tidy up README formatting 2026-02-19 20:23:01 +02:00
a8f9e0ae25 bump to v1.1.0: accessibility, filter bar fix, updated README 2026-02-19 20:15:01 +02:00
ae9bc20093 add aria-labels to TopBar buttons and CalendarPopover 2026-02-19 19:56:47 +02:00
964284295d add ARIA labels and pressed states to board list and remaining components 2026-02-19 19:55:30 +02:00
8e3e644127 add ARIA labels, pressed states, and fix keyboard visibility across card detail components 2026-02-19 19:54:22 +02:00
c089247fa0 add labels and ARIA attributes to filter bar inputs 2026-02-19 19:51:56 +02:00
f48847b2c0 add ARIA tab roles, pressed states, and labels to settings dialog 2026-02-19 19:50:54 +02:00
3bcf2e458e add dialog semantics, focus trap, and ARIA labels to card detail modal 2026-02-19 19:49:25 +02:00
13422ff781 improve card thumbnail accessibility: aging opacity, ARIA labels 2026-02-19 19:48:08 +02:00
63f30bef95 fix column header keyboard visibility and ARIA labels 2026-02-19 19:46:58 +02:00
5fecd62951 add skip navigation, page title, and global ARIA live region 2026-02-19 19:45:51 +02:00
d3c680b213 make toasts accessible: ARIA live region, dismiss button, pause on hover 2026-02-19 19:44:06 +02:00
efb631d212 fix focus ring contrast across UI primitives 2026-02-19 19:43:00 +02:00
60d4bee373 fix contrast tokens, focus rings, and scrollbar visibility for WCAG AAA 2026-02-19 19:42:04 +02:00
04e4dc6d00 remove unused files 2026-02-19 19:15:55 +02:00
10176f8968 fix card click delay when reduced motion is active 2026-02-16 18:51:35 +02:00
f80a3d5d0e fix layout animation blocking card clicks when reduce motion is on 2026-02-16 18:38:04 +02:00
2f3060738e add reduce motion toggle and bump to v1.0.1 2026-02-16 17:51:23 +02:00
d33c348260 add custom app icon - teal squircle with lighthouse 2026-02-16 16:07:20 +02:00
57 changed files with 1685 additions and 132 deletions

167
README.md
View File

@@ -8,7 +8,7 @@
<p align="center"> <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/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/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/portable-no%20install%20needed-brightgreen" alt="Portable" />
<img src="https://img.shields.io/badge/built%20with-Tauri%20v2-orange" alt="Built with Tauri v2" /> <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. 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. 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 ### 📋 Boards
- **Unlimited boards** with custom accent colors and editable titles - **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) - **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 - **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 - **Duplicate entire boards** with all cards, labels, and settings preserved
- **Drag-and-drop reordering** in the board list - **Drag-and-drop reordering** in the board list
- **Sort boards** by name, last modified, date created, or manual drag order - **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 ### 🏛️ Columns
- **Add, rename, reorder, and delete** columns with drag-and-drop - **Add, rename, reorder, and delete** columns with drag-and-drop
- **Three column widths** -- Narrow, Standard, Wide - **Three column widths** - Narrow, Standard, Wide
- **Column colors** -- 10 preset hues or no color - **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 - **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 - **Collapse columns** to a narrow vertical strip showing just the title and card count - keep things tidy without losing context
### 🃏 Cards ### 🃏 Cards
- **Drag-and-drop** cards within and between columns - **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 - **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 - **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 - **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") - **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 - **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 - **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 - **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 - **Comments** - timestamped notes on each card, newest first, with add and delete
- **Card duplication** -- copy a card within its column - **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 - **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 ### 🔍 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 - **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 - **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 - **Cross-board search** - find any card by title or description across every board you have
### ⌨️ Keyboard Navigation ### ⌨️ 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 | | Shortcut | Action |
|---|---| |---|---|
@@ -86,33 +86,80 @@ Full keyboard-driven workflow. Vim-style or arrow keys -- your choice.
### 🎨 Appearance ### 🎨 Appearance
- **Theme** -- Light, Dark, or follow your system preference - **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 - **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 - **UI zoom** - 75% to 150% in 5% increments
- **Density** -- Compact, Comfortable, or Spacious -- adjust how much breathing room the interface gets - **Density** - Compact, Comfortable, or Spacious - adjust how much breathing room the interface gets
- **Board backgrounds** -- None, Dots, Grid, or Gradient pattern per board - **Board backgrounds** - None, Dots, Grid, or Gradient pattern per board
- **Default column width** -- configure what width new columns start at - **Default column width** - configure what width new columns start at
- **Custom scrollbars** -- themed scrollbars throughout, with auto-hide behavior - **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 - **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 ### 🛡️ 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 - **Auto-save** - boards save automatically 500ms after every change
- **Automatic backups** -- timestamped snapshots every 5 minutes, last 10 retained per board - **Automatic backups** - timestamped snapshots every 5 minutes, last 10 retained per board
- **Version history** -- browse and restore previous versions from the board settings menu - **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 - **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 - **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. - **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 ### 🖥️ Desktop Integration
- **Custom frameless window** -- integrated title bar with minimize, maximize, and close controls, plus a drag region that feels native - **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 - **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 - **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 - **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. 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 ### Build from Source
@@ -182,15 +229,15 @@ data/
### 📄 Board Format ### 📄 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 ### 🔄 Backup and Recovery
If something goes wrong: 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. 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. 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/`. 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 | | 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. | | **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 ### Exporting
@@ -255,7 +302,7 @@ No lock-in. Take your data wherever you want, whenever you want. We'd rather you
| Layer | Technology | | 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/) | | ⚛️ 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 | | 🎨 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) | | 🧠 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/ openpylon/
├── src/ # React frontend ├── src/ # React frontend
│ ├── components/ │ ├── components/
│ │ ├── board/ # Board view -- columns, cards, filter bar, drag overlays │ │ ├── board/ # Board view - columns, cards, filter bar, drag overlays
│ │ ├── boards/ # Board list -- grid, new board dialog, board cards │ │ ├── boards/ # Board list - grid, new board dialog, board cards
│ │ ├── card-detail/ # Card modal -- markdown, checklists, labels, priority, │ │ ├── card-detail/ # Card modal - markdown, checklists, labels, priority,
│ │ │ # due dates, attachments, comments, cover colors │ │ │ # due dates, attachments, comments, cover colors
│ │ ├── command-palette/ # Ctrl+K fuzzy search across everything │ │ ├── command-palette/ # Ctrl+K fuzzy search across everything
│ │ ├── import-export/ # Import/export buttons and file handling │ │ ├── import-export/ # Import/export buttons and file handling
@@ -288,7 +335,7 @@ openpylon/
│ │ └── ui/ # Shared primitives (button, dialog, tooltip, popover...) │ │ └── ui/ # Shared primitives (button, dialog, tooltip, popover...)
│ ├── hooks/ # Keyboard shortcuts, keyboard card navigation │ ├── hooks/ # Keyboard shortcuts, keyboard card navigation
│ ├── lib/ # Storage, import/export, board factory, motion presets │ ├── 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 │ └── types/ # TypeScript interfaces and type definitions
├── src-tauri/ # Tauri / Rust backend ├── src-tauri/ # Tauri / Rust backend
│ ├── src/ │ ├── src/
@@ -312,7 +359,7 @@ openpylon/
npm run tauri dev npm run tauri dev
# Type-check the frontend # Type-check the frontend
npx tsc --noEmit npx tsc -noEmit
# Production build (portable exe) # Production build (portable exe)
npm run tauri build npm run tauri build
@@ -322,7 +369,7 @@ The dev server runs on `http://localhost:1420` with Vite HMR. Rust backend chang
### 🤝 Contributing ### 🤝 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. Good things happen when tools are shared freely.
@@ -336,11 +383,11 @@ Good things happen when tools are shared freely.
</a> </a>
</p> </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. 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. 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> <sub>
Made with care. Shared without conditions. Made with care. Shared without conditions.
<br /> <br />
Your tools should serve you -- not the other way around. Your tools should serve you - not the other way around.
</sub> </sub>
</p> </p>

View File

@@ -2,7 +2,6 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenPylon</title> <title>OpenPylon</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">

1282
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "openpylon", "name": "openpylon",
"private": true, "private": true,
"version": "1.0.0", "version": "1.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -45,7 +45,10 @@
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"png-to-ico": "^3.0.1",
"puppeteer-core": "^24.37.3",
"shadcn": "^3.8.4", "shadcn": "^3.8.4",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",

View File

@@ -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

View File

@@ -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
View File

@@ -2360,7 +2360,7 @@ dependencies = [
[[package]] [[package]]
name = "openpylon" name = "openpylon"
version = "0.1.0" version = "1.1.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "openpylon" name = "openpylon"
version = "1.0.0" version = "1.1.0"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "openpylon", "productName": "openpylon",
"version": "1.0.0", "version": "1.1.0",
"identifier": "com.openpylon.app", "identifier": "com.openpylon.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { getCurrentWindow, LogicalSize, LogicalPosition } from "@tauri-apps/api/window"; import { getCurrentWindow, LogicalSize, LogicalPosition } from "@tauri-apps/api/window";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion, MotionConfig } from "framer-motion";
import { springs, fadeSlideLeft, fadeSlideRight } from "@/lib/motion"; import { springs, fadeSlideLeft, fadeSlideRight } from "@/lib/motion";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
@@ -13,11 +13,15 @@ import { SettingsDialog } from "@/components/settings/SettingsDialog";
import { ToastContainer } from "@/components/toast/ToastContainer"; import { ToastContainer } from "@/components/toast/ToastContainer";
import { ShortcutHelpModal } from "@/components/shortcuts/ShortcutHelpModal"; import { ShortcutHelpModal } from "@/components/shortcuts/ShortcutHelpModal";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
import { useAnnounceStore } from "@/hooks/useAnnounce";
export default function App() { export default function App() {
const initialized = useAppStore((s) => s.initialized); const initialized = useAppStore((s) => s.initialized);
const init = useAppStore((s) => s.init); const init = useAppStore((s) => s.init);
const view = useAppStore((s) => s.view); 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 [settingsOpen, setSettingsOpen] = useState(false);
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false); const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
@@ -110,6 +114,10 @@ export default function App() {
}; };
}, []); }, []);
useEffect(() => {
document.title = boardTitle ? `${boardTitle} - OpenPylon` : "OpenPylon";
}, [boardTitle, view]);
const handleOpenSettings = useCallback(() => { const handleOpenSettings = useCallback(() => {
setSettingsOpen(true); setSettingsOpen(true);
}, []); }, []);
@@ -127,7 +135,10 @@ export default function App() {
} }
return ( return (
<> <MotionConfig reducedMotion={reduceMotion ? "always" : "user"}>
<div aria-live="assertive" aria-atomic="true" className="sr-only">
{announcement}
</div>
<AppShell> <AppShell>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{view.type === "board-list" ? ( {view.type === "board-list" ? (
@@ -161,6 +172,6 @@ export default function App() {
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} /> <SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<ToastContainer /> <ToastContainer />
<ShortcutHelpModal open={shortcutHelpOpen} onOpenChange={setShortcutHelpOpen} /> <ShortcutHelpModal open={shortcutHelpOpen} onOpenChange={setShortcutHelpOpen} />
</> </MotionConfig>
); );
} }

View File

@@ -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

View File

@@ -44,6 +44,7 @@ export function AddCardInput({ columnId, onClose }: AddCardInputProps) {
onBlur={() => { onBlur={() => {
if (!value.trim()) onClose(); if (!value.trim()) onClose();
}} }}
aria-label="Card title"
placeholder="Card title..." placeholder="Card title..."
rows={2} 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" 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"

View File

@@ -388,7 +388,7 @@ export function BoardView() {
const columnIds = board.columns.map((c) => c.id); const columnIds = board.columns.map((c) => c.id);
return ( return (
<> <div className="flex h-full flex-col">
{/* Visually hidden live region for drag-and-drop announcements */} {/* Visually hidden live region for drag-and-drop announcements */}
<div <div
aria-live="polite" aria-live="polite"
@@ -421,7 +421,7 @@ export function BoardView() {
> >
<OverlayScrollbarsComponent <OverlayScrollbarsComponent
ref={osRef} 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" } }} options={{ scrollbars: { theme: "os-theme-pylon", autoHide: "scroll", autoHideDelay: 600, clickScroll: true }, overflow: { y: "hidden" } }}
defer defer
> >
@@ -511,6 +511,6 @@ export function BoardView() {
cardId={selectedCardId} cardId={selectedCardId}
onClose={() => { setSelectedCardId(null); }} onClose={() => { setSelectedCardId(null); }}
/> />
</> </div>
); );
} }

View File

@@ -46,9 +46,9 @@ function getDueDateStatus(dueDate: string | null): { color: string; bgColor: str
function getAgingOpacity(updatedAt: string): number { function getAgingOpacity(updatedAt: string): number {
const days = (Date.now() - new Date(updatedAt).getTime()) / (1000 * 60 * 60 * 24); const days = (Date.now() - new Date(updatedAt).getTime()) / (1000 * 60 * 60 * 24);
if (days <= 7) return 1.0; if (days <= 7) return 1.0;
if (days <= 14) return 0.85; if (days <= 14) return 0.9;
if (days <= 30) return 0.7; if (days <= 30) return 0.8;
return 0.55; return 0.7;
} }
/* ---------- Priority colors ---------- */ /* ---------- Priority colors ---------- */
@@ -134,14 +134,14 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick, isFocu
className={`w-full rounded-lg bg-pylon-surface shadow-sm text-left ${ className={`w-full rounded-lg bg-pylon-surface shadow-sm text-left ${
isFocused ? "ring-2 ring-pylon-accent ring-offset-2 ring-offset-pylon-column" : "" isFocused ? "ring-2 ring-pylon-accent ring-offset-2 ring-offset-pylon-column" : ""
}`} }`}
layoutId={`card-${card.id}`} layoutId={prefersReducedMotion ? undefined : `card-${card.id}`}
variants={fadeSlideUp} variants={fadeSlideUp}
initial={prefersReducedMotion ? false : "hidden"} initial={prefersReducedMotion ? false : "hidden"}
animate="visible" animate="visible"
whileHover={{ scale: 1.02, y: -2, boxShadow: "0 4px 12px oklch(0% 0 0 / 10%)" }} whileHover={{ scale: 1.02, y: -2, boxShadow: "0 4px 12px oklch(0% 0 0 / 10%)" }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
transition={springs.bouncy} transition={springs.bouncy}
layout layout={!prefersReducedMotion}
{...attributes} {...attributes}
{...listeners} {...listeners}
role="article" role="article"
@@ -175,13 +175,14 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick, isFocu
<span <span
className={`size-2.5 shrink-0 rounded-full ${card.priority === "urgent" ? "animate-pulse" : ""}`} className={`size-2.5 shrink-0 rounded-full ${card.priority === "urgent" ? "animate-pulse" : ""}`}
style={{ backgroundColor: PRIORITY_COLORS[card.priority] }} style={{ backgroundColor: PRIORITY_COLORS[card.priority] }}
title={`Priority: ${card.priority}`} aria-label={`Priority: ${card.priority}`}
role="img"
/> />
)} )}
{dueDateStatus && card.dueDate && ( {dueDateStatus && card.dueDate && (
<span <span
className={`font-mono text-xs rounded px-1 py-0.5 ${dueDateStatus.color} ${dueDateStatus.bgColor}`} 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")} {format(new Date(card.dueDate), "MMM d")}
</span> </span>
@@ -193,7 +194,7 @@ export function CardThumbnail({ card, boardLabels, columnId, onCardClick, isFocu
<DescriptionPreview description={card.description} /> <DescriptionPreview description={card.description} />
)} )}
{card.attachments.length > 0 && ( {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" /> <Paperclip className="size-3" />
<span className="font-mono text-xs">{card.attachments.length}</span> <span className="font-mono text-xs">{card.attachments.length}</span>
</span> </span>
@@ -343,6 +344,8 @@ function DescriptionPreview({ description }: { description: string }) {
ref={iconRef} ref={iconRef}
onMouseEnter={handleEnter} onMouseEnter={handleEnter}
onMouseLeave={handleLeave} onMouseLeave={handleLeave}
aria-label="Has description"
role="img"
> >
<AlignLeft className="size-3 text-pylon-text-secondary" /> <AlignLeft className="size-3 text-pylon-text-secondary" />
{createPortal( {createPortal(

View File

@@ -8,7 +8,7 @@ export function ChecklistBar({ checklist }: ChecklistBarProps) {
if (checklist.length === 0) return null; if (checklist.length === 0) return null;
return ( 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) => ( {checklist.map((item) => (
<span <span
key={item.id} key={item.id}

View File

@@ -88,6 +88,7 @@ export function ColumnHeader({ column, cardCount, filteredCount }: ColumnHeaderP
onChange={(e) => setEditValue(e.target.value)} onChange={(e) => setEditValue(e.target.value)}
onBlur={commitRename} onBlur={commitRename}
onKeyDown={handleKeyDown} 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" 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 <Button
variant="ghost" variant="ghost"
size="icon-xs" 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" /> <MoreHorizontal className="size-3.5" />
</Button> </Button>
@@ -166,6 +168,7 @@ export function ColumnHeader({ column, cardCount, filteredCount }: ColumnHeaderP
outlineOffset: "1px", outlineOffset: "1px",
}} }}
title={label} title={label}
aria-label={label}
/> />
))} ))}
</div> </div>

View File

@@ -79,6 +79,7 @@ export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBar
value={textDraft} value={textDraft}
onChange={(e) => handleTextChange(e.target.value)} onChange={(e) => handleTextChange(e.target.value)}
placeholder="Search cards..." 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" className="h-5 w-40 bg-transparent text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary/60"
/> />
</div> </div>
@@ -90,6 +91,8 @@ export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBar
<button <button
key={label.id} key={label.id}
onClick={() => toggleLabel(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 ${ className={`rounded-full px-2 py-0.5 text-xs transition-all ${
filters.labels.includes(label.id) filters.labels.includes(label.id)
? "text-white" ? "text-white"
@@ -107,6 +110,7 @@ export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBar
<select <select
value={filters.dueDate} value={filters.dueDate}
onChange={(e) => onChange({ ...filters, dueDate: e.target.value as FilterState["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" className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
> >
<option value="all">All dates</option> <option value="all">All dates</option>
@@ -120,6 +124,7 @@ export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBar
<select <select
value={filters.priority} value={filters.priority}
onChange={(e) => onChange({ ...filters, priority: e.target.value as FilterState["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" className="h-6 rounded-md bg-pylon-column px-2 text-xs text-pylon-text outline-none"
> >
<option value="all">All priorities</option> <option value="all">All priorities</option>
@@ -137,7 +142,7 @@ export function FilterBar({ filters, onChange, onClose, boardLabels }: FilterBar
Clear all Clear all
</Button> </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" /> <X className="size-3.5" />
</Button> </Button>
</div> </div>

View File

@@ -27,6 +27,8 @@ export function LabelDots({ labelIds, boardLabels }: LabelDotsProps) {
<span <span
className="inline-block size-2 shrink-0 rounded-full" className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: label.color }} style={{ backgroundColor: label.color }}
aria-label={label.name}
role="img"
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{label.name}</TooltipContent> <TooltipContent>{label.name}</TooltipContent>

View File

@@ -183,6 +183,7 @@ export function BoardCard({ board, sortable = false }: BoardCardProps) {
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<button <button
onClick={handleOpen} 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" 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 */} {/* Color accent stripe */}

View File

@@ -170,6 +170,7 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
variant={template === t && !selectedUserTemplate ? "default" : "outline"} variant={template === t && !selectedUserTemplate ? "default" : "outline"}
size="sm" size="sm"
onClick={() => { setTemplate(t); setSelectedUserTemplate(null); }} onClick={() => { setTemplate(t); setSelectedUserTemplate(null); }}
aria-pressed={template === t && !selectedUserTemplate}
className="capitalize" className="capitalize"
> >
{t} {t}
@@ -182,6 +183,7 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
variant={selectedUserTemplate?.id === ut.id ? "default" : "outline"} variant={selectedUserTemplate?.id === ut.id ? "default" : "outline"}
size="sm" size="sm"
onClick={() => { setSelectedUserTemplate(ut); setColor(ut.color); }} onClick={() => { setSelectedUserTemplate(ut); setColor(ut.color); }}
aria-pressed={selectedUserTemplate?.id === ut.id}
> >
<span <span
className="inline-block size-2 rounded-full shrink-0" className="inline-block size-2 rounded-full shrink-0"
@@ -193,7 +195,7 @@ export function NewBoardDialog({ open, onOpenChange }: NewBoardDialogProps) {
type="button" type="button"
onClick={() => handleDeleteTemplate(ut.id)} 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" 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" /> <X className="size-3" />
</button> </button>

View File

@@ -52,6 +52,7 @@ export function AttachmentSection({
size="icon-xs" size="icon-xs"
onClick={handleAdd} onClick={handleAdd}
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
aria-label="Add attachment"
> >
<Plus className="size-3.5" /> <Plus className="size-3.5" />
</Button> </Button>
@@ -71,14 +72,14 @@ export function AttachmentSection({
</span> </span>
<button <button
onClick={() => openPath(att.path)} 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" aria-label="Open attachment"
> >
<ExternalLink className="size-3" /> <ExternalLink className="size-3" />
</button> </button>
<button <button
onClick={() => removeAttachment(cardId, att.id)} 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" aria-label="Remove attachment"
> >
<X className="size-3" /> <X className="size-3" />

View File

@@ -109,6 +109,7 @@ export function CalendarPopover({
<Button <Button
variant="ghost" variant="ghost"
size="icon-xs" size="icon-xs"
aria-label="Previous month"
onClick={() => setViewDate((d) => subMonths(d, 1))} onClick={() => setViewDate((d) => subMonths(d, 1))}
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
> >
@@ -118,12 +119,14 @@ export function CalendarPopover({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={() => setViewMode(viewMode === "months" ? "days" : "months")} 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" className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
> >
{format(viewDate, "MMMM")} {format(viewDate, "MMMM")}
</button> </button>
<button <button
onClick={() => setViewMode(viewMode === "years" ? "days" : "years")} 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" className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
> >
{format(viewDate, "yyyy")} {format(viewDate, "yyyy")}
@@ -133,6 +136,7 @@ export function CalendarPopover({
<Button <Button
variant="ghost" variant="ghost"
size="icon-xs" size="icon-xs"
aria-label="Next month"
onClick={() => setViewDate((d) => addMonths(d, 1))} onClick={() => setViewDate((d) => addMonths(d, 1))}
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
> >
@@ -164,7 +168,7 @@ export function CalendarPopover({
</div> </div>
{/* Day grid */} {/* Day grid */}
<div className="grid grid-cols-7"> <div className="grid grid-cols-7" role="grid" aria-label="Calendar days">
{calendarDays.map((day) => { {calendarDays.map((day) => {
const inMonth = isSameMonth(day, viewDate); const inMonth = isSameMonth(day, viewDate);
const today = isTodayFn(day); const today = isTodayFn(day);
@@ -179,6 +183,8 @@ export function CalendarPopover({
<button <button
key={day.toISOString()} key={day.toISOString()}
onClick={() => handleSelectDate(day)} 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 className={`flex h-9 items-center justify-center rounded-lg text-sm transition-colors
${selected ${selected
? "bg-pylon-accent font-medium text-white" ? "bg-pylon-accent font-medium text-white"

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
@@ -25,6 +25,24 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
const updateCard = useBoardStore((s) => s.updateCard); const updateCard = useBoardStore((s) => s.updateCard);
const open = cardId != null && card != null; const open = cardId != null && card != null;
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 ( return (
<AnimatePresence> <AnimatePresence>
@@ -36,7 +54,7 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.2 }} transition={prefersReducedMotion ? instant : { duration: 0.2 }}
onClick={onClose} onClick={onClose}
/> />
@@ -46,9 +64,17 @@ export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
onClick={onClose} onClick={onClose}
> >
<motion.div <motion.div
layoutId={`card-${cardId}`} 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" className="relative w-full max-w-4xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl"
transition={springs.gentle} initial={prefersReducedMotion ? { opacity: 0 } : undefined}
animate={prefersReducedMotion ? { opacity: 1 } : undefined}
exit={prefersReducedMotion ? { opacity: 0 } : undefined}
transition={prefersReducedMotion ? instant : springs.gentle}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<EscapeHandler onClose={onClose} /> <EscapeHandler onClose={onClose} />
@@ -254,6 +280,7 @@ function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps)
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onBlur={handleSave} onBlur={handleSave}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
aria-label="Card title"
className={`flex-1 font-heading text-xl bg-transparent outline-none border-b pb-0.5 ${ 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" hasColor ? "text-white border-white/50" : "text-pylon-text border-pylon-accent"
}`} }`}
@@ -263,6 +290,7 @@ function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps)
return ( return (
<h2 <h2
id="card-detail-title"
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
className={`flex-1 cursor-pointer font-heading text-xl transition-colors ${textColor} ${hoverColor}`} className={`flex-1 cursor-pointer font-heading text-xl transition-colors ${textColor} ${hoverColor}`}
> >
@@ -304,6 +332,7 @@ function CoverColorPicker({
onClick={() => updateCard(cardId, { coverColor: null })} 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" 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" title="None"
aria-label="No cover color"
> >
&times; &times;
</button> </button>
@@ -319,6 +348,7 @@ function CoverColorPicker({
outlineOffset: "1px", outlineOffset: "1px",
}} }}
title={label} title={label}
aria-label={label}
/> />
))} ))}
</div> </div>

View File

@@ -121,6 +121,7 @@ export function ChecklistSection({ cardId, checklist }: ChecklistSectionProps) {
onChange={(e) => setNewItemText(e.target.value)} onChange={(e) => setNewItemText(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Add item..." 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" 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> </div>
@@ -176,7 +177,8 @@ function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps)
{...attributes} {...attributes}
> >
<span <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} {...listeners}
> >
<GripVertical className="size-3" /> <GripVertical className="size-3" />
@@ -185,6 +187,7 @@ function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps)
type="checkbox" type="checkbox"
checked={item.checked} checked={item.checked}
onChange={onToggle} onChange={onToggle}
aria-label={`Mark "${item.text}" as ${item.checked ? "incomplete" : "complete"}`}
className="size-3.5 shrink-0 cursor-pointer accent-pylon-accent" className="size-3.5 shrink-0 cursor-pointer accent-pylon-accent"
/> />
@@ -215,7 +218,7 @@ function ChecklistRow({ item, onToggle, onUpdate, onDelete }: ChecklistRowProps)
<button <button
onClick={onDelete} 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" aria-label="Delete item"
> >
<X className="size-3" /> <X className="size-3" />

View File

@@ -47,6 +47,7 @@ export function CommentsSection({ cardId, comments }: CommentsSectionProps) {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Add a comment... (Enter to send, Shift+Enter for newline)" placeholder="Add a comment... (Enter to send, Shift+Enter for newline)"
rows={2} 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" 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 <Button
@@ -82,7 +83,7 @@ export function CommentsSection({ cardId, comments }: CommentsSectionProps) {
</div> </div>
<button <button
onClick={() => deleteComment(cardId, comment.id)} 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" aria-label="Delete comment"
> >
<X className="size-3" /> <X className="size-3" />

View File

@@ -70,6 +70,7 @@ export function LabelPicker({
variant="ghost" variant="ghost"
size="icon-xs" size="icon-xs"
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
aria-label="Manage labels"
> >
<Plus className="size-3.5" /> <Plus className="size-3.5" />
</Button> </Button>
@@ -95,6 +96,8 @@ export function LabelPicker({
key={label.id} key={label.id}
onClick={() => toggleCardLabel(cardId, 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" 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 <span
className="size-3 shrink-0 rounded-full" className="size-3 shrink-0 rounded-full"
@@ -122,6 +125,7 @@ export function LabelPicker({
onChange={(e) => setNewLabelName(e.target.value)} onChange={(e) => setNewLabelName(e.target.value)}
onKeyDown={handleCreateKeyDown} onKeyDown={handleCreateKeyDown}
placeholder="Label name..." 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" 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"> <div className="flex flex-wrap gap-1.5">
@@ -130,6 +134,7 @@ export function LabelPicker({
key={color} key={color}
onClick={() => setNewLabelColor(color)} onClick={() => setNewLabelColor(color)}
className="size-5 rounded-full transition-transform hover:scale-110" className="size-5 rounded-full transition-transform hover:scale-110"
aria-label={`Color ${color}`}
style={{ style={{
backgroundColor: color, backgroundColor: color,
outline: outline:

View File

@@ -76,6 +76,7 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
variant={mode === "edit" ? "secondary" : "ghost"} variant={mode === "edit" ? "secondary" : "ghost"}
size="xs" size="xs"
onClick={() => setMode("edit")} onClick={() => setMode("edit")}
aria-pressed={mode === "edit"}
className="font-mono text-xs" className="font-mono text-xs"
> >
Edit Edit
@@ -83,6 +84,7 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
<Button <Button
variant={mode === "preview" ? "secondary" : "ghost"} variant={mode === "preview" ? "secondary" : "ghost"}
size="xs" size="xs"
aria-pressed={mode === "preview"}
onClick={() => { onClick={() => {
// Save before switching to preview // Save before switching to preview
if (mode === "edit") { if (mode === "edit") {
@@ -113,6 +115,7 @@ export function MarkdownEditor({ cardId, value }: MarkdownEditorProps) {
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
placeholder="Add a description... (Markdown supported)" 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" 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> </OverlayScrollbarsComponent>

View File

@@ -27,6 +27,7 @@ export function PriorityPicker({ cardId, priority }: PriorityPickerProps) {
<button <button
key={value} key={value}
onClick={() => updateCard(cardId, { priority: value })} onClick={() => updateCard(cardId, { priority: value })}
aria-pressed={priority === value}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${ className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
priority === value priority === value
? "text-white shadow-sm" ? "text-white shadow-sm"

View File

@@ -10,8 +10,14 @@ export function AppShell({ children }: AppShellProps) {
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="flex h-screen flex-col bg-pylon-bg"> <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 /> <TopBar />
<main className="flex-1 overflow-hidden">{children}</main> <main id="main-content" className="flex-1 overflow-hidden">{children}</main>
</div> </div>
</TooltipProvider> </TooltipProvider>
); );

View File

@@ -163,6 +163,7 @@ export function TopBar() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label="Undo"
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() => useBoardStore.temporal.getState().undo()} onClick={() => useBoardStore.temporal.getState().undo()}
> >
@@ -178,6 +179,7 @@ export function TopBar() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label="Redo"
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() => useBoardStore.temporal.getState().redo()} onClick={() => useBoardStore.temporal.getState().redo()}
> >
@@ -193,6 +195,7 @@ export function TopBar() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label="Filter cards"
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() => document.dispatchEvent(new CustomEvent("toggle-filter-bar"))} onClick={() => document.dispatchEvent(new CustomEvent("toggle-filter-bar"))}
> >
@@ -211,6 +214,7 @@ export function TopBar() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label="Board settings"
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
> >
<SlidersHorizontal className="size-4" /> <SlidersHorizontal className="size-4" />
@@ -256,6 +260,7 @@ export function TopBar() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label="Command palette"
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() => onClick={() =>
document.dispatchEvent(new CustomEvent("open-command-palette")) document.dispatchEvent(new CustomEvent("open-command-palette"))
@@ -275,6 +280,7 @@ export function TopBar() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label="Settings"
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
onClick={() => onClick={() =>
document.dispatchEvent(new CustomEvent("open-settings-dialog")) document.dispatchEvent(new CustomEvent("open-settings-dialog"))

View File

@@ -92,6 +92,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
const setAccentColor = useAppStore((s) => s.setAccentColor); const setAccentColor = useAppStore((s) => s.setAccentColor);
const setUiZoom = useAppStore((s) => s.setUiZoom); const setUiZoom = useAppStore((s) => s.setUiZoom);
const setDensity = useAppStore((s) => s.setDensity); const setDensity = useAppStore((s) => s.setDensity);
const setReduceMotion = useAppStore((s) => s.setReduceMotion);
const setDefaultColumnWidth = useAppStore((s) => s.setDefaultColumnWidth); const setDefaultColumnWidth = useAppStore((s) => s.setDefaultColumnWidth);
const roRef = useRef<ResizeObserver | null>(null); const roRef = useRef<ResizeObserver | null>(null);
@@ -131,10 +132,12 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
</DialogHeader> </DialogHeader>
{/* Tab bar */} {/* 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) => ( {TABS.map((t) => (
<Button <Button
key={t.value} key={t.value}
role="tab"
aria-selected={tab === t.value}
variant={tab === t.value ? "secondary" : "ghost"} variant={tab === t.value ? "secondary" : "ghost"}
size="sm" size="sm"
onClick={() => setTab(t.value)} onClick={() => setTab(t.value)}
@@ -149,6 +152,8 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
<AnimatePresence mode="popLayout" initial={false}> <AnimatePresence mode="popLayout" initial={false}>
<motion.div <motion.div
key={tab} key={tab}
role="tabpanel"
aria-label={`${tab} settings`}
className="flex flex-col gap-5" className="flex flex-col gap-5"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
@@ -165,6 +170,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
<Button <Button
key={value} key={value}
type="button" type="button"
aria-pressed={settings.theme === value}
variant={settings.theme === value ? "default" : "outline"} variant={settings.theme === value ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setTheme(value)} onClick={() => setTheme(value)}
@@ -192,6 +198,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
variant="ghost" variant="ghost"
size="icon-xs" size="icon-xs"
onClick={() => setUiZoom(1)} onClick={() => setUiZoom(1)}
aria-label="Reset zoom"
className="text-pylon-text-secondary hover:text-pylon-text" className="text-pylon-text-secondary hover:text-pylon-text"
> >
<RotateCcw className="size-3" /> <RotateCcw className="size-3" />
@@ -206,6 +213,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
step="0.05" step="0.05"
value={settings.uiZoom} value={settings.uiZoom}
onChange={(e) => setUiZoom(parseFloat(e.target.value))} onChange={(e) => setUiZoom(parseFloat(e.target.value))}
aria-label="UI Zoom level"
className="w-full accent-pylon-accent" className="w-full accent-pylon-accent"
/> />
<div className="mt-1 flex justify-between font-mono text-[10px] text-pylon-text-secondary"> <div className="mt-1 flex justify-between font-mono text-[10px] text-pylon-text-secondary">
@@ -258,6 +266,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
<Button <Button
key={value} key={value}
type="button" type="button"
aria-pressed={settings.density === value}
variant={settings.density === value ? "default" : "outline"} variant={settings.density === value ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setDensity(value)} onClick={() => setDensity(value)}
@@ -268,6 +277,31 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
))} ))}
</div> </div>
</div> </div>
<Separator />
{/* Reduce Motion */}
<div>
<SectionLabel>Reduce Motion</SectionLabel>
<p className="mb-2 text-xs text-pylon-text-secondary">
Reduces animations and transitions for accessibility.
</p>
<div className="flex gap-2">
{([false, true] as const).map((value) => (
<Button
key={String(value)}
type="button"
aria-pressed={settings.reduceMotion === value}
variant={settings.reduceMotion === value ? "default" : "outline"}
size="sm"
onClick={() => setReduceMotion(value)}
className="flex-1"
>
{value ? "Reduced" : "Normal"}
</Button>
))}
</div>
</div>
</> </>
)} )}
@@ -279,6 +313,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
<Button <Button
key={value} key={value}
type="button" type="button"
aria-pressed={settings.defaultColumnWidth === value}
variant={settings.defaultColumnWidth === value ? "default" : "outline"} variant={settings.defaultColumnWidth === value ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setDefaultColumnWidth(value)} onClick={() => setDefaultColumnWidth(value)}
@@ -308,7 +343,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
<div className="space-y-2 text-sm text-pylon-text"> <div className="space-y-2 text-sm text-pylon-text">
<p className="font-heading text-lg">OpenPylon</p> <p className="font-heading text-lg">OpenPylon</p>
<p className="text-pylon-text-secondary"> <p className="text-pylon-text-secondary">
v0.1.0 &middot; Local-first Kanban board v1.1.0 &middot; Local-first Kanban board
</p> </p>
<p className="text-pylon-text-secondary"> <p className="text-pylon-text-secondary">
Built with Tauri, React, and TypeScript. Built with Tauri, React, and TypeScript.

View File

@@ -1,4 +1,5 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
import { springs } from "@/lib/motion"; import { springs } from "@/lib/motion";
import { useToastStore } from "@/stores/toast-store"; import { useToastStore } from "@/stores/toast-store";
@@ -10,9 +11,17 @@ const TYPE_STYLES = {
export function ToastContainer() { export function ToastContainer() {
const toasts = useToastStore((s) => s.toasts); 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 ( 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> <AnimatePresence>
{toasts.map((toast) => ( {toasts.map((toast) => (
<motion.div <motion.div
@@ -21,9 +30,20 @@ export function ToastContainer() {
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.9 }} exit={{ opacity: 0, y: 20, scale: 0.9 }}
transition={springs.wobbly} 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> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>

View File

@@ -5,13 +5,13 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: 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: 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", "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: secondary:

View File

@@ -68,7 +68,7 @@ function DialogContent({
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close <DialogPrimitive.Close
data-slot="dialog-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 /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>

View File

@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input" data-slot="input"
className={cn( 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", "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", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
)} )}

View File

@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( 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 className
)} )}
{...props} {...props}

19
src/hooks/useAnnounce.ts Normal file
View 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);
}

View File

@@ -70,11 +70,11 @@
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 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: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --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); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
@@ -95,7 +95,7 @@
--pylon-column: oklch(95% 0.008 80); --pylon-column: oklch(95% 0.008 80);
--pylon-accent: oklch(55% 0.12 160); --pylon-accent: oklch(55% 0.12 160);
--pylon-text: oklch(25% 0.015 50); --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); --pylon-danger: oklch(55% 0.18 25);
} }
@@ -111,11 +111,11 @@
--secondary: oklch(0.32 0 0); --secondary: oklch(0.32 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.32 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: oklch(0.32 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --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%); --input: oklch(1 0 0 / 18%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
@@ -136,13 +136,13 @@
--pylon-column: oklch(27% 0.014 50); --pylon-column: oklch(27% 0.014 50);
--pylon-accent: oklch(62% 0.13 160); --pylon-accent: oklch(62% 0.13 160);
--pylon-text: oklch(92% 0.01 50); --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); --pylon-danger: oklch(62% 0.18 25);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: oklch(50% 0 0 / 20%) transparent; scrollbar-color: oklch(50% 0 0 / 20%) transparent;
} }
@@ -161,16 +161,16 @@
font-family: "Epilogue", system-ui, -apple-system, sans-serif; font-family: "Epilogue", system-ui, -apple-system, sans-serif;
} }
:focus-visible { :focus-visible {
outline: 2px solid var(--pylon-accent); outline: 3px solid var(--pylon-accent);
outline-offset: 2px; outline-offset: 2px;
} }
} }
/* OverlayScrollbars custom theme */ /* OverlayScrollbars custom theme */
.os-theme-pylon { .os-theme-pylon {
--os-handle-bg: oklch(50% 0 0 / 22%); --os-handle-bg: oklch(50% 0 0 / 45%);
--os-handle-bg-hover: oklch(50% 0 0 / 40%); --os-handle-bg-hover: oklch(50% 0 0 / 60%);
--os-handle-bg-active: oklch(50% 0 0 / 55%); --os-handle-bg-active: oklch(50% 0 0 / 75%);
--os-size: 8px; --os-size: 8px;
--os-handle-border-radius: 9999px; --os-handle-border-radius: 9999px;
--os-padding-perpendicular: 2px; --os-padding-perpendicular: 2px;
@@ -178,15 +178,22 @@
--os-handle-min-size: 30px; --os-handle-min-size: 30px;
} }
.dark .os-theme-pylon { .dark .os-theme-pylon {
--os-handle-bg: oklch(80% 0 0 / 18%); --os-handle-bg: oklch(80% 0 0 / 35%);
--os-handle-bg-hover: oklch(80% 0 0 / 35%); --os-handle-bg-hover: oklch(80% 0 0 / 55%);
--os-handle-bg-active: oklch(80% 0 0 / 50%); --os-handle-bg-active: oklch(80% 0 0 / 70%);
} }
@media (prefers-contrast: more) { @media (prefers-contrast: more) {
:root { :root {
--pylon-text: oklch(10% 0.02 50); --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%);
} }
} }
@@ -200,3 +207,12 @@
scroll-behavior: auto !important; scroll-behavior: auto !important;
} }
} }
.reduce-motion *,
.reduce-motion *::before,
.reduce-motion *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}

View File

@@ -87,4 +87,5 @@ export const appSettingsSchema = z.object({
boardSortOrder: z.enum(["manual", "title", "created", "updated"]).default("updated"), boardSortOrder: z.enum(["manual", "title", "created", "updated"]).default("updated"),
boardManualOrder: z.array(z.string()).default([]), boardManualOrder: z.array(z.string()).default([]),
lastNotificationCheck: z.string().nullable().default(null), lastNotificationCheck: z.string().nullable().default(null),
reduceMotion: z.boolean().default(false),
}); });

View File

@@ -21,6 +21,7 @@ interface AppState {
setView: (view: View) => void; setView: (view: View) => void;
refreshBoards: () => Promise<void>; refreshBoards: () => Promise<void>;
addRecentBoard: (boardId: string) => void; addRecentBoard: (boardId: string) => void;
setReduceMotion: (reduceMotion: boolean) => void;
setBoardSortOrder: (order: BoardSortOrder) => void; setBoardSortOrder: (order: BoardSortOrder) => void;
setBoardManualOrder: (ids: string[]) => void; setBoardManualOrder: (ids: string[]) => void;
getSortedBoards: () => BoardMeta[]; getSortedBoards: () => BoardMeta[];
@@ -36,6 +37,10 @@ function applyTheme(theme: AppSettings["theme"]): void {
} }
} }
function applyReduceMotion(on: boolean): void {
document.documentElement.classList.toggle("reduce-motion", on);
}
function applyAppearance(settings: AppSettings): void { function applyAppearance(settings: AppSettings): void {
const root = document.documentElement; const root = document.documentElement;
root.style.fontSize = `${settings.uiZoom * 16}px`; root.style.fontSize = `${settings.uiZoom * 16}px`;
@@ -70,6 +75,7 @@ export const useAppStore = create<AppState>((set, get) => ({
boardSortOrder: "updated", boardSortOrder: "updated",
boardManualOrder: [], boardManualOrder: [],
lastNotificationCheck: null, lastNotificationCheck: null,
reduceMotion: false,
}, },
boards: [], boards: [],
view: { type: "board-list" }, view: { type: "board-list" },
@@ -82,6 +88,7 @@ export const useAppStore = create<AppState>((set, get) => ({
set({ settings, boards, initialized: true }); set({ settings, boards, initialized: true });
applyTheme(settings.theme); applyTheme(settings.theme);
applyAppearance(settings); applyAppearance(settings);
applyReduceMotion(settings.reduceMotion);
// Due date notifications (once per hour) // Due date notifications (once per hour)
const lastCheck = settings.lastNotificationCheck; const lastCheck = settings.lastNotificationCheck;
@@ -148,6 +155,11 @@ export const useAppStore = create<AppState>((set, get) => ({
updateAndSave(get, set, { defaultColumnWidth }); updateAndSave(get, set, { defaultColumnWidth });
}, },
setReduceMotion: (reduceMotion) => {
updateAndSave(get, set, { reduceMotion });
applyReduceMotion(reduceMotion);
},
setView: (view) => set({ view }), setView: (view) => set({ view }),
refreshBoards: async () => { refreshBoards: async () => {

View File

@@ -12,9 +12,28 @@ interface ToastState {
toasts: Toast[]; toasts: Toast[];
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
removeToast: (id: string) => void; removeToast: (id: string) => void;
pauseToast: (id: string) => void;
resumeToast: (id: string) => void;
} }
let nextId = 0; 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) => ({ export const useToastStore = create<ToastState>((set) => ({
toasts: [], toasts: [],
@@ -22,12 +41,33 @@ export const useToastStore = create<ToastState>((set) => ({
addToast: (message, type = "info") => { addToast: (message, type = "info") => {
const id = String(++nextId); const id = String(++nextId);
set((s) => ({ toasts: [...s.toasts, { id, message, type }] })); set((s) => ({ toasts: [...s.toasts, { id, message, type }] }));
setTimeout(() => { startTimer(id, TOAST_DURATION, set);
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
}, 3000);
}, },
removeToast: (id) => { 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) })); 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);
}
},
})); }));

View File

@@ -22,4 +22,5 @@ export interface AppSettings {
boardSortOrder: BoardSortOrder; boardSortOrder: BoardSortOrder;
boardManualOrder: string[]; boardManualOrder: string[];
lastNotificationCheck: string | null; lastNotificationCheck: string | null;
reduceMotion: boolean;
} }