@leonarto/spec-embryo 0.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/README.md +156 -0
- package/package.json +48 -0
- package/src/backends/base.ts +18 -0
- package/src/backends/deterministic.ts +105 -0
- package/src/backends/index.ts +26 -0
- package/src/backends/prompt.ts +169 -0
- package/src/backends/subprocess.ts +198 -0
- package/src/cli.ts +111 -0
- package/src/commands/agents.ts +16 -0
- package/src/commands/current.ts +95 -0
- package/src/commands/doctor.ts +12 -0
- package/src/commands/handoff.ts +64 -0
- package/src/commands/init.ts +101 -0
- package/src/commands/reshape.ts +20 -0
- package/src/commands/resume.ts +19 -0
- package/src/commands/spec.ts +108 -0
- package/src/commands/status.ts +98 -0
- package/src/commands/task.ts +190 -0
- package/src/commands/ui.ts +35 -0
- package/src/domain.ts +357 -0
- package/src/engine.ts +290 -0
- package/src/frontmatter.ts +83 -0
- package/src/index.ts +75 -0
- package/src/paths.ts +32 -0
- package/src/repository.ts +807 -0
- package/src/services/adoption.ts +169 -0
- package/src/services/agents.ts +191 -0
- package/src/services/dashboard.ts +776 -0
- package/src/services/details.ts +453 -0
- package/src/services/doctor.ts +452 -0
- package/src/services/layout.ts +420 -0
- package/src/services/spec-answer-evaluation.ts +103 -0
- package/src/services/spec-import.ts +217 -0
- package/src/services/spec-questions.ts +343 -0
- package/src/services/ui.ts +34 -0
- package/src/storage.ts +57 -0
- package/src/templates.ts +270 -0
- package/tsconfig.json +17 -0
- package/web/package.json +24 -0
- package/web/src/app.css +83 -0
- package/web/src/app.d.ts +6 -0
- package/web/src/app.html +11 -0
- package/web/src/lib/components/AnalysisFilters.svelte +293 -0
- package/web/src/lib/components/DocumentBody.svelte +100 -0
- package/web/src/lib/components/MultiSelectDropdown.svelte +280 -0
- package/web/src/lib/components/SelectDropdown.svelte +265 -0
- package/web/src/lib/server/project-root.ts +34 -0
- package/web/src/lib/task-board.ts +20 -0
- package/web/src/routes/+layout.server.ts +57 -0
- package/web/src/routes/+layout.svelte +421 -0
- package/web/src/routes/+layout.ts +1 -0
- package/web/src/routes/+page.svelte +530 -0
- package/web/src/routes/specs/+page.svelte +416 -0
- package/web/src/routes/specs/[specId]/+page.server.ts +81 -0
- package/web/src/routes/specs/[specId]/+page.svelte +675 -0
- package/web/src/routes/tasks/+page.svelte +341 -0
- package/web/src/routes/tasks/[taskId]/+page.server.ts +12 -0
- package/web/src/routes/tasks/[taskId]/+page.svelte +431 -0
- package/web/src/routes/timeline/+page.svelte +1093 -0
- package/web/svelte.config.js +10 -0
- package/web/tsconfig.json +9 -0
- package/web/vite.config.ts +11 -0
package/src/templates.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { serializeTomlFrontmatter } from "./frontmatter.ts";
|
|
3
|
+
import { buildManagedPaths } from "./services/layout.ts";
|
|
4
|
+
|
|
5
|
+
export function createConfigToml(projectName: string, memoryDir = "docs/spm"): string {
|
|
6
|
+
const managed = buildManagedPaths(memoryDir);
|
|
7
|
+
return `
|
|
8
|
+
tool_version = "0.1.0"
|
|
9
|
+
project_name = ${JSON.stringify(projectName)}
|
|
10
|
+
memory_dir = ${JSON.stringify(managed.memoryDir)}
|
|
11
|
+
state_file = ${JSON.stringify(managed.stateFile)}
|
|
12
|
+
handoffs_dir = ${JSON.stringify(managed.handoffsDir)}
|
|
13
|
+
specs_dir = ${JSON.stringify(managed.specsDir)}
|
|
14
|
+
tasks_dir = ${JSON.stringify(managed.tasksDir)}
|
|
15
|
+
skills_dir = ${JSON.stringify(managed.skillsDir)}
|
|
16
|
+
|
|
17
|
+
[defaults]
|
|
18
|
+
resume_backend = "deterministic"
|
|
19
|
+
|
|
20
|
+
[backends.deterministic]
|
|
21
|
+
enabled = true
|
|
22
|
+
|
|
23
|
+
[backends.prompt]
|
|
24
|
+
enabled = true
|
|
25
|
+
|
|
26
|
+
[backends.codex]
|
|
27
|
+
enabled = false
|
|
28
|
+
command = "codex"
|
|
29
|
+
args = ["exec", "{prompt_text_file}"]
|
|
30
|
+
|
|
31
|
+
[backends.claude]
|
|
32
|
+
enabled = false
|
|
33
|
+
command = "claude"
|
|
34
|
+
args = ["-p", "{prompt}"]
|
|
35
|
+
`.trimStart();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createCurrentStateTemplate(updatedAt: string): string {
|
|
39
|
+
return serializeTomlFrontmatter(
|
|
40
|
+
{
|
|
41
|
+
doc_kind: "current-state",
|
|
42
|
+
id: "CURRENT",
|
|
43
|
+
title: "Current Project State",
|
|
44
|
+
summary: "Building the first usable local-first Spec Embryo foundation.",
|
|
45
|
+
focus: "Ship the first usable Spec Embryo workflow around init, status, resume, handoff, spec list, and task list.",
|
|
46
|
+
active_spec_ids: ["SPEC-001"],
|
|
47
|
+
active_task_ids: ["TASK-001", "TASK-002"],
|
|
48
|
+
blocked_task_ids: [],
|
|
49
|
+
next_action_hints: [
|
|
50
|
+
"Verify init/status/resume flows against the seed repo files.",
|
|
51
|
+
"Keep the file-backed storage layer stable before adding server mode.",
|
|
52
|
+
],
|
|
53
|
+
updated_at: updatedAt,
|
|
54
|
+
tags: ["v1", "seed"],
|
|
55
|
+
},
|
|
56
|
+
`# Current State
|
|
57
|
+
|
|
58
|
+
This file is the explicit checkpoint for interrupted work. It should answer:
|
|
59
|
+
|
|
60
|
+
- what the project is trying to do right now
|
|
61
|
+
- which specs and tasks are active
|
|
62
|
+
- what should happen next
|
|
63
|
+
- what is blocked
|
|
64
|
+
- where a future human or agent should resume
|
|
65
|
+
|
|
66
|
+
## Narrative
|
|
67
|
+
|
|
68
|
+
The immediate goal is to establish a strong deterministic core. The repo itself is the durable memory system, so the first slice prioritizes file formats, linking, validation, and resume ergonomics before any local server or remote backend exists.
|
|
69
|
+
|
|
70
|
+
## Notes
|
|
71
|
+
|
|
72
|
+
- Prompt-return mode is first-class to avoid unnecessary nested agent spawning.
|
|
73
|
+
- Subprocess agent backends are adapters, not the center of the product.
|
|
74
|
+
`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createSeedSpecTemplate(updatedAt: string): string {
|
|
79
|
+
return serializeTomlFrontmatter(
|
|
80
|
+
{
|
|
81
|
+
doc_kind: "spec",
|
|
82
|
+
id: "SPEC-001",
|
|
83
|
+
title: "Establish the deterministic local-first core",
|
|
84
|
+
status: "active",
|
|
85
|
+
summary: "Define the file-backed, spec-first architecture and ship the first usable CLI workflow.",
|
|
86
|
+
task_ids: ["TASK-001", "TASK-002", "TASK-003"],
|
|
87
|
+
tags: ["v1", "foundation", "cli"],
|
|
88
|
+
owner: "human-ai",
|
|
89
|
+
updated_at: updatedAt,
|
|
90
|
+
},
|
|
91
|
+
`# Problem
|
|
92
|
+
|
|
93
|
+
AI-assisted software work loses continuity when the current state only lives in chat history or an external task tracker.
|
|
94
|
+
|
|
95
|
+
# Goals
|
|
96
|
+
|
|
97
|
+
- Keep repo memory local and readable.
|
|
98
|
+
- Make specs central and tasks explicitly linked.
|
|
99
|
+
- Support deterministic status and resume flows without any LLM.
|
|
100
|
+
- Leave room for prompt-return and subprocess backends later.
|
|
101
|
+
|
|
102
|
+
# Non-Goals For V1
|
|
103
|
+
|
|
104
|
+
- No web UI yet.
|
|
105
|
+
- No database dependency.
|
|
106
|
+
- No mandatory hosted service.
|
|
107
|
+
`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createSeedTaskTemplates(updatedAt: string): Array<{ name: string; content: string }> {
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
name: "TASK-001-bootstrap-cli.md",
|
|
115
|
+
content: serializeTomlFrontmatter(
|
|
116
|
+
{
|
|
117
|
+
doc_kind: "task",
|
|
118
|
+
id: "TASK-001",
|
|
119
|
+
title: "Implement the Bun CLI and file-backed repository layer",
|
|
120
|
+
status: "in_progress",
|
|
121
|
+
summary: "Create the runnable CLI entrypoint, document parser, storage helpers, and core commands.",
|
|
122
|
+
spec_ids: ["SPEC-001"],
|
|
123
|
+
depends_on: [],
|
|
124
|
+
blocked_by: [],
|
|
125
|
+
priority: 1,
|
|
126
|
+
owner_role: "builder",
|
|
127
|
+
created_at: updatedAt,
|
|
128
|
+
tags: ["v1", "cli"],
|
|
129
|
+
updated_at: updatedAt,
|
|
130
|
+
},
|
|
131
|
+
`# Scope
|
|
132
|
+
|
|
133
|
+
Ship the initial commands and the document model so the project is already useful with local files alone.
|
|
134
|
+
|
|
135
|
+
# Done Looks Like
|
|
136
|
+
|
|
137
|
+
- \`init\`, \`status\`, \`resume\`, \`handoff\`, \`spec list\`, and \`task list\` exist
|
|
138
|
+
- repo memory files are created and validated deterministically
|
|
139
|
+
- the CLI can help a human or agent resume after interruption
|
|
140
|
+
`,
|
|
141
|
+
),
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "TASK-002-deterministic-resume.md",
|
|
145
|
+
content: serializeTomlFrontmatter(
|
|
146
|
+
{
|
|
147
|
+
doc_kind: "task",
|
|
148
|
+
id: "TASK-002",
|
|
149
|
+
title: "Derive deterministic status, resume context, and next actions",
|
|
150
|
+
status: "todo",
|
|
151
|
+
summary: "Build the engine that links specs, tasks, current state, git data, blockers, and critical path.",
|
|
152
|
+
spec_ids: ["SPEC-001"],
|
|
153
|
+
depends_on: ["TASK-001"],
|
|
154
|
+
blocked_by: [],
|
|
155
|
+
priority: 1,
|
|
156
|
+
owner_role: "planner",
|
|
157
|
+
created_at: updatedAt,
|
|
158
|
+
tags: ["v1", "engine"],
|
|
159
|
+
updated_at: updatedAt,
|
|
160
|
+
},
|
|
161
|
+
`# Scope
|
|
162
|
+
|
|
163
|
+
Use explicit rules to compute what matters now, what is blocked, and what should happen next.
|
|
164
|
+
|
|
165
|
+
# Notes
|
|
166
|
+
|
|
167
|
+
Critical path can be simple in v1: longest unfinished dependency chain by declared task links.
|
|
168
|
+
`,
|
|
169
|
+
),
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "TASK-003-prompt-return-mode.md",
|
|
173
|
+
content: serializeTomlFrontmatter(
|
|
174
|
+
{
|
|
175
|
+
doc_kind: "task",
|
|
176
|
+
id: "TASK-003",
|
|
177
|
+
title: "Add prompt-return mode and subprocess backend adapters",
|
|
178
|
+
status: "todo",
|
|
179
|
+
summary: "Return structured context and prompts when an AI is already calling the tool, with future-ready subprocess adapters.",
|
|
180
|
+
spec_ids: ["SPEC-001"],
|
|
181
|
+
depends_on: ["TASK-002"],
|
|
182
|
+
blocked_by: [],
|
|
183
|
+
priority: 2,
|
|
184
|
+
owner_role: "orchestrator",
|
|
185
|
+
created_at: updatedAt,
|
|
186
|
+
tags: ["v1", "backends"],
|
|
187
|
+
updated_at: updatedAt,
|
|
188
|
+
},
|
|
189
|
+
`# Scope
|
|
190
|
+
|
|
191
|
+
Prompt-return should be first-class. Spawning another AI should be opt-in rather than assumed.
|
|
192
|
+
|
|
193
|
+
# Notes
|
|
194
|
+
|
|
195
|
+
Adapters should keep backend-specific behavior behind a shared interface.
|
|
196
|
+
`,
|
|
197
|
+
),
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function createSkillsReadme(): string {
|
|
203
|
+
return `# Managed Skills
|
|
204
|
+
|
|
205
|
+
This folder is part of the managed Spec Embryo memory surface for the project.
|
|
206
|
+
|
|
207
|
+
Possible future contents:
|
|
208
|
+
|
|
209
|
+
- role definitions for planner / builder / reviewer agents
|
|
210
|
+
- prompt snippets or subtask recipes
|
|
211
|
+
- deterministic capability manifests
|
|
212
|
+
- skill docs another agent can inspect before continuing work
|
|
213
|
+
`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function createTemplateFiles(): Array<{ name: string; content: string }> {
|
|
217
|
+
const placeholderTimestamp = "2026-04-02T00:00:00Z";
|
|
218
|
+
return [
|
|
219
|
+
{
|
|
220
|
+
name: "spec-template.md",
|
|
221
|
+
content: createSeedSpecTemplate(placeholderTimestamp),
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: "task-template.md",
|
|
225
|
+
content: createSeedTaskTemplates(placeholderTimestamp)[0]!.content,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "handoff-template.md",
|
|
229
|
+
content: serializeTomlFrontmatter(
|
|
230
|
+
{
|
|
231
|
+
doc_kind: "handoff",
|
|
232
|
+
id: "HANDOFF-PLACEHOLDER",
|
|
233
|
+
title: "Checkpoint title",
|
|
234
|
+
summary: "Short handoff summary.",
|
|
235
|
+
handoff_kind: "checkpoint",
|
|
236
|
+
created_at: placeholderTimestamp,
|
|
237
|
+
active_task_ids: ["TASK-001"],
|
|
238
|
+
related_spec_ids: ["SPEC-001"],
|
|
239
|
+
author: "spec-embryo",
|
|
240
|
+
updated_at: placeholderTimestamp,
|
|
241
|
+
tags: ["handoff"],
|
|
242
|
+
},
|
|
243
|
+
`# Summary
|
|
244
|
+
|
|
245
|
+
Describe where the project was left.
|
|
246
|
+
|
|
247
|
+
## What Changed
|
|
248
|
+
|
|
249
|
+
- Itemize meaningful changes.
|
|
250
|
+
|
|
251
|
+
## Next Steps
|
|
252
|
+
|
|
253
|
+
- List the clearest next moves.
|
|
254
|
+
|
|
255
|
+
## Blockers
|
|
256
|
+
|
|
257
|
+
- Note anything that needs a decision or unblocking.
|
|
258
|
+
`,
|
|
259
|
+
),
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: "current-state-template.md",
|
|
263
|
+
content: createCurrentStateTemplate(placeholderTimestamp),
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function inferProjectName(rootDir: string): string {
|
|
269
|
+
return basename(rootDir);
|
|
270
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"allowImportingTsExtensions": true,
|
|
8
|
+
"verbatimModuleSyntax": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"noFallthroughCasesInSwitch": true,
|
|
13
|
+
"noUncheckedIndexedAccess": true,
|
|
14
|
+
"noImplicitOverride": true,
|
|
15
|
+
"forceConsistentCasingInFileNames": true
|
|
16
|
+
}
|
|
17
|
+
}
|
package/web/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spec-embryo-web",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "svelte-kit sync && vite dev",
|
|
7
|
+
"build": "svelte-kit sync && vite build",
|
|
8
|
+
"preview": "vite preview",
|
|
9
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@sveltejs/adapter-auto": "7.0.1",
|
|
13
|
+
"@sveltejs/kit": "2.55.0",
|
|
14
|
+
"@sveltejs/vite-plugin-svelte": "7.0.0",
|
|
15
|
+
"@types/node": "25.5.0",
|
|
16
|
+
"svelte": "5.55.1",
|
|
17
|
+
"svelte-check": "4.4.6",
|
|
18
|
+
"typescript": "6.0.2",
|
|
19
|
+
"vite": "8.0.3"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@lucide/svelte": "^1.7.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/web/src/app.css
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #f1ebdf;
|
|
3
|
+
--bg-deep: #e5dac7;
|
|
4
|
+
--panel: rgba(255, 252, 246, 0.84);
|
|
5
|
+
--panel-strong: rgba(255, 253, 249, 0.95);
|
|
6
|
+
--panel-muted: rgba(248, 242, 232, 0.82);
|
|
7
|
+
--ink: #181612;
|
|
8
|
+
--muted: #5a5143;
|
|
9
|
+
--muted-soft: #766b5d;
|
|
10
|
+
--line: rgba(24, 22, 18, 0.12);
|
|
11
|
+
--line-strong: rgba(24, 22, 18, 0.22);
|
|
12
|
+
--accent: #0f8d60;
|
|
13
|
+
--accent-soft: rgba(15, 141, 96, 0.12);
|
|
14
|
+
--warning: #8f5421;
|
|
15
|
+
--warning-soft: rgba(143, 84, 33, 0.14);
|
|
16
|
+
--danger: #972d2d;
|
|
17
|
+
--danger-soft: rgba(151, 45, 45, 0.12);
|
|
18
|
+
--shadow: 0 28px 80px rgba(37, 29, 13, 0.12);
|
|
19
|
+
--shadow-soft: 0 10px 28px rgba(34, 26, 12, 0.08);
|
|
20
|
+
--display-font: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
|
|
21
|
+
--body-font: "Avenir Next", "Helvetica Neue", "Segoe UI", sans-serif;
|
|
22
|
+
--radius-lg: 1.15rem;
|
|
23
|
+
--radius-xl: 1.55rem;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
*,
|
|
27
|
+
*::before,
|
|
28
|
+
*::after {
|
|
29
|
+
box-sizing: border-box;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
html,
|
|
33
|
+
body {
|
|
34
|
+
min-height: 100%;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
body {
|
|
38
|
+
margin: 0;
|
|
39
|
+
color: var(--ink);
|
|
40
|
+
background:
|
|
41
|
+
radial-gradient(circle at top left, rgba(15, 141, 96, 0.12), transparent 24rem),
|
|
42
|
+
radial-gradient(circle at top right, rgba(151, 45, 45, 0.08), transparent 22rem),
|
|
43
|
+
linear-gradient(180deg, #f7f1e6 0%, var(--bg) 48%, var(--bg-deep) 100%);
|
|
44
|
+
font-family: var(--body-font);
|
|
45
|
+
text-rendering: optimizeLegibility;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
body::before {
|
|
49
|
+
content: "";
|
|
50
|
+
position: fixed;
|
|
51
|
+
inset: 0;
|
|
52
|
+
pointer-events: none;
|
|
53
|
+
background-image:
|
|
54
|
+
linear-gradient(rgba(26, 24, 21, 0.03) 1px, transparent 1px),
|
|
55
|
+
linear-gradient(90deg, rgba(26, 24, 21, 0.03) 1px, transparent 1px);
|
|
56
|
+
background-size: 24px 24px;
|
|
57
|
+
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.85), transparent);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
body::after {
|
|
61
|
+
content: "";
|
|
62
|
+
position: fixed;
|
|
63
|
+
inset: 0;
|
|
64
|
+
pointer-events: none;
|
|
65
|
+
background:
|
|
66
|
+
linear-gradient(140deg, rgba(255, 255, 255, 0.18), transparent 32%),
|
|
67
|
+
radial-gradient(circle at 80% 18%, rgba(15, 141, 96, 0.08), transparent 18rem);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
a {
|
|
71
|
+
color: inherit;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
button,
|
|
75
|
+
input,
|
|
76
|
+
textarea,
|
|
77
|
+
select {
|
|
78
|
+
font: inherit;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
::selection {
|
|
82
|
+
background: rgba(15, 141, 96, 0.18);
|
|
83
|
+
}
|
package/web/src/app.d.ts
ADDED
package/web/src/app.html
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
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
|
+
%sveltekit.head%
|
|
7
|
+
</head>
|
|
8
|
+
<body data-sveltekit-preload-data="hover">
|
|
9
|
+
<div style="display: contents">%sveltekit.body%</div>
|
|
10
|
+
</body>
|
|
11
|
+
</html>
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { goto } from "$app/navigation";
|
|
3
|
+
import { page } from "$app/state";
|
|
4
|
+
import { Filter, Layers3, RotateCcw, SlidersHorizontal } from "@lucide/svelte";
|
|
5
|
+
import type { Snippet } from "svelte";
|
|
6
|
+
import type { DashboardData, DashboardTimeWindow } from "../../../../src/services/dashboard.ts";
|
|
7
|
+
import MultiSelectDropdown from "./MultiSelectDropdown.svelte";
|
|
8
|
+
import SelectDropdown from "./SelectDropdown.svelte";
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
dashboard,
|
|
12
|
+
title = "Analysis filters",
|
|
13
|
+
detail = "Slice the analytical views by spec set and lifecycle period.",
|
|
14
|
+
eyebrow = "Analytical slice",
|
|
15
|
+
showTimeWindow = true,
|
|
16
|
+
resultCount = dashboard.analysis.filteredTaskCount,
|
|
17
|
+
resultLabel = "tasks in the current analytical slice",
|
|
18
|
+
hasActiveFilters = dashboard.analysis.hasActiveFilters,
|
|
19
|
+
inactiveDetail = "Without analytical filters, archived tasks stay hidden from active-flow views.",
|
|
20
|
+
activeDetail = "Archived tasks are included while filters are active.",
|
|
21
|
+
clearHref = "",
|
|
22
|
+
extraField,
|
|
23
|
+
} = $props<{
|
|
24
|
+
dashboard: DashboardData;
|
|
25
|
+
title?: string;
|
|
26
|
+
detail?: string;
|
|
27
|
+
eyebrow?: string;
|
|
28
|
+
showTimeWindow?: boolean;
|
|
29
|
+
resultCount?: number;
|
|
30
|
+
resultLabel?: string;
|
|
31
|
+
hasActiveFilters?: boolean;
|
|
32
|
+
inactiveDetail?: string;
|
|
33
|
+
activeDetail?: string;
|
|
34
|
+
clearHref?: string;
|
|
35
|
+
extraField?: Snippet;
|
|
36
|
+
}>();
|
|
37
|
+
|
|
38
|
+
const timeWindows: Array<{ value: DashboardTimeWindow; label: string; description: string }> = [
|
|
39
|
+
{ value: "7d", label: "Last 7 days", description: "Only recently completed or cancelled work." },
|
|
40
|
+
{ value: "30d", label: "Last 30 days", description: "A broader recent delivery slice." },
|
|
41
|
+
{ value: "90d", label: "Last 90 days", description: "A quarterly view of lifecycle history." },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const specOptions = $derived(
|
|
45
|
+
dashboard.analysis.availableSpecs.map((spec: DashboardData["analysis"]["availableSpecs"][number]) => ({
|
|
46
|
+
value: spec.id,
|
|
47
|
+
label: spec.id,
|
|
48
|
+
description: spec.title,
|
|
49
|
+
})),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const currentPath = $derived(page.url.pathname);
|
|
53
|
+
|
|
54
|
+
async function applyFilters(event: Event): Promise<void> {
|
|
55
|
+
const form = event.currentTarget as HTMLFormElement;
|
|
56
|
+
const formData = new FormData(form);
|
|
57
|
+
const searchParams = new URLSearchParams();
|
|
58
|
+
|
|
59
|
+
for (const [key, value] of formData.entries()) {
|
|
60
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
searchParams.append(key, value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const search = searchParams.toString();
|
|
68
|
+
await goto(search.length > 0 ? `${currentPath}?${search}` : currentPath, {
|
|
69
|
+
keepFocus: true,
|
|
70
|
+
noScroll: true,
|
|
71
|
+
replaceState: true,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<section class="filters-shell">
|
|
77
|
+
<div class="filters-copy">
|
|
78
|
+
<div class="copy-head">
|
|
79
|
+
<span class="icon-pill">
|
|
80
|
+
<Filter size={15} strokeWidth={1.9} />
|
|
81
|
+
</span>
|
|
82
|
+
<div>
|
|
83
|
+
<p class="eyebrow">{eyebrow}</p>
|
|
84
|
+
<h4>{title}</h4>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
<p>{detail}</p>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<form class="filters-form" method="GET" action={currentPath} onchange={applyFilters}>
|
|
91
|
+
<div class="field-grid">
|
|
92
|
+
<fieldset class="field-card">
|
|
93
|
+
<legend>
|
|
94
|
+
<Layers3 size={14} strokeWidth={1.9} />
|
|
95
|
+
Specs
|
|
96
|
+
</legend>
|
|
97
|
+
<MultiSelectDropdown
|
|
98
|
+
name="spec"
|
|
99
|
+
options={specOptions}
|
|
100
|
+
selectedValues={dashboard.analysis.selectedSpecIds}
|
|
101
|
+
buttonLabel="Choose specs"
|
|
102
|
+
emptyLabel="All specs"
|
|
103
|
+
/>
|
|
104
|
+
</fieldset>
|
|
105
|
+
|
|
106
|
+
{#if showTimeWindow}
|
|
107
|
+
<fieldset class="field-card field-card-select">
|
|
108
|
+
<legend>
|
|
109
|
+
<SlidersHorizontal size={14} strokeWidth={1.9} />
|
|
110
|
+
Time window
|
|
111
|
+
</legend>
|
|
112
|
+
<SelectDropdown
|
|
113
|
+
name="window"
|
|
114
|
+
options={timeWindows}
|
|
115
|
+
selectedValue={dashboard.analysis.timeWindow ?? ""}
|
|
116
|
+
buttonLabel="Choose window"
|
|
117
|
+
emptyLabel="All lifecycle history"
|
|
118
|
+
/>
|
|
119
|
+
</fieldset>
|
|
120
|
+
{/if}
|
|
121
|
+
|
|
122
|
+
{@render extraField?.()}
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div class="filters-footer">
|
|
126
|
+
<div class="result-copy">
|
|
127
|
+
<strong>{resultCount}</strong>
|
|
128
|
+
<span>{resultLabel}</span>
|
|
129
|
+
{#if hasActiveFilters}
|
|
130
|
+
<small>{activeDetail}</small>
|
|
131
|
+
{:else}
|
|
132
|
+
<small>{inactiveDetail}</small>
|
|
133
|
+
{/if}
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div class="button-row">
|
|
137
|
+
<a class="ghost-button" href={clearHref || currentPath}>
|
|
138
|
+
<RotateCcw size={14} strokeWidth={1.9} />
|
|
139
|
+
Clear
|
|
140
|
+
</a>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</form>
|
|
144
|
+
</section>
|
|
145
|
+
|
|
146
|
+
<style>
|
|
147
|
+
.filters-shell {
|
|
148
|
+
display: grid;
|
|
149
|
+
gap: 0.85rem;
|
|
150
|
+
margin-bottom: 1rem;
|
|
151
|
+
padding: 0.9rem;
|
|
152
|
+
border-radius: var(--radius-xl);
|
|
153
|
+
border: 1px solid var(--line);
|
|
154
|
+
background: linear-gradient(180deg, rgba(255, 252, 246, 0.94), rgba(247, 241, 232, 0.88));
|
|
155
|
+
box-shadow: var(--shadow);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.filters-copy,
|
|
159
|
+
.filters-form,
|
|
160
|
+
.field-grid {
|
|
161
|
+
display: grid;
|
|
162
|
+
gap: 0.7rem;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.copy-head,
|
|
166
|
+
.filters-footer,
|
|
167
|
+
.button-row,
|
|
168
|
+
legend {
|
|
169
|
+
display: flex;
|
|
170
|
+
align-items: center;
|
|
171
|
+
gap: 0.5rem;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.filters-footer {
|
|
175
|
+
justify-content: space-between;
|
|
176
|
+
gap: 0.85rem;
|
|
177
|
+
flex-wrap: wrap;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.copy-head {
|
|
181
|
+
align-items: start;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.icon-pill {
|
|
185
|
+
display: inline-flex;
|
|
186
|
+
align-items: center;
|
|
187
|
+
justify-content: center;
|
|
188
|
+
width: 2.05rem;
|
|
189
|
+
height: 2.05rem;
|
|
190
|
+
border-radius: 0.85rem;
|
|
191
|
+
border: 1px solid var(--line);
|
|
192
|
+
background: rgba(255, 255, 255, 0.84);
|
|
193
|
+
color: var(--muted-soft);
|
|
194
|
+
flex-shrink: 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.eyebrow {
|
|
198
|
+
margin: 0 0 0.22rem;
|
|
199
|
+
text-transform: uppercase;
|
|
200
|
+
letter-spacing: 0.14em;
|
|
201
|
+
font-size: 0.68rem;
|
|
202
|
+
color: var(--muted);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
h4,
|
|
206
|
+
p,
|
|
207
|
+
fieldset,
|
|
208
|
+
legend {
|
|
209
|
+
margin: 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
h4 {
|
|
213
|
+
font-family: var(--display-font);
|
|
214
|
+
font-size: 1.02rem;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
p,
|
|
218
|
+
.result-copy span,
|
|
219
|
+
.result-copy small {
|
|
220
|
+
color: var(--muted);
|
|
221
|
+
line-height: 1.45;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.field-grid {
|
|
225
|
+
grid-template-columns: minmax(0, 1.65fr) repeat(2, minmax(13rem, 0.9fr));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.field-grid:has(> :only-child) {
|
|
229
|
+
grid-template-columns: 1fr;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.field-card {
|
|
233
|
+
display: grid;
|
|
234
|
+
gap: 0.55rem;
|
|
235
|
+
min-width: 0;
|
|
236
|
+
padding: 0.72rem;
|
|
237
|
+
border-radius: var(--radius-lg);
|
|
238
|
+
border: 1px solid var(--line);
|
|
239
|
+
background: rgba(255, 255, 255, 0.72);
|
|
240
|
+
box-shadow: var(--shadow-soft);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.field-card-select {
|
|
244
|
+
align-content: start;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
legend,
|
|
248
|
+
legend {
|
|
249
|
+
padding: 0;
|
|
250
|
+
color: var(--muted-soft);
|
|
251
|
+
font-size: 0.78rem;
|
|
252
|
+
font-weight: 600;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.ghost-button {
|
|
256
|
+
border-radius: 999px;
|
|
257
|
+
border: 1px solid var(--line-strong);
|
|
258
|
+
background: rgba(255, 255, 255, 0.9);
|
|
259
|
+
color: var(--ink);
|
|
260
|
+
box-shadow: var(--shadow-soft);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.result-copy {
|
|
264
|
+
display: grid;
|
|
265
|
+
gap: 0.08rem;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.result-copy strong {
|
|
269
|
+
font-family: var(--display-font);
|
|
270
|
+
font-size: 1.08rem;
|
|
271
|
+
line-height: 1;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.button-row {
|
|
275
|
+
flex-wrap: wrap;
|
|
276
|
+
justify-content: end;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.ghost-button {
|
|
280
|
+
display: inline-flex;
|
|
281
|
+
align-items: center;
|
|
282
|
+
gap: 0.42rem;
|
|
283
|
+
padding: 0.62rem 0.88rem;
|
|
284
|
+
text-decoration: none;
|
|
285
|
+
cursor: pointer;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
@media (max-width: 980px) {
|
|
289
|
+
.field-grid {
|
|
290
|
+
grid-template-columns: 1fr;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
</style>
|