@the-bearded-bear/claude-craft 7.35.0 → 8.1.0
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/Dev/scripts/install-php-rules.sh +1 -1
- package/Dev/scripts/validate-skills-spec.sh +121 -0
- package/README.md +13 -11
- package/cli/index.js +6 -0
- package/cli/kanban/client/index.html +17 -0
- package/cli/kanban/client/src/App.svelte +106 -0
- package/cli/kanban/client/src/app.css +175 -0
- package/cli/kanban/client/src/lib/router.svelte.js +19 -0
- package/cli/kanban/client/src/lib/store.svelte.js +132 -0
- package/cli/kanban/client/src/main.js +6 -0
- package/cli/kanban/client/src/views/BacklogView.svelte +344 -0
- package/cli/kanban/client/src/views/BurndownView.svelte +189 -0
- package/cli/kanban/client/src/views/DepsView.svelte +334 -0
- package/cli/kanban/client/src/views/DocsView.svelte +451 -0
- package/cli/kanban/client/src/views/KanbanView.svelte +227 -0
- package/cli/kanban/client/vite.config.js +21 -0
- package/cli/kanban/server/app.js +201 -0
- package/cli/kanban/server/middleware/security.js +53 -0
- package/cli/kanban/server/services/event-bus.js +33 -0
- package/cli/kanban/server/services/file-scanner.js +113 -0
- package/cli/kanban/server/services/file-watcher.js +68 -0
- package/cli/kanban/server/services/file-writer.js +107 -0
- package/cli/kanban/server/services/frontmatter.js +55 -0
- package/cli/kanban/server/services/repository.js +173 -0
- package/cli/kanban/server/services/sprint-cache.js +208 -0
- package/cli/kanban/server/services/state-machine.js +156 -0
- package/cli/kanban/shared/schemas.js +127 -0
- package/cli/lib/help.js +4 -0
- package/cli/lib/kanban.js +103 -0
- package/package.json +21 -3
|
@@ -12,7 +12,7 @@ I18N_DIR="$(dirname "$SCRIPT_DIR")/i18n"
|
|
|
12
12
|
TECH_NAME="PHP"
|
|
13
13
|
TECH_DISPLAY_NAME="PHP"
|
|
14
14
|
TECH_NAMESPACE="php"
|
|
15
|
-
DEFAULT_STACK="PHP 8.
|
|
15
|
+
DEFAULT_STACK="PHP 8.5, Composer, PSR Standards, Pest 4, PHPUnit 12"
|
|
16
16
|
|
|
17
17
|
# --- TCL version ---
|
|
18
18
|
source "${SCRIPT_DIR}/tcl-common.sh"
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# validate-skills-spec.sh — Verify all .claude/skills/* conform to Anthropic Agent Skills spec.
|
|
3
|
+
# v8.0.0+ : failure blocks CI.
|
|
4
|
+
#
|
|
5
|
+
# Spec: https://github.com/anthropics/skills/blob/main/spec/agent-skills-spec.md
|
|
6
|
+
# Rules enforced:
|
|
7
|
+
# 1. Each skill is a directory under .claude/skills/
|
|
8
|
+
# 2. Each skill has a SKILL.md file at its root
|
|
9
|
+
# 3. SKILL.md starts with YAML frontmatter (---)
|
|
10
|
+
# 4. Frontmatter contains `name:` (matches dir name) and `description:` (non-empty)
|
|
11
|
+
# 5. No absolute paths (/home/, /Users/, C:\) in any skill file
|
|
12
|
+
# 6. Skill name is lowercase kebab-case
|
|
13
|
+
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
SKILLS_DIR="${1:-.claude/skills}"
|
|
17
|
+
ERRORS=0
|
|
18
|
+
WARNINGS=0
|
|
19
|
+
|
|
20
|
+
fail() {
|
|
21
|
+
echo "❌ FAIL: $1" >&2
|
|
22
|
+
ERRORS=$((ERRORS + 1))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
warn() {
|
|
26
|
+
echo "⚠️ WARN: $1" >&2
|
|
27
|
+
WARNINGS=$((WARNINGS + 1))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
pass() {
|
|
31
|
+
echo "✅ $1"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if [[ ! -d "$SKILLS_DIR" ]]; then
|
|
35
|
+
fail "Skills directory not found: $SKILLS_DIR"
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
echo "🔍 Validating skills in $SKILLS_DIR"
|
|
40
|
+
echo ""
|
|
41
|
+
|
|
42
|
+
SKILL_COUNT=0
|
|
43
|
+
for skill_path in "$SKILLS_DIR"/*/; do
|
|
44
|
+
[[ -d "$skill_path" ]] || continue
|
|
45
|
+
skill_name=$(basename "$skill_path")
|
|
46
|
+
SKILL_COUNT=$((SKILL_COUNT + 1))
|
|
47
|
+
|
|
48
|
+
# Rule 6: kebab-case
|
|
49
|
+
if [[ ! "$skill_name" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
|
50
|
+
fail "[$skill_name] name must be lowercase kebab-case"
|
|
51
|
+
continue
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
skill_md="${skill_path}SKILL.md"
|
|
55
|
+
|
|
56
|
+
# Rule 2: SKILL.md exists
|
|
57
|
+
if [[ ! -f "$skill_md" ]]; then
|
|
58
|
+
fail "[$skill_name] missing SKILL.md"
|
|
59
|
+
continue
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
# Rule 3: starts with ---
|
|
63
|
+
first_line=$(head -1 "$skill_md")
|
|
64
|
+
if [[ "$first_line" != "---" ]]; then
|
|
65
|
+
fail "[$skill_name] SKILL.md must start with --- (YAML frontmatter)"
|
|
66
|
+
continue
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# Extract frontmatter (between first two --- lines)
|
|
70
|
+
frontmatter=$(awk '/^---$/{c++; next} c==1{print} c==2{exit}' "$skill_md")
|
|
71
|
+
|
|
72
|
+
# Rule 4a: name field present
|
|
73
|
+
if ! echo "$frontmatter" | grep -q "^name:"; then
|
|
74
|
+
fail "[$skill_name] frontmatter missing 'name:' field"
|
|
75
|
+
continue
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
declared_name=$(echo "$frontmatter" | grep "^name:" | head -1 | sed 's/^name:[[:space:]]*//' | tr -d '"' | tr -d "'")
|
|
79
|
+
if [[ "$declared_name" != "$skill_name" ]]; then
|
|
80
|
+
fail "[$skill_name] frontmatter name '$declared_name' does not match directory name"
|
|
81
|
+
continue
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
# Rule 4b: description field present and non-empty
|
|
85
|
+
if ! echo "$frontmatter" | grep -q "^description:"; then
|
|
86
|
+
fail "[$skill_name] frontmatter missing 'description:' field"
|
|
87
|
+
continue
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
description=$(echo "$frontmatter" | grep "^description:" | head -1 | sed 's/^description:[[:space:]]*//')
|
|
91
|
+
if [[ -z "$description" ]]; then
|
|
92
|
+
fail "[$skill_name] description is empty"
|
|
93
|
+
continue
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
# Warn if description too short (< 30 chars) — won't help Claude decide
|
|
97
|
+
if [[ ${#description} -lt 30 ]]; then
|
|
98
|
+
warn "[$skill_name] description too short (${#description} chars). Aim for >= 80 chars with 'Use when...'"
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
# Rule 5: no absolute paths in any file of the skill
|
|
102
|
+
if grep -rn "/home/\|/Users/\|C:\\\\" "$skill_path" 2>/dev/null | grep -v '\.git' | head -1 &>/dev/null; then
|
|
103
|
+
fail "[$skill_name] contains absolute path (forbidden by spec)"
|
|
104
|
+
continue
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
pass "[$skill_name]"
|
|
108
|
+
done
|
|
109
|
+
|
|
110
|
+
echo ""
|
|
111
|
+
echo "───────────────────────────────────────────"
|
|
112
|
+
echo "Summary: $SKILL_COUNT skills | $ERRORS errors | $WARNINGS warnings"
|
|
113
|
+
echo "───────────────────────────────────────────"
|
|
114
|
+
|
|
115
|
+
if [[ "$ERRORS" -gt 0 ]]; then
|
|
116
|
+
echo "❌ Validation FAILED"
|
|
117
|
+
exit 1
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
echo "✅ All skills conform to Anthropic Agent Skills spec"
|
|
121
|
+
exit 0
|
package/README.md
CHANGED
|
@@ -6,14 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
A comprehensive framework for AI-assisted development with [Claude Code](https://claude.ai/code). Install standardized rules, agents, and commands for your projects across multiple technology stacks.
|
|
8
8
|
|
|
9
|
-
## What's New in
|
|
10
|
-
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
9
|
+
## What's New in v8.0 (Breaking — Spec Anthropic)
|
|
10
|
+
|
|
11
|
+
- **Strict alignment to Anthropic Agent Skills spec** -- 41/41 skills conformes, validation CI (`Dev/scripts/validate-skills-spec.sh`)
|
|
12
|
+
- **5 new skills** (v7.31-v7.32) -- `atomic-tasks` (GSD), `design-md-convention`, `architect`, `debug-methodical`, `socratic-brainstorm`
|
|
13
|
+
- **4 new agents** (v7.34) -- `@security-auditor`, `@data-analyst`, `@migration-specialist`, `@cost-optimizer`
|
|
14
|
+
- **2 new commands** (v7.33-v7.34) -- `/common:pack-repo` (Repomix wrapper + fallback shell), `/uiux:generate-design-md`
|
|
15
|
+
- **Memory lifecycle hooks** (v7.35) -- 5 hooks + SQLite local, inspired by claude-mem
|
|
16
|
+
- **Rule 23 Karpathy** (v7.31) -- principes AI-first development
|
|
17
|
+
- **Convention DESIGN.md** (v7.31) -- template AI-friendly + `/uiux:generate-design-md`
|
|
18
|
+
- **Migration guide** -- [docs/MIGRATION-v7-to-v8.md](docs/MIGRATION-v7-to-v8.md)
|
|
17
19
|
- See [CHANGELOG](CHANGELOG.md) for full details
|
|
18
20
|
|
|
19
21
|
## Install and First Result
|
|
@@ -118,7 +120,7 @@ These are the commands you'll use most:
|
|
|
118
120
|
| `/common:ralph-run "task"` | Run Claude in continuous loop until task is done |
|
|
119
121
|
| `/qa:recette` | Automated acceptance testing via Chrome |
|
|
120
122
|
|
|
121
|
-
See [CLI Reference](docs/CLI-REFERENCE.md) for all
|
|
123
|
+
See [CLI Reference](docs/CLI-REFERENCE.md) for all 214 commands across 27 namespaces.
|
|
122
124
|
|
|
123
125
|
## Installation
|
|
124
126
|
|
|
@@ -183,8 +185,8 @@ Context usage is optimized: ~3,500 tokens always loaded vs ~70,000 if everything
|
|
|
183
185
|
| [Installation](docs/INSTALLATION.md) | All installation methods |
|
|
184
186
|
| [Configuration](docs/CONFIGURATION.md) | Project configuration |
|
|
185
187
|
| [CLI Reference](docs/CLI-REFERENCE.md) | Full CLI documentation |
|
|
186
|
-
| [Commands](docs/COMMANDS.md) | All
|
|
187
|
-
| [Agents](docs/AGENTS.md) | All
|
|
188
|
+
| [Commands](docs/COMMANDS.md) | All 214 commands |
|
|
189
|
+
| [Agents](docs/AGENTS.md) | All 67 agents |
|
|
188
190
|
| [Skills](docs/SKILLS.md) | Best practices reference |
|
|
189
191
|
| [Technologies](docs/TECHNOLOGIES.md) | Stack-specific guides |
|
|
190
192
|
| [BMAD Guide](docs/BMAD-PRACTICAL-GUIDE.md) | Project management framework |
|
package/cli/index.js
CHANGED
|
@@ -38,6 +38,7 @@ import { runCheck } from './lib/check.js';
|
|
|
38
38
|
import { runList } from './lib/list.js';
|
|
39
39
|
import { runDoctor } from './lib/doctor.js';
|
|
40
40
|
import { runUpdate } from './lib/update.js';
|
|
41
|
+
import { runKanban } from './lib/kanban.js';
|
|
41
42
|
|
|
42
43
|
// Flattener module
|
|
43
44
|
import { flatten as flattenCodebaseFn } from './flattener.js';
|
|
@@ -204,6 +205,11 @@ class ClaudeCraftCLI {
|
|
|
204
205
|
await runRalph(this, args.slice(1), options, ctx);
|
|
205
206
|
break;
|
|
206
207
|
|
|
208
|
+
case 'kanban':
|
|
209
|
+
printBanner(VERSION);
|
|
210
|
+
await runKanban({ targetPath: this.config.targetPath, options });
|
|
211
|
+
break;
|
|
212
|
+
|
|
207
213
|
case 'help':
|
|
208
214
|
case '--help':
|
|
209
215
|
case '-h':
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>claude-craft kanban</title>
|
|
7
|
+
<meta
|
|
8
|
+
http-equiv="Content-Security-Policy"
|
|
9
|
+
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self';"
|
|
10
|
+
/>
|
|
11
|
+
<link rel="stylesheet" href="./src/app.css" />
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="app"></div>
|
|
15
|
+
<script type="module" src="./src/main.js"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import { route } from './lib/router.svelte.js';
|
|
4
|
+
import { store, loadStories, loadSprint, connectEvents, disconnectEvents, dismissToast } from './lib/store.svelte.js';
|
|
5
|
+
import KanbanView from './views/KanbanView.svelte';
|
|
6
|
+
import BacklogView from './views/BacklogView.svelte';
|
|
7
|
+
// Lazy-loaded : pulls heavy viz libs (uPlot, Cytoscape, marked+dompurify).
|
|
8
|
+
const lazyBurndown = () => import('./views/BurndownView.svelte');
|
|
9
|
+
const lazyDeps = () => import('./views/DepsView.svelte');
|
|
10
|
+
const lazyDocs = () => import('./views/DocsView.svelte');
|
|
11
|
+
|
|
12
|
+
onMount(async () => {
|
|
13
|
+
await Promise.all([loadStories(), loadSprint()]);
|
|
14
|
+
connectEvents();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
onDestroy(() => disconnectEvents());
|
|
18
|
+
|
|
19
|
+
const navItems = [
|
|
20
|
+
{ path: '/kanban', label: 'Kanban' },
|
|
21
|
+
{ path: '/backlog', label: 'Backlog' },
|
|
22
|
+
{ path: '/burndown', label: 'Burndown' },
|
|
23
|
+
{ path: '/deps', label: 'Dependencies' },
|
|
24
|
+
{ path: '/docs', label: 'Docs' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function currentPath() {
|
|
28
|
+
return '/' + (route.parts[0] ?? 'kanban');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let sprintLabel = $derived(store.sprint ? `${store.sprint.name} (${store.sprint.sprint_id})` : 'no sprint');
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<div class="app-shell">
|
|
35
|
+
<aside class="sidebar" aria-label="Navigation">
|
|
36
|
+
<h1>claude-craft</h1>
|
|
37
|
+
<nav class="nav" aria-label="Views">
|
|
38
|
+
{#each navItems as item}
|
|
39
|
+
<a
|
|
40
|
+
href={'#' + item.path}
|
|
41
|
+
aria-current={currentPath() === item.path ? 'page' : undefined}
|
|
42
|
+
>{item.label}</a>
|
|
43
|
+
{/each}
|
|
44
|
+
</nav>
|
|
45
|
+
|
|
46
|
+
<h1>Sprint</h1>
|
|
47
|
+
<div style="font-size:12px; color: var(--fg-dim); padding: 0 10px;">
|
|
48
|
+
{sprintLabel}
|
|
49
|
+
</div>
|
|
50
|
+
</aside>
|
|
51
|
+
|
|
52
|
+
<header class="topbar" role="banner">
|
|
53
|
+
<div class="title">
|
|
54
|
+
{#if store.sprint}
|
|
55
|
+
{store.sprint.goal || store.sprint.name}
|
|
56
|
+
{:else}
|
|
57
|
+
Kanban
|
|
58
|
+
{/if}
|
|
59
|
+
</div>
|
|
60
|
+
<div class="status {store.connected ? 'connected' : 'disconnected'}" aria-live="polite">
|
|
61
|
+
{store.connected ? '● live' : '○ offline'}
|
|
62
|
+
</div>
|
|
63
|
+
</header>
|
|
64
|
+
|
|
65
|
+
<main class="main" id="main">
|
|
66
|
+
{#if store.error && store.stories.length === 0}
|
|
67
|
+
<div class="empty">
|
|
68
|
+
<strong>Cannot reach server.</strong><br/>
|
|
69
|
+
{store.error}
|
|
70
|
+
</div>
|
|
71
|
+
{:else if currentPath() === '/kanban'}
|
|
72
|
+
<KanbanView />
|
|
73
|
+
{:else if currentPath() === '/backlog'}
|
|
74
|
+
<BacklogView />
|
|
75
|
+
{:else if currentPath() === '/burndown'}
|
|
76
|
+
{#await lazyBurndown()}
|
|
77
|
+
<div class="empty">Loading…</div>
|
|
78
|
+
{:then mod}
|
|
79
|
+
{@const Comp = mod.default}
|
|
80
|
+
<Comp />
|
|
81
|
+
{/await}
|
|
82
|
+
{:else if currentPath() === '/deps'}
|
|
83
|
+
{#await lazyDeps()}
|
|
84
|
+
<div class="empty">Loading…</div>
|
|
85
|
+
{:then mod}
|
|
86
|
+
{@const Comp = mod.default}
|
|
87
|
+
<Comp />
|
|
88
|
+
{/await}
|
|
89
|
+
{:else if currentPath() === '/docs'}
|
|
90
|
+
{#await lazyDocs()}
|
|
91
|
+
<div class="empty">Loading…</div>
|
|
92
|
+
{:then mod}
|
|
93
|
+
{@const Comp = mod.default}
|
|
94
|
+
<Comp />
|
|
95
|
+
{/await}
|
|
96
|
+
{/if}
|
|
97
|
+
</main>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div class="toast-layer" aria-live="polite">
|
|
101
|
+
{#each store.toasts as t (t.id)}
|
|
102
|
+
<div class="toast {t.kind}" role="status" onclick={() => dismissToast(t.id)}>
|
|
103
|
+
{t.message}
|
|
104
|
+
</div>
|
|
105
|
+
{/each}
|
|
106
|
+
</div>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
--bg: #fafafa;
|
|
4
|
+
--bg-elev: #ffffff;
|
|
5
|
+
--bg-sidebar: #f3f3f5;
|
|
6
|
+
--fg: #1a1a1a;
|
|
7
|
+
--fg-dim: #6b7280;
|
|
8
|
+
--border: #e4e4e7;
|
|
9
|
+
--accent: #7c3aed;
|
|
10
|
+
--accent-fg: #ffffff;
|
|
11
|
+
--danger: #dc2626;
|
|
12
|
+
--warn: #d97706;
|
|
13
|
+
--ok: #16a34a;
|
|
14
|
+
--info: #0284c7;
|
|
15
|
+
--shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
16
|
+
--radius: 6px;
|
|
17
|
+
--font: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
18
|
+
--mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@media (prefers-color-scheme: dark) {
|
|
22
|
+
:root {
|
|
23
|
+
--bg: #0f0f11;
|
|
24
|
+
--bg-elev: #1a1a1d;
|
|
25
|
+
--bg-sidebar: #141417;
|
|
26
|
+
--fg: #f4f4f5;
|
|
27
|
+
--fg-dim: #a1a1aa;
|
|
28
|
+
--border: #27272a;
|
|
29
|
+
--accent: #a78bfa;
|
|
30
|
+
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
* {
|
|
35
|
+
box-sizing: border-box;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
html,
|
|
39
|
+
body,
|
|
40
|
+
#app {
|
|
41
|
+
margin: 0;
|
|
42
|
+
padding: 0;
|
|
43
|
+
height: 100%;
|
|
44
|
+
background: var(--bg);
|
|
45
|
+
color: var(--fg);
|
|
46
|
+
font-family: var(--font);
|
|
47
|
+
font-size: 14px;
|
|
48
|
+
line-height: 1.45;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
button,
|
|
52
|
+
input,
|
|
53
|
+
select {
|
|
54
|
+
font: inherit;
|
|
55
|
+
color: inherit;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
a {
|
|
59
|
+
color: var(--accent);
|
|
60
|
+
text-decoration: none;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.app-shell {
|
|
64
|
+
display: grid;
|
|
65
|
+
grid-template-columns: 240px 1fr;
|
|
66
|
+
grid-template-rows: 48px 1fr;
|
|
67
|
+
grid-template-areas:
|
|
68
|
+
'sidebar topbar'
|
|
69
|
+
'sidebar main';
|
|
70
|
+
height: 100vh;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.sidebar {
|
|
74
|
+
grid-area: sidebar;
|
|
75
|
+
background: var(--bg-sidebar);
|
|
76
|
+
border-right: 1px solid var(--border);
|
|
77
|
+
padding: 16px 12px;
|
|
78
|
+
overflow-y: auto;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.sidebar h1 {
|
|
82
|
+
font-size: 13px;
|
|
83
|
+
font-weight: 700;
|
|
84
|
+
margin: 0 0 12px;
|
|
85
|
+
color: var(--fg-dim);
|
|
86
|
+
letter-spacing: 0.04em;
|
|
87
|
+
text-transform: uppercase;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.nav {
|
|
91
|
+
display: flex;
|
|
92
|
+
flex-direction: column;
|
|
93
|
+
gap: 2px;
|
|
94
|
+
margin-bottom: 20px;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.nav a {
|
|
98
|
+
padding: 6px 10px;
|
|
99
|
+
border-radius: var(--radius);
|
|
100
|
+
color: var(--fg);
|
|
101
|
+
font-weight: 500;
|
|
102
|
+
}
|
|
103
|
+
.nav a:hover {
|
|
104
|
+
background: var(--border);
|
|
105
|
+
}
|
|
106
|
+
.nav a[aria-current='page'] {
|
|
107
|
+
background: var(--accent);
|
|
108
|
+
color: var(--accent-fg);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.topbar {
|
|
112
|
+
grid-area: topbar;
|
|
113
|
+
background: var(--bg-elev);
|
|
114
|
+
border-bottom: 1px solid var(--border);
|
|
115
|
+
padding: 0 20px;
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: 16px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.topbar .title {
|
|
122
|
+
font-weight: 600;
|
|
123
|
+
flex: 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.topbar .status {
|
|
127
|
+
color: var(--fg-dim);
|
|
128
|
+
font-size: 12px;
|
|
129
|
+
font-family: var(--mono);
|
|
130
|
+
}
|
|
131
|
+
.topbar .status.connected {
|
|
132
|
+
color: var(--ok);
|
|
133
|
+
}
|
|
134
|
+
.topbar .status.disconnected {
|
|
135
|
+
color: var(--danger);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.main {
|
|
139
|
+
grid-area: main;
|
|
140
|
+
overflow: auto;
|
|
141
|
+
padding: 20px;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.empty {
|
|
145
|
+
text-align: center;
|
|
146
|
+
padding: 80px 20px;
|
|
147
|
+
color: var(--fg-dim);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.toast-layer {
|
|
151
|
+
position: fixed;
|
|
152
|
+
bottom: 20px;
|
|
153
|
+
right: 20px;
|
|
154
|
+
display: flex;
|
|
155
|
+
flex-direction: column;
|
|
156
|
+
gap: 8px;
|
|
157
|
+
z-index: 1000;
|
|
158
|
+
}
|
|
159
|
+
.toast {
|
|
160
|
+
background: var(--bg-elev);
|
|
161
|
+
border: 1px solid var(--border);
|
|
162
|
+
border-radius: var(--radius);
|
|
163
|
+
padding: 10px 14px;
|
|
164
|
+
box-shadow: var(--shadow);
|
|
165
|
+
max-width: 360px;
|
|
166
|
+
}
|
|
167
|
+
.toast.error {
|
|
168
|
+
border-left: 3px solid var(--danger);
|
|
169
|
+
}
|
|
170
|
+
.toast.success {
|
|
171
|
+
border-left: 3px solid var(--ok);
|
|
172
|
+
}
|
|
173
|
+
.toast.info {
|
|
174
|
+
border-left: 3px solid var(--info);
|
|
175
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Minimal hash-based router using Svelte 5 runes.
|
|
2
|
+
// Routes : #/kanban, #/backlog, #/burndown, #/deps, #/docs[/<path>]
|
|
3
|
+
|
|
4
|
+
function parse() {
|
|
5
|
+
const raw = window.location.hash.slice(1) || '/kanban';
|
|
6
|
+
const [pathname, query = ''] = raw.split('?');
|
|
7
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
8
|
+
return { pathname, parts, query };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const route = $state(parse());
|
|
12
|
+
|
|
13
|
+
window.addEventListener('hashchange', () => {
|
|
14
|
+
Object.assign(route, parse());
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export function navigate(path) {
|
|
18
|
+
window.location.hash = path.startsWith('#') ? path : `#${path}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Central store for the Kanban UI, backed by Svelte 5 runes.
|
|
2
|
+
// Normalized caches + SSE-driven invalidation.
|
|
3
|
+
|
|
4
|
+
export const store = $state({
|
|
5
|
+
stories: [],
|
|
6
|
+
epics: [],
|
|
7
|
+
sprint: null,
|
|
8
|
+
burndown: null,
|
|
9
|
+
connected: false,
|
|
10
|
+
loading: false,
|
|
11
|
+
error: null,
|
|
12
|
+
toasts: [],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
let es = null;
|
|
16
|
+
let toastSeq = 0;
|
|
17
|
+
|
|
18
|
+
export function toast({ kind = 'info', message, ttl = 3500 }) {
|
|
19
|
+
const id = ++toastSeq;
|
|
20
|
+
store.toasts.push({ id, kind, message });
|
|
21
|
+
setTimeout(() => {
|
|
22
|
+
const idx = store.toasts.findIndex((t) => t.id === id);
|
|
23
|
+
if (idx >= 0) store.toasts.splice(idx, 1);
|
|
24
|
+
}, ttl);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function dismissToast(id) {
|
|
28
|
+
const idx = store.toasts.findIndex((t) => t.id === id);
|
|
29
|
+
if (idx >= 0) store.toasts.splice(idx, 1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function request(url, init = {}) {
|
|
33
|
+
const res = await fetch(url, {
|
|
34
|
+
...init,
|
|
35
|
+
headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) },
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
let body;
|
|
39
|
+
try {
|
|
40
|
+
body = await res.json();
|
|
41
|
+
} catch {
|
|
42
|
+
body = { error: res.statusText };
|
|
43
|
+
}
|
|
44
|
+
const err = new Error(body.error || `HTTP ${res.status}`);
|
|
45
|
+
err.status = res.status;
|
|
46
|
+
err.body = body;
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
return res.json();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function loadStories() {
|
|
53
|
+
store.loading = true;
|
|
54
|
+
try {
|
|
55
|
+
const data = await request('/api/stories');
|
|
56
|
+
store.stories = data.stories;
|
|
57
|
+
store.epics = data.epics;
|
|
58
|
+
store.error = null;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
store.error = err.message;
|
|
61
|
+
toast({ kind: 'error', message: `Failed to load stories: ${err.message}` });
|
|
62
|
+
} finally {
|
|
63
|
+
store.loading = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function loadSprint() {
|
|
68
|
+
try {
|
|
69
|
+
const data = await request('/api/sprints/current');
|
|
70
|
+
store.sprint = data.sprint;
|
|
71
|
+
store.burndown = data.burndown;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err.status !== 404) toast({ kind: 'error', message: `Sprint load failed: ${err.message}` });
|
|
74
|
+
store.sprint = null;
|
|
75
|
+
store.burndown = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function patchStatus(id, body) {
|
|
80
|
+
const prev = store.stories.find((s) => s.id === id);
|
|
81
|
+
if (!prev) throw new Error('story not found');
|
|
82
|
+
// Optimistic update on structural transitions (skip on blocked without reason)
|
|
83
|
+
const idx = store.stories.findIndex((s) => s.id === id);
|
|
84
|
+
const before = { ...prev };
|
|
85
|
+
store.stories[idx] = { ...prev, status: body.status };
|
|
86
|
+
try {
|
|
87
|
+
const data = await request(`/api/stories/${id}/status`, {
|
|
88
|
+
method: 'PATCH',
|
|
89
|
+
body: JSON.stringify(body),
|
|
90
|
+
});
|
|
91
|
+
store.stories[idx] = data.story;
|
|
92
|
+
toast({ kind: 'success', message: `${id} -> ${data.story.status}` });
|
|
93
|
+
return data.story;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
store.stories[idx] = before;
|
|
96
|
+
const details = err.body?.missing?.length ? ` (${err.body.missing.join(', ')})` : '';
|
|
97
|
+
toast({ kind: 'error', message: `Transition failed: ${err.body?.reason ?? err.message}${details}`, ttl: 5000 });
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function connectEvents() {
|
|
103
|
+
if (es) return;
|
|
104
|
+
es = new EventSource('/api/events');
|
|
105
|
+
es.addEventListener('open', () => {
|
|
106
|
+
store.connected = true;
|
|
107
|
+
});
|
|
108
|
+
es.addEventListener('error', () => {
|
|
109
|
+
store.connected = false;
|
|
110
|
+
});
|
|
111
|
+
es.addEventListener('story:updated', async () => {
|
|
112
|
+
await loadStories();
|
|
113
|
+
});
|
|
114
|
+
es.addEventListener('file:changed', async (e) => {
|
|
115
|
+
try {
|
|
116
|
+
const { payload } = JSON.parse(e.data);
|
|
117
|
+
if (payload?.category === 'story' || payload?.category === 'task' || payload?.category === 'epic') {
|
|
118
|
+
await loadStories();
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
await loadStories();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function disconnectEvents() {
|
|
127
|
+
if (es) {
|
|
128
|
+
es.close();
|
|
129
|
+
es = null;
|
|
130
|
+
store.connected = false;
|
|
131
|
+
}
|
|
132
|
+
}
|