@fapost/jig-ui 0.1.0 → 0.2.0-next.21

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/dist/http/app.js CHANGED
@@ -4,7 +4,10 @@ import { isAbsolute, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { serveStatic } from '@hono/node-server/serve-static';
6
6
  import { Hono } from 'hono';
7
+ import { HTTPException } from 'hono/http-exception';
8
+ import { secureHeaders } from 'hono/secure-headers';
7
9
  import { streamSSE } from 'hono/streaming';
10
+ import { validator } from 'hono/validator';
8
11
  import { columns, placeOf } from '../board/columns.js';
9
12
  import { RegistryUnreadable } from '../source/project-source.js';
10
13
  import { localOnly, tokenAccess } from './access.js';
@@ -29,61 +32,58 @@ function absolute(path) {
29
32
  const expanded = path.trim().replace(/^~(?=$|[\\/])/, homedir());
30
33
  return isAbsolute(expanded) ? expanded : undefined;
31
34
  }
32
- export function createApp({ port, token, source, version = readPackageVersion(), webRoot = DEFAULT_WEB_ROOT }) {
33
- const app = new Hono();
34
- app.use(localOnly(port));
35
- app.use(tokenAccess({ port, token }));
36
- app.onError((error, c) => {
37
- if (error instanceof RegistryUnreadable)
38
- return refuse(c, 500, 'registry-unreadable', error.message);
39
- console.error(error);
40
- return c.text('Internal Server Error', 500);
41
- });
42
- app.get('/api/instance', (c) => c.json({ version }));
43
- app.get('/api/workspace', async (c) => c.json({ projects: await source.workspace() }));
44
- app.get('/api/projects/:id/board', async (c) => {
35
+ /**
36
+ * The API, chained so that its type carries every route's answers: the page derives its types
37
+ * from `AppType` and never declares them a second time.
38
+ */
39
+ function apiRoutes(source, version) {
40
+ return (new Hono()
41
+ .get('/api/instance', (c) => c.json({ version }, 200))
42
+ .get('/api/workspace', async (c) => c.json({ projects: await source.workspace() }, 200))
43
+ .get('/api/projects/:id/board', async (c) => {
45
44
  const id = c.req.param('id');
46
45
  const read = await source.tasks(id);
47
46
  if (read === undefined)
48
47
  return refuse(c, 404, 'not-connected', 'No connected project has this id.');
49
48
  if (read.state !== 'ready')
50
- return c.json({ board: read });
51
- return c.json({ board: { id, state: 'ready', columns: columns(read.tasks), finished: read.finished } });
52
- });
53
- app.get('/api/projects/:id/tasks/:task', async (c) => {
49
+ return c.json({ board: read }, 200);
50
+ return c.json({ board: { id, state: read.state, columns: columns(read.tasks), finished: read.finished } }, 200);
51
+ })
52
+ .get('/api/projects/:id/tasks/:task', async (c) => {
54
53
  const read = await source.task(c.req.param('id'), c.req.param('task'));
55
54
  if (read === undefined)
56
55
  return refuse(c, 404, 'not-connected', 'No connected project has this id.');
57
56
  if (read.state !== 'ready')
58
- return c.json({ task: read });
57
+ return c.json({ task: read }, 200);
59
58
  if (read.task === null)
60
59
  return refuse(c, 404, 'not-found', 'This project has no live task with this id.');
61
- return c.json({ task: { state: 'ready', ...read.task, column: placeOf(read.task) } });
62
- });
63
- app.get('/api/projects/:id/specs', async (c) => {
60
+ return c.json({ task: { state: read.state, ...read.task, column: placeOf(read.task) } }, 200);
61
+ })
62
+ .get('/api/projects/:id/specs', async (c) => {
64
63
  const read = await source.specs(c.req.param('id'));
65
64
  if (read === undefined)
66
65
  return refuse(c, 404, 'not-connected', 'No connected project has this id.');
67
- return c.json({ specs: read });
68
- });
69
- app.post('/api/projects', async (c) => {
70
- const body = await c.req.json().catch(() => undefined);
71
- const path = absolute(body?.path);
66
+ return c.json({ specs: read }, 200);
67
+ })
68
+ .post('/api/projects', validator('json', (body) => ({
69
+ path: absolute(body?.path),
70
+ })), async (c) => {
71
+ const { path } = c.req.valid('json');
72
72
  if (path === undefined)
73
73
  return refuse(c, 422, 'usage', 'Give the absolute path of a project.');
74
74
  const result = await source.connect(path);
75
75
  if ('code' in result)
76
76
  return refuse(c, 422, result.code, result.message);
77
- return c.json({ project: result.project }, result.created ? 201 : 200);
78
- });
79
- app.delete('/api/projects/:id', async (c) => {
77
+ return result.created ? c.json({ project: result.project }, 201) : c.json({ project: result.project }, 200);
78
+ })
79
+ .delete('/api/projects/:id', async (c) => {
80
80
  if (!(await source.disconnect(c.req.param('id')))) {
81
81
  return refuse(c, 404, 'not-connected', 'No connected project has this id.');
82
82
  }
83
83
  return c.body(null, 204);
84
- });
85
- // A signal, not data: the page re-fetches what it shows (adr-20261014-project-source-port-keeps-hosting-open).
86
- app.get('/api/events', (c) => streamSSE(c, async (stream) => {
84
+ })
85
+ // A signal, not data: the page re-fetches what it shows (adr-20261014-project-source-port-keeps-hosting-open).
86
+ .get('/api/events', (c) => streamSSE(c, async (stream) => {
87
87
  const unsubscribe = source.subscribe(({ id, revision }) => {
88
88
  void stream.writeSSE({ event: 'changed', data: JSON.stringify({ project: id, revision }) });
89
89
  });
@@ -93,7 +93,25 @@ export function createApp({ port, token, source, version = readPackageVersion(),
93
93
  if (!stream.aborted)
94
94
  await stream.write(': ping\n\n');
95
95
  }
96
- }));
96
+ })));
97
+ }
98
+ export function createApp({ port, token, source, version = readPackageVersion(), webRoot = DEFAULT_WEB_ROOT }) {
99
+ const app = new Hono();
100
+ // Before the guard, so its refusals carry them too; HSTS means nothing on http://127.0.0.1.
101
+ app.use(secureHeaders({ strictTransportSecurity: false }));
102
+ app.use(localOnly(port));
103
+ app.use(tokenAccess({ port, token }));
104
+ app.onError((error, c) => {
105
+ if (error instanceof RegistryUnreadable)
106
+ return refuse(c, 500, 'registry-unreadable', error.message);
107
+ // Hono's own refusals, such as a body that is no JSON: their status, in the API's form.
108
+ if (error instanceof HTTPException)
109
+ return c.json({ error: { code: 'usage', message: error.message } }, error.status);
110
+ // Only the terminal hears what failed: the message may name a path.
111
+ console.error(error);
112
+ return refuse(c, 500, 'internal', 'Jig UI hit an error it did not expect; its terminal says what.');
113
+ });
114
+ app.route('/', apiRoutes(source, version));
97
115
  app.all('/api/*', (c) => c.json({ error: 'not found' }, 404));
98
116
  if (existsSync(join(webRoot, 'index.html'))) {
99
117
  app.use('/*', serveStatic({ root: webRoot }));
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
+ import { z } from 'zod';
5
6
  const run = promisify(execFile);
6
7
  /** The oldest Jig whose reads Jig UI understands: 0.29.0 is the first that prints JSON. */
7
8
  export const MIN_JIG_VERSION = '0.29.0';
@@ -17,6 +18,7 @@ export const watchDir = (root) => join(root, '.ai');
17
18
  * (Jig 0.29.0, found by watching which files its reads touch).
18
19
  */
19
20
  export const writtenByReads = (path) => (path === 'runtime' || path.startsWith('runtime/')) && !path.startsWith('runtime/revision');
21
+ let gitBash;
20
22
  /**
21
23
  * The bash that runs `.ai/scripts/jig`. On Windows it is Git's, found next to git itself
22
24
  * (`<git>/mingw64/libexec/git-core` → `<git>/bin/bash.exe`): a `bash` on PATH may be WSL's.
@@ -24,8 +26,12 @@ export const writtenByReads = (path) => (path === 'runtime' || path.startsWith('
24
26
  async function bash(platform) {
25
27
  if (platform !== 'win32')
26
28
  return 'bash';
27
- const { stdout } = await run('git', ['--exec-path'], { timeout: TIMEOUT_MS });
28
- return resolve(stdout.trim(), '..', '..', '..', 'bin', 'bash.exe');
29
+ // Asked once per process; a failed ask is forgotten, so the next run asks again.
30
+ gitBash ??= run('git', ['--exec-path'], { timeout: TIMEOUT_MS }).then(({ stdout }) => resolve(stdout.trim(), '..', '..', '..', 'bin', 'bash.exe'), (error) => {
31
+ gitBash = undefined;
32
+ throw error;
33
+ });
34
+ return gitBash;
29
35
  }
30
36
  /** Runs the project's own Jig in its main checkout, without a shell. */
31
37
  async function jig(root, args, platform) {
@@ -60,8 +66,6 @@ export class JigShapeError extends Error {
60
66
  }
61
67
  }
62
68
  const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
63
- const isString = (v) => typeof v === 'string';
64
- const isNullableString = (v) => v === null || typeof v === 'string';
65
69
  /**
66
70
  * Runs a `--json` read and returns its `data` and the clone's revision. Jig's refusal throws
67
71
  * JigRefusal, as does output that is no JSON at all (a crash); an envelope of another shape throws
@@ -87,159 +91,157 @@ async function readJson(root, args, platform) {
87
91
  throw new JigShapeError();
88
92
  if (isObject(envelope.error)) {
89
93
  const code = envelope.error.code;
90
- throw new JigRefusal(isString(code) && CODES.has(code) ? code : 'failed');
94
+ throw new JigRefusal(typeof code === 'string' && CODES.has(code) ? code : 'failed');
91
95
  }
92
96
  if (!isObject(envelope.data))
93
97
  throw new JigShapeError();
94
98
  return { revision: envelope.revision, data: envelope.data };
95
99
  }
96
- const isAttention = (v) => isObject(v) &&
97
- isString(v.kind) &&
98
- isObject(v.subject) &&
99
- isString(v.subject.type) &&
100
- isNullableString(v.subject.id) &&
101
- Array.isArray(v.tasks) &&
102
- v.tasks.every(isString) &&
103
- isString(v.title) &&
104
- isNullableString(v.detail) &&
105
- isString(v.todo) &&
106
- isNullableString(v.url);
100
+ /** `data` read through `schema`, the shape its contract test pins; anything else is a Jig this Jig UI does not read. */
101
+ function parsed(schema, data) {
102
+ const result = schema.safeParse(data);
103
+ if (!result.success)
104
+ throw new JigShapeError();
105
+ return result.data;
106
+ }
107
+ // Each shape of Jig's output once: what Jig prints (snake_case, every field Jig UI reads present —
108
+ // `null` where Jig has nothing, never left out), turned into what Jig UI's types say. Keys Jig adds
109
+ // without raising `schema` are dropped.
110
+ const text = z.string();
111
+ const maybe = z.string().nullable();
112
+ const count = z.int().nonnegative();
113
+ /** Something that needs the person, as `jig status --json` reports it; a kind this Jig UI does not know is shown by its texts. */
114
+ const Attention = z.object({
115
+ kind: text,
116
+ subject: z.object({ type: text, id: maybe }),
117
+ tasks: z.array(text),
118
+ title: text,
119
+ detail: maybe,
120
+ todo: text,
121
+ url: maybe,
122
+ });
123
+ const Status = z.object({
124
+ attention: z.array(Attention),
125
+ housekeeping: z.object({ stale: z.boolean() }).nullable(),
126
+ });
107
127
  /** What needs the person in a project, most urgent first, and whether the pull request facts behind it may be missing. */
108
128
  export async function readStatus(root, platform = process.platform) {
109
129
  const { revision, data } = await readJson(root, ['status'], platform);
110
- const { attention, housekeeping } = data;
111
- if (!Array.isArray(attention) || !attention.every(isAttention))
112
- throw new JigShapeError();
113
- if (housekeeping !== null && !(isObject(housekeeping) && typeof housekeeping.stale === 'boolean'))
114
- throw new JigShapeError();
115
- return {
116
- revision,
117
- attention: attention.map(({ kind, subject, tasks, title, detail, todo, url }) => ({
118
- kind,
119
- subject: { type: subject.type, id: subject.id },
120
- tasks: [...tasks],
121
- title,
122
- detail,
123
- todo,
124
- url,
125
- })),
126
- housekeepingStale: housekeeping?.stale === true,
127
- };
130
+ const { attention, housekeeping } = parsed(Status, data);
131
+ return { revision, attention, housekeepingStale: housekeeping?.stale === true };
128
132
  }
129
- const STATUSES = new Set(['active', 'ready', 'consolidated', 'abandoned']);
130
133
  /** Jig's action ids this Jig UI knows; Jig may add ids without raising `schema`, and a consumer ignores those. */
131
134
  const ACTIONS = new Set(['start', 'pause', 'resume', 'approve-gate', 'answer-stop', 'set-class', 'ship', 'close', 'abandon', 'reopen']);
132
- const nullableStrings = (v, keys) => keys.every((k) => isNullableString(v[k]));
133
- const isGate = (v) => v === null || (isObject(v) && isString(v.state) && isNullableString(v.by));
134
- const isAutopilot = (v) => v === null || (isObject(v) && isString(v.state) && nullableStrings(v, ['mode', 'stage', 'stage_at', 'stop_reason', 'stop_at', 'phase']));
135
- const isPullRequest = (v) => v === null || (isObject(v) && isString(v.url) && nullableStrings(v, ['state', 'ci', 'fetched_at']));
136
- const isWorktree = (v) => v === null || (isObject(v) && typeof v.uncommitted === 'number');
137
- const isAction = (v) => isObject(v) && isString(v.id) && isString(v.command) && typeof v.offered === 'boolean' && isNullableString(v.reason);
138
- const isTaskRow = (v) => isObject(v) &&
139
- isString(v.id) &&
140
- isString(v.status) &&
141
- STATUSES.has(v.status) &&
142
- nullableStrings(v, ['class', 'issue', 'goal', 'created_at', 'updated_at', 'branch']) &&
143
- typeof v.knowledge_consolidated === 'boolean' &&
144
- typeof v.paused === 'boolean' &&
145
- isString(v.base_branch) &&
146
- isWorktree(v.worktree) &&
147
- isGate(v.gate) &&
148
- isAutopilot(v.autopilot) &&
149
- isPullRequest(v.pull_request) &&
150
- Array.isArray(v.housekeeping_flags) &&
151
- v.housekeeping_flags.every(isString) &&
152
- Array.isArray(v.actions) &&
153
- v.actions.every(isAction);
154
- function summary(t) {
155
- const { gate, autopilot: run, pull_request: pr } = t;
156
- return {
157
- id: t.id,
158
- class: t.class,
159
- status: t.status,
160
- issue: t.issue,
161
- goal: t.goal,
162
- knowledgeConsolidated: t.knowledge_consolidated,
163
- paused: t.paused,
164
- createdAt: t.created_at,
165
- updatedAt: t.updated_at,
166
- branch: t.branch,
167
- baseBranch: t.base_branch,
168
- worktree: t.worktree && { uncommitted: t.worktree.uncommitted },
169
- gate: gate && { state: gate.state, by: gate.by },
170
- autopilot: run && {
171
- state: run.state,
172
- mode: run.mode,
173
- stage: run.stage,
174
- stageAt: run.stage_at,
175
- stopReason: run.stop_reason,
176
- stopAt: run.stop_at,
177
- phase: run.phase,
178
- },
179
- pullRequest: pr && { url: pr.url, state: pr.state, ci: pr.ci, fetchedAt: pr.fetched_at },
180
- housekeepingFlags: [...t.housekeeping_flags],
181
- actions: t.actions.filter((a) => ACTIONS.has(a.id)).map(({ id, command, offered, reason }) => ({ id, command, offered, reason })),
182
- };
183
- }
135
+ /** What a person may do next with a task, as Jig decides it from the task's state. */
136
+ const TaskAction = z.object({
137
+ id: text,
138
+ /** The Jig command that takes it; a word in `<…>` is a value the person supplies. */
139
+ command: text,
140
+ offered: z.boolean(),
141
+ /** Jig's refusal, word for word, when not offered; it may name a path. */
142
+ reason: maybe,
143
+ });
144
+ /** A task as `jig task list --json` reports it, as far as Jig UI reads it; the statuses are Jig's (schemas/state.md). */
145
+ const TaskRow = z
146
+ .object({
147
+ id: text,
148
+ class: maybe,
149
+ status: z.enum(['active', 'ready', 'consolidated', 'abandoned']),
150
+ issue: maybe,
151
+ goal: maybe,
152
+ knowledge_consolidated: z.boolean(),
153
+ paused: z.boolean(),
154
+ created_at: maybe,
155
+ updated_at: maybe,
156
+ /** `null` while the task has not been started. */
157
+ branch: maybe,
158
+ base_branch: text,
159
+ /** The worktree that has the task's branch checked out: only how many files differ there, never where it is. */
160
+ worktree: z.object({ uncommitted: z.number() }).nullable(),
161
+ gate: z.object({ state: text, by: maybe }).nullable(),
162
+ autopilot: z
163
+ .object({ state: text, mode: maybe, stage: maybe, stage_at: maybe, stop_reason: maybe, stop_at: maybe, phase: maybe })
164
+ .nullable(),
165
+ pull_request: z.object({ url: text, state: maybe, ci: maybe, fetched_at: maybe }).nullable(),
166
+ housekeeping_flags: z.array(text),
167
+ actions: z.array(TaskAction),
168
+ })
169
+ .transform(({ autopilot: run, pull_request: pr, ...t }) => ({
170
+ id: t.id,
171
+ class: t.class,
172
+ status: t.status,
173
+ issue: t.issue,
174
+ goal: t.goal,
175
+ knowledgeConsolidated: t.knowledge_consolidated,
176
+ paused: t.paused,
177
+ createdAt: t.created_at,
178
+ updatedAt: t.updated_at,
179
+ branch: t.branch,
180
+ baseBranch: t.base_branch,
181
+ worktree: t.worktree,
182
+ gate: t.gate,
183
+ autopilot: run && {
184
+ state: run.state,
185
+ mode: run.mode,
186
+ stage: run.stage,
187
+ stageAt: run.stage_at,
188
+ stopReason: run.stop_reason,
189
+ stopAt: run.stop_at,
190
+ phase: run.phase,
191
+ },
192
+ pullRequest: pr && { url: pr.url, state: pr.state, ci: pr.ci, fetchedAt: pr.fetched_at },
193
+ housekeepingFlags: t.housekeeping_flags,
194
+ actions: t.actions.filter((a) => ACTIONS.has(a.id)),
195
+ }));
196
+ const TaskList = z.object({ tasks: z.array(TaskRow), hidden: z.number() });
184
197
  /** The project's live tasks (`jig task list --json`) and how many finished ones it left out. */
185
198
  export async function readTasks(root, platform = process.platform) {
186
199
  const { revision, data } = await readJson(root, ['task', 'list'], platform);
187
- if (!Array.isArray(data.tasks) || !data.tasks.every(isTaskRow) || typeof data.hidden !== 'number')
188
- throw new JigShapeError();
189
- return { revision, tasks: data.tasks.map(summary), hidden: data.hidden };
200
+ return { revision, ...parsed(TaskList, data) };
190
201
  }
191
- const isCount = (v) => Number.isInteger(v) && v >= 0;
192
- const isProgress = (v) => isObject(v) && isCount(v.done) && isCount(v.total) && isCount(v.filed) && isCount(v.fog);
193
- const isItem = (v) => isObject(v) && isString(v.text) && typeof v.checked === 'boolean' && isNullableString(v.task) && typeof v.fog === 'boolean';
194
- const isPhase = (v) => isObject(v) &&
195
- isProgress(v) &&
196
- (v.phase === null || isCount(v.phase)) &&
197
- isNullableString(v.title) &&
198
- Array.isArray(v.items) &&
199
- v.items.every(isItem);
200
- const isEpic = (v) => v === null ||
201
- (isObject(v) &&
202
- isString(v.branch) &&
203
- (v.state === 'open' || v.state === 'finished') &&
204
- typeof v.checked_out === 'boolean' &&
205
- typeof v.missing === 'boolean' &&
206
- isNullableString(v.ref));
207
- const isSpecRow = (v) => isObject(v) &&
208
- isString(v.id) &&
209
- isNullableString(v.title) &&
210
- Array.isArray(v.missing_files) &&
211
- v.missing_files.every(isString) &&
212
- isNullableString(v.release) &&
213
- isEpic(v.epic) &&
214
- (v.progress === null || isProgress(v.progress)) &&
215
- Array.isArray(v.phases) &&
216
- v.phases.every(isPhase);
217
- const progress = ({ done, total, filed, fog }) => ({ done, total, filed, fog });
202
+ /** How far a roadmap or one of its phases is: items checked, all items, items with a task filed, fog items. */
203
+ const SpecProgress = z.object({ done: count, total: count, filed: count, fog: count });
204
+ /** A roadmap item: its words as the roadmap has them, and the task it names, if any. */
205
+ const SpecItem = z.object({ text, checked: z.boolean(), task: maybe, fog: z.boolean() });
206
+ /** A roadmap phase; `phase` and `title` are `null` for items before any phase heading. */
207
+ const SpecPhase = SpecProgress.extend({ phase: count.nullable(), title: maybe, items: z.array(SpecItem) });
208
+ /** A spec as `jig spec list --json` reports it; the roadmap is read from the epic while one is open. */
209
+ const SpecRow = z
210
+ .object({
211
+ id: text,
212
+ title: maybe,
213
+ /** The spec's own files that are not there: `spec.md`, `roadmap.md`. */
214
+ missing_files: z.array(text),
215
+ /** The version level the epic's release raises, as the roadmap records it. */
216
+ release: maybe,
217
+ epic: z
218
+ .object({ branch: text, state: z.enum(['open', 'finished']), checked_out: z.boolean(), missing: z.boolean(), ref: maybe })
219
+ .nullable(),
220
+ /** `null` when the roadmap could not be read. */
221
+ progress: SpecProgress.nullable(),
222
+ phases: z.array(SpecPhase),
223
+ })
224
+ .transform(({ missing_files, epic, ...s }) => ({
225
+ id: s.id,
226
+ title: s.title,
227
+ missingFiles: missing_files,
228
+ release: s.release,
229
+ // `fetched`: the roadmap was read from the remote branch as of the last fetch; `missing`: the branch is nowhere.
230
+ epic: epic && {
231
+ branch: epic.branch,
232
+ state: epic.state,
233
+ checkedOut: epic.checked_out,
234
+ missing: epic.missing,
235
+ fetched: epic.ref?.startsWith('refs/remotes/') === true,
236
+ },
237
+ progress: s.progress,
238
+ phases: s.phases,
239
+ }));
240
+ const SpecList = z.object({ specs: z.array(SpecRow) });
218
241
  /** The project's specs (`jig spec list --json`), in Jig's order. */
219
242
  export async function readSpecs(root, platform = process.platform) {
220
243
  const { data } = await readJson(root, ['spec', 'list'], platform);
221
- if (!Array.isArray(data.specs) || !data.specs.every(isSpecRow))
222
- throw new JigShapeError();
223
- return data.specs.map((s) => ({
224
- id: s.id,
225
- title: s.title,
226
- missingFiles: [...s.missing_files],
227
- release: s.release,
228
- epic: s.epic && {
229
- branch: s.epic.branch,
230
- state: s.epic.state,
231
- checkedOut: s.epic.checked_out,
232
- missing: s.epic.missing,
233
- fetched: s.epic.ref?.startsWith('refs/remotes/') === true,
234
- },
235
- progress: s.progress && progress(s.progress),
236
- phases: s.phases.map((p) => ({
237
- ...progress(p),
238
- phase: p.phase,
239
- title: p.title,
240
- items: p.items.map(({ text, checked, task, fog }) => ({ text, checked, task, fog })),
241
- })),
242
- }));
244
+ return parsed(SpecList, data).specs;
243
245
  }
244
246
  /** True when `version` is at least the minimum Jig UI reads. */
245
247
  export function supports(version, minimum = MIN_JIG_VERSION) {
@@ -57,7 +57,7 @@ export async function connectThrough(info, path, timeoutMs = 15_000) {
57
57
  body: JSON.stringify({ path }),
58
58
  signal: AbortSignal.timeout(timeoutMs),
59
59
  });
60
- // A failure the instance did not foresee answers in plain text.
60
+ // An instance before 0.2.0 answers a failure it did not foresee in plain text.
61
61
  const body = (await res.json().catch(() => ({})));
62
62
  if (body.project)
63
63
  return { id: body.project.id, created: res.status === 201 };
@@ -34,7 +34,7 @@ export async function readProjects(dir) {
34
34
  export function slugFor(root, taken) {
35
35
  const base = basename(root)
36
36
  .normalize('NFKD')
37
- .replace(/[̀-ͯ]/g, '')
37
+ .replace(/\p{M}/gu, '')
38
38
  .toLowerCase()
39
39
  .replace(/[^a-z0-9]+/g, '-')
40
40
  .replace(/^-+|-+$/g, '') || 'project';
@@ -129,15 +129,19 @@ export function localSource(configDir, { rescanMs = 60_000, quietMs, maxWaitMs }
129
129
  /** Counts the times following started; a read that finishes after a stop belongs to none. */
130
130
  let session = 0;
131
131
  const following = () => rescan !== undefined;
132
+ /** The ids the last sweep of this following found registered; none before its first. */
133
+ let known;
132
134
  const entryOf = (project) => {
133
135
  let entry = entries.get(project.id);
134
136
  if (!entry)
135
- entries.set(project.id, (entry = { project, held: false, dirty: false }));
137
+ entries.set(project.id, (entry = { project, held: false, dirty: false, news: false }));
136
138
  return entry;
137
139
  };
138
140
  /** Keeps what was shown over one torn read; otherwise the read is what the project shows. */
139
141
  function settle(entry, read) {
140
- if (torn(read) && entry.shown && !torn(entry.shown) && !entry.held) {
142
+ // While following, only a read this following showed is kept: one from before it was never shown through it.
143
+ const ours = !following() || entry.readIn === session;
144
+ if (torn(read) && entry.shown && !torn(entry.shown) && !entry.held && ours) {
141
145
  entry.held = true;
142
146
  return;
143
147
  }
@@ -145,8 +149,12 @@ export function localSource(configDir, { rescanMs = 60_000, quietMs, maxWaitMs }
145
149
  entry.shown = read;
146
150
  entry.readIn = following() ? session : undefined;
147
151
  }
148
- /** Reads the project again — one read at a time — and signals when what it shows changed. */
149
- function refresh(entry) {
152
+ /**
153
+ * Reads the project again — one read at a time — and signals when what it shows changed. The
154
+ * first read since following started signals nothing: every page reads when its stream opens.
155
+ */
156
+ function refresh(entry, news = false) {
157
+ entry.news ||= news;
150
158
  if (entry.reading) {
151
159
  entry.dirty = true;
152
160
  return entry.reading;
@@ -154,9 +162,16 @@ export function localSource(configDir, { rescanMs = 60_000, quietMs, maxWaitMs }
154
162
  entry.reading = (async () => {
155
163
  do {
156
164
  entry.dirty = false;
165
+ const was = entry.readIn;
157
166
  const before = JSON.stringify(entry.shown);
158
167
  settle(entry, await readProject(entry.project));
159
- if (following() && entries.get(entry.project.id) === entry && JSON.stringify(entry.shown) !== before) {
168
+ // Compared only with a read of this following, checked once the read is in: one from before
169
+ // it, or from a following that ended meanwhile, was never shown through this one.
170
+ const changed = following() && was === session && JSON.stringify(entry.shown) !== before;
171
+ // News lasts one read: one made while nobody listens tells nobody, and must not tell the next listener.
172
+ const news = entry.news;
173
+ entry.news = false;
174
+ if (following() && entries.get(entry.project.id) === entry && (news || changed)) {
160
175
  signal({ id: entry.project.id, revision: entry.shown?.revision ?? null });
161
176
  }
162
177
  } while (entry.dirty);
@@ -194,6 +209,8 @@ export function localSource(configDir, { rescanMs = 60_000, quietMs, maxWaitMs }
194
209
  if (!projects || !following())
195
210
  return;
196
211
  const ids = new Set(projects.map((p) => p.id));
212
+ const before = known;
213
+ known = ids;
197
214
  for (const [id, entry] of entries) {
198
215
  if (ids.has(id))
199
216
  continue;
@@ -203,11 +220,12 @@ export function localSource(configDir, { rescanMs = 60_000, quietMs, maxWaitMs }
203
220
  for (const project of projects) {
204
221
  const entry = entryOf(project);
205
222
  follow(entry); // a watcher that failed, or a directory that came back
206
- void refresh(entry);
223
+ void refresh(entry, before !== undefined && !before.has(project.id)); // one another Jig UI connected
207
224
  }
208
225
  }
209
226
  function start() {
210
227
  session++;
228
+ known = undefined;
211
229
  rescan = setInterval(() => void sweep(), rescanMs);
212
230
  rescan.unref();
213
231
  void sweep();
@@ -235,7 +253,10 @@ export function localSource(configDir, { rescanMs = 60_000, quietMs, maxWaitMs }
235
253
  const entry = entryOf(project);
236
254
  if (following())
237
255
  follow(entry);
238
- await refresh(entry);
256
+ // News too when another Jig UI registered it since the last sweep: this page has not heard of it.
257
+ const news = created || (known !== undefined && !known.has(project.id));
258
+ known?.add(project.id);
259
+ await refresh(entry, news);
239
260
  return { project: entry.shown.view, created };
240
261
  },
241
262
  async tasks(id) {
@@ -260,6 +281,7 @@ export function localSource(configDir, { rescanMs = 60_000, quietMs, maxWaitMs }
260
281
  return false;
261
282
  entries.get(id)?.watch?.close();
262
283
  entries.delete(id);
284
+ known?.delete(id); // connected again before the next sweep, it is news again
263
285
  if (following())
264
286
  signal({ id, revision: null });
265
287
  return true;
@@ -0,0 +1 @@
1
+ import{A as e,B as t,C as n,D as r,E as i,F as a,G as o,H as s,I as c,J as l,K as u,L as d,M as ee,N as te,O as f,P as p,Q as m,R as h,S as ne,T as re,U as g,V as _,W as v,X as y,Y as b,Z as x,_ as S,a as ie,b as C,c as ae,d as w,f as oe,g as T,h as E,i as D,j as O,k,l as se,m as ce,n as A,o as j,p as le,q as M,r as ue,s as de,t as fe,u as pe,v as me,w as he,x as N,y as P,z as F}from"./index-DOnV4WX5.js";var I=s({__name:`BaseSeparator`,props:{orientation:{type:String,required:!1,default:`horizontal`},decorative:{type:Boolean,required:!1},asChild:{type:Boolean,required:!1},as:{type:null,required:!1}},setup(e){let t=e,n=[`horizontal`,`vertical`];function r(e){return n.includes(e)}let i=a(()=>r(t.orientation)?t.orientation:`horizontal`),s=a(()=>i.value===`vertical`?t.orientation:void 0),c=a(()=>t.decorative?{role:`none`}:{"aria-orientation":s.value,role:`separator`});return(t,n)=>(o(),d(b(f),v({as:e.as,"as-child":e.asChild,"data-orientation":i.value},c.value),{default:l(()=>[M(t.$slots,`default`)]),_:3},16,[`as`,`as-child`,`data-orientation`]))}}),L=s({__name:`Separator`,props:{orientation:{type:String,required:!1,default:`horizontal`},decorative:{type:Boolean,required:!1},asChild:{type:Boolean,required:!1},as:{type:null,required:!1}},setup(e){let t=e;return(e,n)=>(o(),d(I,x(g(t)),{default:l(()=>[M(e.$slots,`default`)]),_:3},16))}}),R=s({__name:`CardContent`,props:{class:{type:[Boolean,null,String,Object,Array]}},setup(e){let t=e;return(e,n)=>(o(),F(`div`,{"data-slot":`card-content`,class:y(b(k)(`px-4 group-data-[size=sm]/card:px-3`,t.class))},[M(e.$slots,`default`)],2))}}),ge=s({__name:`CardDescription`,props:{class:{type:[Boolean,null,String,Object,Array]}},setup(e){let t=e;return(e,n)=>(o(),F(`div`,{"data-slot":`card-description`,class:y(b(k)(`text-xs/relaxed text-muted-foreground`,t.class))},[M(e.$slots,`default`)],2))}}),_e=s({__name:`CardHeader`,props:{class:{type:[Boolean,null,String,Object,Array]}},setup(e){let t=e;return(e,n)=>(o(),F(`div`,{"data-slot":`card-header`,class:y(b(k)(`group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-none px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3`,t.class))},[M(e.$slots,`default`)],2))}}),ve=s({__name:`CardTitle`,props:{class:{type:[Boolean,null,String,Object,Array]}},setup(e){let t=e;return(e,n)=>(o(),F(`div`,{"data-slot":`card-title`,class:y(b(k)(`cn-font-heading text-sm font-medium group-data-[size=sm]/card:text-sm`,t.class))},[M(e.$slots,`default`)],2))}}),z=s({__name:`Separator`,props:{orientation:{default:`horizontal`},decorative:{type:Boolean,default:!0},asChild:{type:Boolean},as:{},class:{type:[Boolean,null,String,Object,Array]}},setup(e){let t=e,n=he(t,`class`);return(e,r)=>(o(),d(b(L),v({"data-slot":`separator`},b(n),{class:b(k)(`shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch`,t.class)}),null,16,[`class`]))}}),ye=[`aria-label`],be={class:`flex items-baseline gap-3 border-b bg-muted/50 px-4 py-2`},xe={class:`font-semibold tracking-wider uppercase`},Se={key:0,class:`text-muted-foreground`},Ce={class:`grid grid-cols-1 divide-y lg:grid-cols-2 lg:divide-x lg:divide-y-0`},B=s({__name:`CanvasSection`,props:{title:{},note:{}},setup(e){return(t,n)=>(o(),F(`section`,{"aria-label":e.title,class:`border-b`},[c(`h2`,be,[c(`span`,xe,m(e.title),1),e.note?(o(),F(`span`,Se,m(e.note),1)):h(``,!0)]),c(`div`,Ce,[(o(),F(p,null,u([`light`,`dark`],e=>c(`div`,{key:e,class:y([e,`flex flex-col gap-4 bg-background p-4 text-foreground`])},[M(t.$slots,`default`)],2)),64))])],8,ye))}}),V=(e,t)=>({id:e,class:`T2`,status:`active`,goal:null,paused:!1,started:!0,gate:null,autopilot:null,pullRequest:null,...t}),H=(e,t)=>({url:`https://github.com/fapost-lab/jig-ui/pull/18`,state:e,ci:t,fetchedAt:`2026-10-18T09:40:00Z`}),U=[V(`design-system`,{class:`T3`,gate:`waiting`,goal:`shadcn-vue on Tailwind v4 with light and dark themes; #/canvas with every component in every variant; the three screens rebuilt on it.`}),V(`registry-schema`,{gate:`changed`,goal:`Version the registry file so a newer Jig UI can read an older one.`}),V(`pause-request`,{class:`T3`,autopilot:`stopped`,goal:`A drop on the paused column writes a sticky request through Jig; the card shows it with its age and a cancel.`}),V(`specs-and-epics`,{autopilot:`on`,goal:`Show each spec with its phases, progress and epic branch beside the board, from Jig's spec list.`}),V(`task-details`,{status:`ready`,pullRequest:H(`open`,`green`),goal:`Open a task's details from its card.`}),V(`refusal-fixture`,{class:`T0`,status:`ready`,pullRequest:H(`open`,`red`),goal:`Replace the refusal text in task-list-states.json with one Jig prints.`}),V(`docs-board`,{class:`T1`,status:`ready`,pullRequest:H(`open`,`pending`),goal:`Describe the five columns on the projects page of the docs.`}),V(`live-updates`,{status:`ready`,pullRequest:H(`merged`,`none`),goal:`Follow connected projects without a reload.`}),V(`no-pr-yet`,{class:`T1`,status:`ready`,goal:`Ready, nothing pushed.`}),V(`jig-init-hook`,{class:`T3`,paused:!0,goal:`Register a project in an installed Jig UI from jig init.`}),V(`file-backlog-task`,{class:null,started:!1,goal:`File a minimal backlog task from the page through jig task new.`}),V(`a-task-with-a-rather-long-identifier-that-wraps`,{goal:null})],W=[{id:`needs-you`,title:`Needs you`,cards:U.slice(0,3)},{id:`in-progress`,title:`In progress`,cards:[U[3],U[11]]},{id:`ready`,title:`Ready`,cards:U.slice(4,9)},{id:`backlog`,title:`Backlog`,cards:[U[10]]},{id:`paused`,title:`Paused`,cards:[U[9]]}],G=W.map(e=>({...e,cards:[]})),we=[{id:`needs-you`,title:`Needs you`,count:3},{id:`in-progress`,title:`In progress`,count:1},{id:`ready`,title:`Ready`,count:5},{id:`backlog`,title:`Backlog`,count:1},{id:`paused`,title:`Paused`,count:1},{id:`unknown`,title:`A column this page does not know`,count:0}],K=[{kind:`gate`,subject:{type:`task`,id:`design-system`},tasks:[`design-system`],title:`Design waits at its gate`,detail:null,todo:`Review the design and approve it: jig task gate design-system approved`,url:null},{kind:`pull-request-ci-failed`,subject:{type:`task`,id:`refusal-fixture`},tasks:[`refusal-fixture`],title:`CI failed`,detail:`pull request #19`,todo:`Look at the failing check.`,url:`https://github.com/fapost-lab/jig-ui/pull/19`},{kind:`housekeeping`,subject:{type:`project`,id:null},tasks:[],title:`Housekeeping never ran`,detail:null,todo:`Run jig housekeeping.`,url:`https://example.com/docs`}],q=[{id:`approve-gate`,command:`jig task gate design-system approved`},{id:`answer-stop`,command:`jig task autopilot pause-request resume --answer <text>`},{id:`pause`,command:`jig task pause design-system`},{id:`something-new`,command:`jig task something-new design-system`}],J={state:`ready`,issue:null,baseBranch:`main`,createdAt:`2026-10-17`,updatedAt:`2026-10-18`,knowledgeConsolidated:!1,worktree:null,gateBy:null,run:null,housekeepingFlags:[],attention:[]},Y=[{...J,...U[2],gate:`approved`,gateBy:`human`,branch:`task/pause-request`,worktree:{uncommitted:4},issue:`#42`,pullRequest:H(`open`,`red`),run:{mode:`attended`,stage:`implement`,stageAt:`2026-10-18T09:00:00Z`,stopReason:`Which column takes a paused task that already has an open pull request?`,stopAt:`2026-10-18T09:12:00Z`,phase:null},housekeepingFlags:[`needs-consolidation`],attention:[K[0]],actions:q,column:{id:`needs-you`,title:`Needs you`}},{...J,...U[10],branch:null,actions:[{id:`start`,command:`jig task start file-backlog-task`}],column:{id:`backlog`,title:`Backlog`}}],X=(e,t={})=>({id:e,state:`ready`,jigVersion:`0.29.0`,counts:{active:11,paused:1,ready:3},attention:[],housekeepingStale:!1,...t}),Z=[X(`jig-ui`,{attention:K,housekeepingStale:!0}),X(`nextjs-store`,{counts:{active:0,paused:0,ready:0}}),{id:`old-jig`,state:`unsupported`,jigVersion:`0.26.0`,minimum:`0.29.0`},{id:`new-jig`,state:`incompatible`,jigVersion:`1.0.0`},{id:`busy`,state:`unreadable`,code:`locked`},{id:`moved`,state:`unavailable`,reason:`missing`}],Q=[{text:"`design-system` — design system and canvas",checked:!0,task:`design-system`,fog:!1},{text:"`specs-and-epics` — specs and epics (after: project board — `jig spec list`)",checked:!1,task:`specs-and-epics`,fog:!1},{text:"`old-task` — a task no longer live",checked:!1,task:`old-task`,fog:!1},{text:`invoices from orders, no task filed yet`,checked:!1,task:null,fog:!1},{text:"fog: registration by `jig init`",checked:!1,task:null,fog:!0}],Te=[{id:`jig-ui`,title:`Jig UI — a local control center for Jig projects`,missingFiles:[],release:null,epic:null,progress:{done:2,total:6,filed:2,fog:1},phases:[{phase:0,title:`Package and release`,done:1,total:1,filed:0,fog:0,items:[{...Q[0],text:`package and release`,task:null}]},{phase:1,title:`Read-only UI`,done:1,total:5,filed:2,fog:1,items:Q}]},{id:`billing`,title:`Billing in one place`,missingFiles:[],release:`minor`,epic:{branch:`epic/billing`,state:`open`,checkedOut:!1,missing:!1,fetched:!0},progress:{done:0,total:1,filed:0,fog:0},phases:[{phase:null,title:null,done:0,total:1,filed:0,fog:0,items:[Q[3]]}]},{id:`lost-epic`,title:`An epic whose branch is gone`,missingFiles:[],release:`major`,epic:{branch:`epic/lost`,state:`open`,checkedOut:!0,missing:!0,fetched:!1},progress:null,phases:[]},{id:`half-written`,title:null,missingFiles:[`spec.md`,`roadmap.md`],release:null,epic:null,progress:null,phases:[]}],Ee={class:`flex gap-4`},De={class:`w-24 text-muted-foreground`},Oe={class:`flex items-center gap-2`},ke={class:`flex flex-wrap gap-2`},Ae={class:`flex max-w-sm flex-col gap-2`},$={class:`flex h-5 items-center gap-2`},je={class:`grid grid-cols-2 gap-2 xl:grid-cols-3`},Me={class:`flex h-[28rem] flex-col`},Ne={class:`flex h-40 flex-col`},Pe={class:`flex h-[28rem] flex-col`},Fe={class:`flex h-64 flex-col`},Ie={class:`flex h-64 flex-col`},Le=s({__name:`CanvasView`,setup(a){let s=[`default`,`secondary`,`outline`,`ghost`,`destructive`,`link`],f=[`xs`,`sm`,`default`,`lg`],h=[`icon-xs`,`icon-sm`,`icon`,`icon-lg`],g=[`default`,`secondary`,`outline`,`ghost`,`destructive`,`link`],v=[`attention`,`good`,`bad`,`quiet`];return(a,x)=>(o(),F(`div`,null,[x[12]||=c(`div`,{class:`border-b px-4 py-3`},[c(`h1`,{class:`font-semibold tracking-wider uppercase`},`Canvas`),c(`p`,{class:`text-muted-foreground`},`Every component of the page in every variant and state; the left panel light, the right dark.`)],-1),_(B,{title:`Tones`,note:`lib/tones.ts — the only colours with a meaning`},{default:l(()=>[(o(),F(p,null,u(v,e=>c(`p`,{key:e,class:y(b(ee)[e])},m(e)+` as a rule`,3)),64)),c(`p`,Ee,[(o(),F(p,null,u(v,e=>c(`span`,{key:e,class:y(b(te)[e])},m(e),3)),64))])]),_:1}),x[13]||=c(`h2`,{class:`border-b px-4 py-2 font-bold tracking-widest uppercase`},`components/ui`,-1),_(B,{title:`Button`,note:`variant × size; icon sizes; disabled`},{default:l(()=>[(o(),F(p,null,u(s,e=>c(`div`,{key:e,class:`flex flex-wrap items-center gap-2`},[c(`span`,De,m(e),1),(o(),F(p,null,u(f,n=>_(b(r),{key:n,variant:e,size:n},{default:l(()=>[t(m(n),1)]),_:2},1032,[`variant`,`size`])),64)),_(b(r),{variant:e,disabled:``},{default:l(()=>[...x[0]||=[t(`disabled`,-1)]]),_:1},8,[`variant`])])),64)),c(`div`,Oe,[x[2]||=c(`span`,{class:`w-24 text-muted-foreground`},`icon`,-1),(o(),F(p,null,u(h,t=>_(b(r),{key:t,variant:`outline`,size:t,"aria-label":t},{default:l(()=>[_(b(e))]),_:1},8,[`size`,`aria-label`])),64)),_(b(r),{variant:`outline`,size:`sm`},{default:l(()=>[_(b(e),{"data-icon":`inline-start`}),x[1]||=t(`with icon`,-1)]),_:1})])]),_:1}),_(B,{title:`Badge`},{default:l(()=>[c(`div`,ke,[(o(),F(p,null,u(g,e=>_(b(n),{key:e,variant:e},{default:l(()=>[t(m(e),1)]),_:2},1032,[`variant`])),64))])]),_:1}),_(B,{title:`Card`},{default:l(()=>[_(b(ne),{class:`max-w-sm`},{default:l(()=>[_(b(_e),null,{default:l(()=>[_(b(ve),null,{default:l(()=>[...x[3]||=[t(`Card title`,-1)]]),_:1}),_(b(ge),null,{default:l(()=>[...x[4]||=[t(`Card description, muted.`,-1)]]),_:1})]),_:1}),_(b(R),null,{default:l(()=>[...x[5]||=[t(`Card content.`,-1)]]),_:1})]),_:1})]),_:1}),_(B,{title:`Input and Label`,note:`empty, filled, invalid, disabled`},{default:l(()=>[c(`div`,Ae,[_(b(ue),null,{default:l(()=>[...x[6]||=[t(`Project path`,-1)]]),_:1}),_(b(D),{placeholder:`/path/to/project`}),_(b(D),{"model-value":`/Users/me/project`}),_(b(D),{"model-value":`/not/a/project`,"aria-invalid":`true`}),_(b(D),{"model-value":`disabled`,disabled:``})])]),_:1}),_(B,{title:`Separator`},{default:l(()=>[_(b(z)),c(`div`,$,[x[7]||=t(`left`,-1),_(b(z),{orientation:`vertical`}),x[8]||=t(`right`,-1)])]),_:1}),_(B,{title:`ToggleGroup`,note:`the Board / List switch`},{default:l(()=>[_(b(de),{type:`single`,variant:`outline`,size:`sm`,"model-value":`board`,"aria-label":`View`},{default:l(()=>[_(b(j),{value:`board`},{default:l(()=>[...x[9]||=[t(`Board`,-1)]]),_:1}),_(b(j),{value:`list`},{default:l(()=>[...x[10]||=[t(`List`,-1)]]),_:1})]),_:1})]),_:1}),_(B,{title:`Collapsible`,note:`open and folded`},{default:l(()=>[(o(),F(p,null,u([!0,!1],e=>_(b(E),{key:String(e),"default-open":e},{default:l(()=>[_(b(le),{class:`w-full border-b bg-muted/50 px-3 py-1.5 text-left`},{default:l(()=>[t(`Trigger (`+m(e?`open`:`folded`)+`)`,1)]),_:2},1024),_(b(ce),{class:`px-3 py-2`},{default:l(()=>[...x[11]||=[t(`Content`,-1)]]),_:1})]),_:2},1032,[`default-open`])),64))]),_:1}),x[14]||=c(`h2`,{class:`border-b px-4 py-2 font-bold tracking-widest uppercase`},`components`,-1),_(B,{title:`ProjectTabs`,note:`on the workspace; on a project; empty`},{default:l(()=>[_(O,{projects:b(Z),version:`0.1.0`},{default:l(()=>[_(i)]),_:1},8,[`projects`]),_(O,{projects:b(Z),current:`jig-ui`,version:`0.1.0`},null,8,[`projects`]),_(O,{projects:[]})]),_:1}),_(B,{title:`ThemeToggle`,note:`switches the whole page; the icon is the theme it switches to`},{default:l(()=>[c(`div`,null,[_(i)])]),_:1}),_(B,{title:`ProjectCard`,note:`ready with attention and stale housekeeping; nothing needs you; each state it cannot be read in`},{default:l(()=>[(o(!0),F(p,null,u(b(Z),e=>(o(),d(fe,{key:e.id,project:e},null,8,[`project`]))),128))]),_:1}),_(B,{title:`ConnectForm`,note:`idle, busy, refused`},{default:l(()=>[_(A,{busy:!1}),_(A,{busy:!0,"model-value":`/Users/me/project`}),_(A,{busy:!1,"model-value":`/Users/me/project/.claude/worktrees/x`,refusal:`That is a worktree. Connect the main checkout instead.`})]),_:1}),_(B,{title:`AttentionItem`,note:`no link; a pull request; another link`},{default:l(()=>[(o(!0),F(p,null,u(b(K),(e,t)=>(o(),d(ie,{key:t,item:e},null,8,[`item`]))),128))]),_:1}),_(B,{title:`ColumnHeader`,note:`each column, and one this page does not know`},{default:l(()=>[(o(!0),F(p,null,u(b(we),e=>(o(),d(re,{id:e.id,key:e.id,title:e.title,count:e.count},null,8,[`id`,`title`,`count`]))),128))]),_:1}),_(B,{title:`TaskCard`,note:`every note a card carries; open with details, while reading, unreadable`},{default:l(()=>[c(`div`,je,[(o(!0),F(p,null,u(b(U),e=>(o(),d(S,{key:e.id,project:`jig-ui`,task:e,open:!1},null,8,[`task`]))),128)),_(S,{project:`jig-ui`,task:b(U)[2],open:!0,details:b(Y)[0]},null,8,[`task`,`details`]),_(S,{project:`jig-ui`,task:b(U)[0],open:!0},null,8,[`task`]),_(S,{project:`jig-ui`,task:b(U)[1],open:!0,details:`unavailable`},null,8,[`task`])])]),_:1}),_(B,{title:`TaskRow`,note:`each note; selected`},{default:l(()=>[c(`div`,null,[(o(!0),F(p,null,u(b(U),(e,t)=>(o(),d(oe,{key:e.id,task:e,selected:t===2},null,8,[`task`,`selected`]))),128))])]),_:1}),_(B,{title:`BoardColumns`,note:`cards in every column, one open with its details; no live task`},{default:l(()=>[c(`div`,Me,[_(T,{project:`jig-ui`,columns:b(W),selected:b(U)[2].id,details:b(Y)[0]},null,8,[`columns`,`selected`,`details`])]),c(`div`,Ne,[_(T,{project:`jig-ui`,columns:b(G)},null,8,[`columns`])])]),_:1}),_(B,{title:`BoardList`,note:`a row picked with its details; nothing picked; no live task`},{default:l(()=>[c(`div`,Pe,[_(w,{project:`jig-ui`,columns:b(W),selected:b(U)[2].id,details:b(Y)[0]},null,8,[`columns`,`selected`,`details`])]),c(`div`,Fe,[_(w,{project:`jig-ui`,columns:b(W)},null,8,[`columns`])]),c(`div`,Ie,[_(w,{project:`jig-ui`,columns:b(G)},null,8,[`columns`])])]),_:1}),_(B,{title:`TaskFacts`,note:`everything a task can carry; a task not started`},{default:l(()=>[(o(!0),F(p,null,u(b(Y),e=>(o(),d(P,{key:e.id,details:e,class:`border-b pb-3`},null,8,[`details`]))),128))]),_:1}),_(B,{title:`TaskBrief`,note:`facts, actions and the link to the full page`},{default:l(()=>[_(me,{project:`jig-ui`,task:b(Y)[1].id,details:b(Y)[1]},null,8,[`task`,`details`])]),_:1}),_(B,{title:`ActionList`,note:`Jig's offers, one unknown id; none`},{default:l(()=>[_(C,{actions:b(q)},null,8,[`actions`]),_(C,{actions:[]})]),_:1}),_(B,{title:`CopyCommand`,note:`short; long enough to be cut`},{default:l(()=>[_(N,{command:`jig task pause design-system`}),_(N,{command:`jig task autopilot a-task-with-a-rather-long-identifier-that-wraps resume --answer 'a long answer to the question'`,class:`max-w-xs`})]),_:1}),_(B,{title:`ProgressBar`,note:`none, part, all, an empty roadmap`},{default:l(()=>[(o(),F(p,null,u([[0,4],[3,9],[5,5],[0,0]],([e,t])=>_(pe,{key:`${e}/${t}`,done:e,total:t},null,8,[`done`,`total`])),64))]),_:1}),_(B,{title:`SpecItemRow`,note:`done with a live task; open with dependencies; a task no longer live; no task; fog`},{default:l(()=>[c(`ul`,null,[(o(!0),F(p,null,u(b(Q),(e,t)=>(o(),d(se,{key:t,item:e,project:`jig-ui`,live:t<2},null,8,[`item`,`live`]))),128))])]),_:1}),_(B,{title:`SpecCard`,note:`no epic, a finished phase folded; an open epic as of the last fetch; a lost epic branch; missing files`},{default:l(()=>[(o(!0),F(p,null,u(b(Te),e=>(o(),d(ae,{key:e.id,spec:e,project:`jig-ui`,live:[`design-system`,`specs-and-epics`]},null,8,[`spec`]))),128))]),_:1})]))}});export{Le as default};