@pi-unipi/kanboard 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # @pi-unipi/kanboard
2
+
3
+ Visualization layer for unipi workflow data. Kanboard provides an HTTP server with htmx + Alpine.js UI, modular parsers for all workflow document types, two web pages (Milestones + Workflow), a TUI overlay with tasks list and kanban board, and a doctor skill for parser diagnostics.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ # Start the kanboard server
9
+ /unipi:kanboard
10
+
11
+ # Diagnose parser issues
12
+ /unipi:kanboard-doctor
13
+ ```
14
+
15
+ ## Architecture
16
+
17
+ ```
18
+ kanboard/
19
+ ├── server/ # HTTP server with port allocation
20
+ │ ├── index.ts # Server core (KanboardServer class)
21
+ │ └── routes/ # Route handlers (milestone, workflow)
22
+ ├── parser/ # Document parsers
23
+ │ ├── index.ts # ParserRegistry + createDefaultRegistry()
24
+ │ ├── specs.ts # Spec parser (checklist items)
25
+ │ ├── plans.ts # Plan parser (task statuses)
26
+ │ ├── milestones.ts # Milestone parser
27
+ │ └── remaining.ts # Quick-work, debug, fix, chore, review
28
+ ├── ui/ # Web UI
29
+ │ ├── layouts/ # Base HTML layout
30
+ │ ├── static/ # CSS + JS (style.css, app.js)
31
+ │ ├── components/ # Reusable components
32
+ │ ├── milestone/ # Milestone page renderer
33
+ │ └── workflow/ # Workflow page renderer
34
+ ├── tui/ # TUI overlay
35
+ │ └── kanboard-overlay.ts
36
+ ├── skills/ # Skills
37
+ │ └── kanboard-doctor/ # Parser diagnostics
38
+ ├── commands.ts # Command registration
39
+ ├── types.ts # Shared TypeScript types
40
+ └── index.ts # Extension entry point
41
+ ```
42
+
43
+ ## Web Pages
44
+
45
+ ### Milestones (`/`)
46
+ - Displays MILESTONES.md phases with progress bars
47
+ - Checklist items with status indicators (✓ done, ○ todo)
48
+ - Collapsible sections per phase
49
+ - Copy-to-clipboard for `/unipi:milestone-update`
50
+
51
+ ### Workflow (`/workflow`)
52
+ - Cards grouped by document type (specs, plans, fixes, etc.)
53
+ - Progress indicators per card
54
+ - Alpine.js filtering by status (All, To Do, In Progress, Done)
55
+ - Copy-to-clipboard for relevant commands
56
+
57
+ ## TUI Overlay
58
+
59
+ Two tabs accessible via the kanboard overlay:
60
+
61
+ - **Tasks** — Flat list of all tasks from all documents with status icons
62
+ - **Board** — Kanban columns (To Do / In Progress / Done)
63
+
64
+ ### Controls
65
+ - `j/k` — Navigate up/down
66
+ - `h/l` — Switch columns (Board tab)
67
+ - `Tab` or `b` — Switch between Tasks/Board tabs
68
+ - `t` — Switch to Tasks tab
69
+ - `gg/G` — Jump to top/bottom
70
+ - `q/Esc` — Close overlay
71
+
72
+ ## API Endpoints
73
+
74
+ | Method | Path | Description |
75
+ |--------|------|-------------|
76
+ | GET | `/` | Milestone page |
77
+ | GET | `/workflow` | Workflow page |
78
+ | GET | `/api/milestones` | Milestone JSON data |
79
+ | GET | `/api/workflow` | Workflow JSON data |
80
+ | POST | `/api/docs/:type/:file/items/:line` | Update item status |
81
+
82
+ ## Parser System
83
+
84
+ Kanboard parses 8 document types from `.unipi/docs/`:
85
+
86
+ | Type | Directory | What's Parsed |
87
+ |------|-----------|---------------|
88
+ | Spec | `specs/` | `- [ ]` / `- [x]` checklist items |
89
+ | Plan | `plans/` | `unstarted:` / `in-progress:` / `completed:` statuses |
90
+ | Milestone | `MILESTONES.md` | Phase headers + checklist items |
91
+ | Quick-work | `quick-work/` | Title + checklist items |
92
+ | Debug | `debug/` | Headers + checklists |
93
+ | Fix | `fix/` | Headers + checklists + related debug ref |
94
+ | Chore | `chore/` | Chore steps as checklist items |
95
+ | Review | `reviews/` | Review remarks as checklist items |
96
+
97
+ ### Parser Warnings
98
+
99
+ Parsers are resilient — they collect warnings per file and return partial results:
100
+ - Empty checkbox text
101
+ - Malformed checkboxes
102
+ - Missing frontmatter
103
+ - Unparseable lines
104
+
105
+ Warnings are surfaced in the kanboard-doctor skill.
106
+
107
+ ## Doctor Skill
108
+
109
+ The `kanboard-doctor` skill runs all parsers and produces a diagnostic report:
110
+
111
+ 1. **Run All Parsers** — Parse every document
112
+ 2. **Collect Errors** — Group warnings by file
113
+ 3. **Present Report** — Structured error listing
114
+ 4. **Fix One by One** — Suggest fixes, ask user to confirm
115
+ 5. **Re-validate** — Re-run parser after each fix
116
+
117
+ ## Server Configuration
118
+
119
+ Default configuration (from `@pi-unipi/core`):
120
+
121
+ ```typescript
122
+ KANBOARD_DEFAULTS = {
123
+ PORT: 8165, // Starting port
124
+ MAX_PORT: 8175, // Maximum port to try
125
+ }
126
+ ```
127
+
128
+ - Port allocation: tries 8165, increments on EADDRINUSE
129
+ - PID file: `.unipi/kanboard.pid`
130
+ - Graceful shutdown on SIGINT/SIGTERM
131
+ - Static files served from `ui/static/`
132
+
133
+ ## Dependencies
134
+
135
+ - `@pi-unipi/core` — Shared constants and utilities
136
+ - `@mariozechner/pi-coding-agent` — Extension API
137
+ - `@mariozechner/pi-tui` — TUI overlay API
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@pi-unipi/kanboard",
3
+ "version": "0.1.2",
4
+ "description": "Visualization layer for unipi workflow — HTTP server with htmx/Alpine.js UI, modular parsers, TUI overlay, and kanban board",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Neuron Mr White",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Neuron-Mr-White/unipi.git",
11
+ "directory": "packages/kanboard"
12
+ },
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi-extension",
16
+ "pi-coding-agent",
17
+ "unipi",
18
+ "kanboard",
19
+ "kanban",
20
+ "visualization",
21
+ "milestones",
22
+ "workflow"
23
+ ],
24
+ "files": [
25
+ "src/**/*.ts",
26
+ "ui/**/*.ts",
27
+ "ui/**/*.css",
28
+ "ui/**/*.js",
29
+ "skills/**/*",
30
+ "README.md"
31
+ ],
32
+ "pi": {
33
+ "extensions": [
34
+ "index.ts"
35
+ ],
36
+ "skills": [
37
+ "skills"
38
+ ]
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "@pi-unipi/core": "*"
45
+ },
46
+ "peerDependencies": {
47
+ "@mariozechner/pi-coding-agent": "*",
48
+ "@mariozechner/pi-tui": "*"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^25.6.0",
52
+ "typescript": "^6.0.0"
53
+ }
54
+ }
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: kanboard-doctor
3
+ description: "Diagnose and fix kanboard parser issues — validates all workflow documents, reports errors, suggests fixes."
4
+ ---
5
+
6
+ # Kanboard Doctor
7
+
8
+ Diagnose parser issues across all workflow documents. Non-destructive — only suggests fixes, asks user to confirm.
9
+
10
+ ## Phase 1: Run All Parsers
11
+
12
+ Execute each parser against its document type directory:
13
+
14
+ 1. Load the parser registry from `@pi-unipi/kanboard`
15
+ 2. Run `registry.parseAll(".unipi/docs")` to parse all documents
16
+ 3. Collect all `ParsedDoc` results including their `warnings` arrays
17
+
18
+ ## Phase 2: Collect Errors
19
+
20
+ Group warnings and errors by file with line numbers:
21
+
22
+ 1. For each `ParsedDoc` with `warnings.length > 0`:
23
+ - Group by `filePath`
24
+ - Include line numbers where available
25
+ - Categorize: malformed checkboxes, empty fields, parse failures
26
+ 2. Also flag documents that returned 0 items (may indicate parsing failure)
27
+
28
+ ## Phase 3: Present Report
29
+
30
+ Show a structured error report:
31
+
32
+ ```
33
+ 📋 Kanboard Doctor Report
34
+
35
+ Files scanned: N
36
+ Files with issues: M
37
+
38
+ 📄 .unipi/docs/specs/example.md
39
+ ⚠ Line 15: Empty checkbox text
40
+ ⚠ Line 23: Malformed checkbox (missing bracket)
41
+
42
+ 📄 .unipi/docs/plans/old-plan.md
43
+ ⚠ Line 5: Empty task name after status
44
+ ```
45
+
46
+ ## Phase 4: Fix One by One
47
+
48
+ For each issue, suggest a fix and ask user to confirm:
49
+
50
+ 1. Show the problematic line with context
51
+ 2. Suggest the corrected version
52
+ 3. Ask: "Apply this fix? (y/n)"
53
+ 4. If yes, apply the fix using the edit tool
54
+ 5. Move to next issue
55
+
56
+ **Non-destructive rules:**
57
+ - Never modify without asking
58
+ - Show before/after for each change
59
+ - Allow skipping individual fixes
60
+ - Allow "fix all" for simple patterns (e.g., trailing whitespace)
61
+
62
+ ## Phase 5: Re-validate
63
+
64
+ After each fix, re-run the parser on the modified file:
65
+
66
+ 1. Parse the file again
67
+ 2. Verify the specific error is resolved
68
+ 3. Check that no new errors were introduced
69
+ 4. Report: "✓ Fixed" or "✗ Still has issues"
70
+
71
+ After all fixes, run full `registry.parseAll()` to confirm clean state.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @pi-unipi/kanboard — Checklist Component
3
+ *
4
+ * Reusable checklist renderer with status indicators and copy buttons.
5
+ */
6
+
7
+ import type { ParsedItem } from "../../types.js";
8
+
9
+ /** Escape HTML */
10
+ function esc(text: string): string {
11
+ return text
12
+ .replace(/&/g, "&")
13
+ .replace(/</g, "&lt;")
14
+ .replace(/>/g, "&gt;")
15
+ .replace(/"/g, "&quot;")
16
+ .replace(/'/g, "&#39;");
17
+ }
18
+
19
+ /** Status icon */
20
+ function statusIcon(status: string): string {
21
+ switch (status) {
22
+ case "done":
23
+ return "✓";
24
+ case "in-progress":
25
+ return "◐";
26
+ default:
27
+ return "○";
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Render a checklist of items.
33
+ */
34
+ export function renderChecklist(items: ParsedItem[]): string {
35
+ if (items.length === 0) {
36
+ return '<div class="empty-state"><p class="empty-state-text">No items</p></div>';
37
+ }
38
+
39
+ const rows = items
40
+ .map(
41
+ (item) => `
42
+ <li class="checklist-item">
43
+ <span class="checklist-status ${item.status}" aria-label="${item.status}">
44
+ <span aria-hidden="true">${statusIcon(item.status)}</span>
45
+ <span class="visually-hidden">${item.status}</span>
46
+ </span>
47
+ <span class="checklist-text${item.status === "done" ? " done" : ""}">${esc(item.text)}</span>
48
+ ${
49
+ item.command
50
+ ? `<button class="copy-btn" onclick="copyToClipboard('${esc(item.command)}', event)" aria-label="Copy ${esc(item.command)} to clipboard">${esc(item.command)}</button>`
51
+ : ""
52
+ }
53
+ </li>`,
54
+ )
55
+ .join("\n");
56
+
57
+ return `<ul class="checklist">${rows}</ul>`;
58
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @pi-unipi/kanboard — Copy Button Component
3
+ *
4
+ * Reusable copy-to-clipboard button with visual feedback.
5
+ */
6
+
7
+ /** Escape HTML */
8
+ function esc(text: string): string {
9
+ return text
10
+ .replace(/&/g, "&amp;")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;")
13
+ .replace(/"/g, "&quot;")
14
+ .replace(/'/g, "&#39;");
15
+ }
16
+
17
+ /**
18
+ * Render a copy-to-clipboard button.
19
+ */
20
+ export function renderCopyButton(text: string, label?: string): string {
21
+ const displayLabel = label ?? (text.length > 40 ? text.slice(0, 37) + "..." : text);
22
+ return `<button class="copy-btn" onclick="copyToClipboard('${esc(text)}', event)" title="${esc(text)}" aria-label="Copy ${esc(displayLabel)} to clipboard">${esc(displayLabel)}</button>`;
23
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @pi-unipi/kanboard — Status Badge Component
3
+ *
4
+ * Color-coded status badge for items.
5
+ */
6
+
7
+ /** Status badge labels */
8
+ const BADGE_LABELS: Record<string, string> = {
9
+ done: "Done",
10
+ "in-progress": "In Progress",
11
+ todo: "To Do",
12
+ reviewed: "Reviewed",
13
+ };
14
+
15
+ /**
16
+ * Render a status badge.
17
+ */
18
+ export function renderStatusBadge(status: string): string {
19
+ const label = BADGE_LABELS[status] ?? status;
20
+ return `<span class="badge badge-${status}">${label}</span>`;
21
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @pi-unipi/kanboard — Base HTML Layout
3
+ *
4
+ * Shared layout for all kanboard web pages.
5
+ * Includes htmx + Alpine.js CDN, Google Fonts, and responsive layout.
6
+ */
7
+
8
+ /** Active page type */
9
+ export type ActivePage = "milestones" | "workflow";
10
+
11
+ /**
12
+ * Render the full HTML layout wrapping page content.
13
+ */
14
+ export function renderLayout(
15
+ title: string,
16
+ content: string,
17
+ activePage: ActivePage,
18
+ ): string {
19
+ return `<!DOCTYPE html>
20
+ <html lang="en">
21
+ <head>
22
+ <meta charset="UTF-8">
23
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
24
+ <title>${escapeHtml(title)} — Kanboard</title>
25
+ <script src="https://unpkg.com/htmx.org@1.9.12"></script>
26
+ <script defer src="https://unpkg.com/alpinejs@3.14.3/dist/cdn.min.js"></script>
27
+ <link rel="preconnect" href="https://fonts.googleapis.com">
28
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
29
+ <link href="https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@1,6..96,400..900&family=Onest:wght@100..900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
30
+ <link rel="stylesheet" href="/static/style.css">
31
+ </head>
32
+ <body>
33
+ <nav class="navbar">
34
+ <div class="nav-brand">
35
+ <span class="nav-icon">✦</span>
36
+ <span class="nav-title">Kanboard</span>
37
+ </div>
38
+ <div class="nav-links">
39
+ <a href="/" class="nav-link ${activePage === "milestones" ? "active" : ""}" ${activePage === "milestones" ? 'aria-current="page"' : ""}>
40
+ Milestones
41
+ </a>
42
+ <a href="/workflow" class="nav-link ${activePage === "workflow" ? "active" : ""}" ${activePage === "workflow" ? 'aria-current="page"' : ""}>
43
+ Workflow
44
+ </a>
45
+ </div>
46
+ </nav>
47
+
48
+ <main class="container">
49
+ ${content}
50
+ </main>
51
+
52
+ <footer class="footer">
53
+ <span>pi-unipi / kanboard</span>
54
+ </footer>
55
+
56
+ <script src="/static/app.js"></script>
57
+ </body>
58
+ </html>`;
59
+ }
60
+
61
+ /** Escape HTML special characters */
62
+ function escapeHtml(text: string): string {
63
+ return text
64
+ .replace(/&/g, "&amp;")
65
+ .replace(/</g, "&lt;")
66
+ .replace(/>/g, "&gt;")
67
+ .replace(/"/g, "&quot;");
68
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @pi-unipi/kanboard — Milestone Page
3
+ *
4
+ * Renders the milestone page with phases, progress bars, and checklists.
5
+ */
6
+
7
+ import type { ParsedDoc, ParsedItem } from "../../types.js";
8
+ import { renderLayout } from "../layouts/base.js";
9
+
10
+ /** Escape HTML special characters */
11
+ function esc(text: string): string {
12
+ return text
13
+ .replace(/&/g, "&amp;")
14
+ .replace(/</g, "&lt;")
15
+ .replace(/>/g, "&gt;")
16
+ .replace(/"/g, "&quot;");
17
+ }
18
+
19
+ /** Render status icon for an item */
20
+ function statusIcon(status: string): string {
21
+ switch (status) {
22
+ case "done":
23
+ return "✓";
24
+ case "in-progress":
25
+ return "◐";
26
+ default:
27
+ return "○";
28
+ }
29
+ }
30
+
31
+ /** Render a single checklist item */
32
+ function renderItem(item: ParsedItem): string {
33
+ const doneClass = item.status === "done" ? " done" : "";
34
+ const commandBtn = item.command
35
+ ? `<button class="copy-btn" onclick="copyToClipboard('${esc(item.command)}', event)" aria-label="Copy ${esc(item.command)} to clipboard">${esc(item.command)}</button>`
36
+ : "";
37
+
38
+ return `
39
+ <li class="checklist-item">
40
+ <span class="checklist-status ${item.status}" aria-label="${item.status}">
41
+ <span aria-hidden="true">${statusIcon(item.status)}</span>
42
+ <span class="visually-hidden">${item.status}</span>
43
+ </span>
44
+ <span class="checklist-text${doneClass}">${esc(item.text)}</span>
45
+ ${commandBtn}
46
+ </li>`;
47
+ }
48
+
49
+ /** Render the milestone page */
50
+ export function renderMilestonePage(docs: ParsedDoc[]): string {
51
+ if (docs.length === 0) {
52
+ const content = `
53
+ <div class="page-header">
54
+ <h1 class="page-title">Milestones</h1>
55
+ <p class="page-subtitle">Track project progress</p>
56
+ </div>
57
+ <div class="empty-state">
58
+ <div class="empty-state-icon">✦</div>
59
+ <p class="empty-state-text">No MILESTONES.md found</p>
60
+ </div>`;
61
+ return renderLayout("Milestones", content, "milestones");
62
+ }
63
+
64
+ const doc = docs[0];
65
+ const totalItems = doc.items.length;
66
+ const doneItems = doc.items.filter((i) => i.status === "done").length;
67
+ const percent = totalItems > 0 ? Math.round((doneItems / totalItems) * 100) : 0;
68
+
69
+ const phases = groupByPhase(doc.items);
70
+
71
+ let phasesHtml = "";
72
+ for (const [phase, items] of Object.entries(phases)) {
73
+ const phaseTotal = items.length;
74
+ const phaseDone = items.filter((i) => i.status === "done").length;
75
+ const phasePercent = phaseTotal > 0 ? Math.round((phaseDone / phaseTotal) * 100) : 0;
76
+
77
+ phasesHtml += `
78
+ <div class="section">
79
+ <button type="button" class="section-header" aria-expanded="true" onclick="toggleSection(event)">
80
+ <span class="section-toggle">▶</span>
81
+ <div class="section-title-wrap">
82
+ <h2 class="section-title">${esc(phase)}</h2>
83
+ </div>
84
+ <span class="section-count">${phaseDone}/${phaseTotal}</span>
85
+ <div class="progress-bar" style="--progress: ${phasePercent};">
86
+ <div class="progress-fill"></div>
87
+ </div>
88
+ </button>
89
+ <div class="section-content">
90
+ <ul class="checklist">
91
+ ${items.map(renderItem).join("\n")}
92
+ </ul>
93
+ </div>
94
+ </div>`;
95
+ }
96
+
97
+ const content = `
98
+ <div class="page-header">
99
+ <h1 class="page-title">${esc(doc.title)}</h1>
100
+ <p class="page-subtitle">${doneItems}/${totalItems} items complete</p>
101
+ <div class="progress-bar" style="--progress: ${percent};">
102
+ <div class="progress-fill"></div>
103
+ </div>
104
+ <div class="page-header-stat">${percent}% complete</div>
105
+ </div>
106
+
107
+ ${phasesHtml}
108
+
109
+ ${doc.warnings.length > 0 ? renderWarnings(doc.warnings) : ""}`;
110
+
111
+ return renderLayout("Milestones", content, "milestones");
112
+ }
113
+
114
+ /** Group items by their phase prefix [Phase Name] */
115
+ function groupByPhase(items: ParsedItem[]): Record<string, ParsedItem[]> {
116
+ const phases: Record<string, ParsedItem[]> = {};
117
+
118
+ for (const item of items) {
119
+ const match = item.text.match(/^\[(.+?)\]\s*(.*)$/);
120
+ if (match) {
121
+ const phase = match[1];
122
+ const text = match[2];
123
+ if (!phases[phase]) phases[phase] = [];
124
+ phases[phase].push({ ...item, text });
125
+ } else {
126
+ if (!phases["Other"]) phases["Other"] = [];
127
+ phases["Other"].push(item);
128
+ }
129
+ }
130
+
131
+ return phases;
132
+ }
133
+
134
+ /** Render warnings list */
135
+ function renderWarnings(warnings: string[]): string {
136
+ return `
137
+ <div class="warnings">
138
+ ${warnings.map((w) => `<div class="warning-item">${esc(w)}</div>`).join("\n")}
139
+ </div>`;
140
+ }
@@ -0,0 +1,73 @@
1
+ /* @pi-unipi/kanboard — Client-side JavaScript */
2
+
3
+ /**
4
+ * Copy text to clipboard and show feedback.
5
+ */
6
+ function copyToClipboard(text, event) {
7
+ navigator.clipboard.writeText(text).then(() => {
8
+ const btn = event.currentTarget;
9
+ btn.classList.add("copied");
10
+ const original = btn.innerHTML;
11
+ btn.innerHTML = '<span>copied</span>';
12
+ setTimeout(() => {
13
+ btn.classList.remove("copied");
14
+ btn.innerHTML = original;
15
+ }, 1800);
16
+ });
17
+ }
18
+
19
+ /**
20
+ * Toggle a section's expanded state with animation.
21
+ */
22
+ function toggleSection(event) {
23
+ const header = event.currentTarget;
24
+ const content = header.nextElementSibling;
25
+ if (!content) return;
26
+
27
+ const wasOpen = header.getAttribute("aria-expanded") === "true";
28
+ header.setAttribute("aria-expanded", wasOpen ? "false" : "true");
29
+
30
+ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
31
+
32
+ if (!wasOpen) {
33
+ content.style.display = "block";
34
+ if (!prefersReducedMotion) {
35
+ content.style.opacity = "0";
36
+ content.style.transform = "translateY(-4px)";
37
+ requestAnimationFrame(() => {
38
+ content.style.transition = "opacity 0.2s ease, transform 0.2s ease";
39
+ content.style.opacity = "1";
40
+ content.style.transform = "translateY(0)";
41
+ });
42
+ } else {
43
+ content.style.opacity = "1";
44
+ content.style.transform = "translateY(0)";
45
+ }
46
+ } else {
47
+ if (!prefersReducedMotion) {
48
+ content.style.transition = "opacity 0.15s ease";
49
+ content.style.opacity = "0";
50
+ setTimeout(() => {
51
+ content.style.display = "none";
52
+ }, 150);
53
+ } else {
54
+ content.style.display = "none";
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Alpine.js data for filtering items by status.
61
+ */
62
+ function kanboardFilters() {
63
+ return {
64
+ filter: "all",
65
+ setFilter(f) {
66
+ this.filter = f;
67
+ },
68
+ isVisible(status) {
69
+ if (this.filter === "all") return true;
70
+ return status === this.filter;
71
+ },
72
+ };
73
+ }