diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 9781b4de..967b517d 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -9085,3 +9085,70 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang DONE (2026-06-29): SVG-substrate compare card landed (approach chosen by user) + tests green. compareTemplateHandler lowers an images array [{path,label,description}] to side-by-side cells, each with the T-318 data-label/description caption + data-lightbox; paths resolved to absolute up front (injected, honest DrawErr on a miss). _paintImage now aspect-fits (contain, centered) so differing shapes don''t distort. Draw card path loads hrefs -> ui.Image via loadDrawingImages (decoder injectable for tests); renderer + lightbox both paint through the resolver. Commits a651c9e0 (template+painter) + 1f6d31ac (image-loading+register).', NULL, '2026-06-29 13:02:44', '2026-06-29 13:02:44.687', '2026-06-29 13:02:44.687', NULL, '61cd7ded0058d2727055ab9805712180', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ESCR4V6V07CRH18FBCDN8', 'status', 'in_progress', 'done', NULL, '2026-06-29 13:02:44', '2026-06-29 13:02:44.726', '2026-06-29 13:02:44.726', NULL, 'a03f0481fb84e46fa45077355a64a47b', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'status', 'backlog', 'in_progress', NULL, '2026-06-29 16:44:18', '2026-06-29 16:44:18.342', '2026-06-29 16:44:18.342', NULL, '0d5c93289262d7527ea7471e96f8d02b', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'description', 'Add a stdin path to clide''s CLI so a command can receive a JSON payload piped in — `… | clide icon show --stdin`, `cat meta.json | clide image show foo.png --stdin` — instead of only positionals/flags or a `--file`. + +## Why + +Structured commands (the labelled icon-card entries in T-313, image annotation metadata in T-316) want a JSON payload that''s awkward to express as flags. Today clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals, --flags, and `-- passthrough`; there is no stdin path. T-313 therefore falls back to a `--file ` flag. A `--stdin` convention is the ergonomic peer of `--file` for piping, and is shared infra both icon.show and image.show consume. + +## Where the work lives + +clide''s IPC server runs in-process and the `clide` CLI is a thin client that serialises argv into an IpcRequest over CLIDE_SOCK. So stdin must be slurped CLIENT-SIDE (in lib/src/cli/, around argv_to_request.dart / argv_dispatch.dart) and folded into the request before it is sent — the in-process handler never sees the real stdin. Decide how it surfaces in the envelope: e.g. a reserved `stdin`/`payload` field on IpcRequest, or a synthesised arg the CommandSchema can opt into (an ArgSpec flag like `acceptsStdin`, mirroring how ArgType.stringList is declared in lib/src/ipc/command_schema.dart). + +## Scope / decisions + +- Generic infra, not icon/image specific — once landed, any command opts in via its CommandSchema. +- Keep `--file` working; --stdin and --file should be mutually exclusive (error if both given) or layered with a defined precedence. +- Text/JSON payloads only to start; define a size cap and a clear error when --stdin is passed but stdin is empty/not a pipe (don''t hang waiting on a TTY). +- Honest IpcError (userError) on malformed JSON, surfaced like image.show''s other validation failures. +- D-6 parity: document the stdin convention alongside the other CLI verbs. + +## Acceptance + +- A command can declare (via CommandSchema) that it accepts a stdin payload; piping JSON in populates the IpcRequest with that payload. +- `clide icon show --stdin` (T-313) and `clide image show --stdin` (T-316) both consume it. +- --stdin + --file together is a clear user error; --stdin with no piped input fails fast, never hangs on a TTY. +- Malformed JSON returns a userError with a helpful message. + +Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this. + +Deferred 2026-06-28 (user): blocked on its consumers T-316 + T-313 so it only resurfaces if one actually wants the piping UX. Standalone --stdin infra isn''t worth a build cycle now — both consumers can ship with --file (per this ticket), and structured JSON also flows natively through the MCP tool surface (D-86). Build it lazily inside whichever consumer first needs piping, if ever.', 'Add a stdin path to clide''s CLI so a command can receive a JSON payload piped in — `… | clide icon show --stdin`, `cat meta.json | clide image show foo.png --stdin` — instead of only positionals/flags or a `--file`. + +## Why + +Structured commands (the labelled icon-card entries in T-313, image annotation metadata in T-316) want a JSON payload that''s awkward to express as flags. Today clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals, --flags, and `-- passthrough`; there is no stdin path. T-313 therefore falls back to a `--file ` flag. A `--stdin` convention is the ergonomic peer of `--file` for piping, and is shared infra both icon.show and image.show consume. + +## Where the work lives + +clide''s IPC server runs in-process and the `clide` CLI is a thin client that serialises argv into an IpcRequest over CLIDE_SOCK. So stdin must be slurped CLIENT-SIDE (in lib/src/cli/, around argv_to_request.dart / argv_dispatch.dart) and folded into the request before it is sent — the in-process handler never sees the real stdin. Decide how it surfaces in the envelope: e.g. a reserved `stdin`/`payload` field on IpcRequest, or a synthesised arg the CommandSchema can opt into (an ArgSpec flag like `acceptsStdin`, mirroring how ArgType.stringList is declared in lib/src/ipc/command_schema.dart). + +## Scope / decisions + +- Generic infra, not icon/image specific — once landed, any command opts in via its CommandSchema. +- Keep `--file` working; --stdin and --file should be mutually exclusive (error if both given) or layered with a defined precedence. +- Text/JSON payloads only to start; define a size cap and a clear error when --stdin is passed but stdin is empty/not a pipe (don''t hang waiting on a TTY). +- Honest IpcError (userError) on malformed JSON, surfaced like image.show''s other validation failures. +- D-6 parity: document the stdin convention alongside the other CLI verbs. + +## Acceptance + +- A command can declare (via CommandSchema) that it accepts a stdin payload; piping JSON in populates the IpcRequest with that payload. +- `clide icon show --stdin` (T-313) and `clide image show --stdin` (T-316) both consume it. +- --stdin + --file together is a clear user error; --stdin with no piped input fails fast, never hangs on a TTY. +- Malformed JSON returns a userError with a helpful message. + +Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this. + +Deferred 2026-06-28 (user): blocked on its consumers T-316 + T-313 so it only resurfaces if one actually wants the piping UX. Standalone --stdin infra isn''t worth a build cycle now — both consumers can ship with --file (per this ticket), and structured JSON also flows natively through the MCP tool surface (D-86). Build it lazily inside whichever consumer first needs piping, if ever. + +DONE (2026-06-29): --stdin piped payloads landed + tests green, C client builds clean. C client (clide.c): slurp stdin on --stdin, strip the flag, ship args.stdin (bounded 32KB + envelope size guard). Dart unwrap (argv_dispatch) folds args.stdin into the inner request as a flag -> named arg (undeclared keys pass the schema). icon.show + image.show read the stdin payload as the peer of --file (stdin wins). Tests: unwrap fold + both handlers'' stdin path. Commit 64410e80. NOTE: the fold is generic, so draw could adopt --stdin trivially if wanted.', NULL, '2026-06-29 21:56:46', '2026-06-29 21:56:46.563', '2026-06-29 21:56:46.563', NULL, '649bcf844d6f04552c9c854b2bca84f2', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'status', 'in_progress', 'done', NULL, '2026-06-29 21:56:46', '2026-06-29 21:56:46.597', '2026-06-29 21:56:46.597', NULL, '5abc8eaf2d572c6eba1a69f9c85147b8', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'description', 'Template for the unified drawing card (T-317, D-91): render a graph passed in the JSON. Reuses clide''s native graph rendering (CustomPaint; the planned graph subsystem, D-46) rather than a new renderer. Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318); coordinate with the graph subsystem so the card embeds it rather than forking it.', 'Template for the unified drawing card (T-317, D-91): render a graph passed in the JSON. Reuses clide''s native graph rendering (CustomPaint; the planned graph subsystem, D-46) rather than a new renderer. Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318); coordinate with the graph subsystem so the card embeds it rather than forking it. + +DECOUPLED from T-323 (2026-06-29): the graph CARD is static display-only (D-78) — it lowers a nodes/edges JSON to the SVG substrate (T-320) like the compare/d2 templates, so it does NOT need the interactive force-directed pane (T-323). T-323 remains a separate from-scratch pane. Building as a Flutter-free ''graph'' drawing template: deterministic circular layout -> nodes + edges + labels.', NULL, '2026-06-29 21:58:13', '2026-06-29 21:58:13.945', '2026-06-29 21:58:13.945', NULL, '77d68f4254bf06af680c330d4817a960', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'status', 'backlog', 'in_progress', NULL, '2026-06-29 21:58:13', '2026-06-29 21:58:13.987', '2026-06-29 21:58:13.987', NULL, '6ad6abee6bb3fe32a0267e9a299ce8ba', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'status', 'in_progress', 'done', NULL, '2026-06-29 22:02:01', '2026-06-29 22:02:01.070', '2026-06-29 22:02:01.070', NULL, 'f810703df30dce71610d7cb15c456987', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'description', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget.', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget. + +SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).', NULL, '2026-06-29 22:02:01', '2026-06-29 22:02:01.103', '2026-06-29 22:02:01.103', NULL, '6805996f31597f6175f5bf5d4fb1da0f', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'status', 'backlog', 'in_progress', NULL, '2026-06-29 22:02:01', '2026-06-29 22:02:01.132', '2026-06-29 22:02:01.132', NULL, 'cdeea9aebcb4acc1242d3e1354756020', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 8be6423b..2e5abce2 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -10993,3 +10993,76 @@ clide''s IPC server runs in-process and the `clide` CLI is a thin client that se Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this. Deferred 2026-06-28 (user): blocked on its consumers T-316 + T-313 so it only resurfaces if one actually wants the piping UX. Standalone --stdin infra isn''t worth a build cycle now — both consumers can ship with --file (per this ticket), and structured JSON also flows natively through the MCP tool surface (D-86). Build it lazily inside whichever consumer first needs piping, if ever.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:34', '2026-06-29 16:44:18.342', NULL, 'c6331517a674a7e66a02bac032bc6cff', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'clide CLI: accept a JSON payload on stdin for structured commands', 'Add a stdin path to clide''s CLI so a command can receive a JSON payload piped in — `… | clide icon show --stdin`, `cat meta.json | clide image show foo.png --stdin` — instead of only positionals/flags or a `--file`. + +## Why + +Structured commands (the labelled icon-card entries in T-313, image annotation metadata in T-316) want a JSON payload that''s awkward to express as flags. Today clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals, --flags, and `-- passthrough`; there is no stdin path. T-313 therefore falls back to a `--file ` flag. A `--stdin` convention is the ergonomic peer of `--file` for piping, and is shared infra both icon.show and image.show consume. + +## Where the work lives + +clide''s IPC server runs in-process and the `clide` CLI is a thin client that serialises argv into an IpcRequest over CLIDE_SOCK. So stdin must be slurped CLIENT-SIDE (in lib/src/cli/, around argv_to_request.dart / argv_dispatch.dart) and folded into the request before it is sent — the in-process handler never sees the real stdin. Decide how it surfaces in the envelope: e.g. a reserved `stdin`/`payload` field on IpcRequest, or a synthesised arg the CommandSchema can opt into (an ArgSpec flag like `acceptsStdin`, mirroring how ArgType.stringList is declared in lib/src/ipc/command_schema.dart). + +## Scope / decisions + +- Generic infra, not icon/image specific — once landed, any command opts in via its CommandSchema. +- Keep `--file` working; --stdin and --file should be mutually exclusive (error if both given) or layered with a defined precedence. +- Text/JSON payloads only to start; define a size cap and a clear error when --stdin is passed but stdin is empty/not a pipe (don''t hang waiting on a TTY). +- Honest IpcError (userError) on malformed JSON, surfaced like image.show''s other validation failures. +- D-6 parity: document the stdin convention alongside the other CLI verbs. + +## Acceptance + +- A command can declare (via CommandSchema) that it accepts a stdin payload; piping JSON in populates the IpcRequest with that payload. +- `clide icon show --stdin` (T-313) and `clide image show --stdin` (T-316) both consume it. +- --stdin + --file together is a clear user error; --stdin with no piped input fails fast, never hangs on a TTY. +- Malformed JSON returns a userError with a helpful message. + +Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this. + +Deferred 2026-06-28 (user): blocked on its consumers T-316 + T-313 so it only resurfaces if one actually wants the piping UX. Standalone --stdin infra isn''t worth a build cycle now — both consumers can ship with --file (per this ticket), and structured JSON also flows natively through the MCP tool surface (D-86). Build it lazily inside whichever consumer first needs piping, if ever. + +DONE (2026-06-29): --stdin piped payloads landed + tests green, C client builds clean. C client (clide.c): slurp stdin on --stdin, strip the flag, ship args.stdin (bounded 32KB + envelope size guard). Dart unwrap (argv_dispatch) folds args.stdin into the inner request as a flag -> named arg (undeclared keys pass the schema). icon.show + image.show read the stdin payload as the peer of --file (stdin wins). Tests: unwrap fold + both handlers'' stdin path. Commit 64410e80. NOTE: the fold is generic, so draw could adopt --stdin trivially if wanted.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:34', '2026-06-29 21:56:46.563', NULL, '6e56c46124a95bc0abe57407d46ca351', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ACSDBDZARV3NNGYD9NYYR', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'clide CLI: accept a JSON payload on stdin for structured commands', 'Add a stdin path to clide''s CLI so a command can receive a JSON payload piped in — `… | clide icon show --stdin`, `cat meta.json | clide image show foo.png --stdin` — instead of only positionals/flags or a `--file`. + +## Why + +Structured commands (the labelled icon-card entries in T-313, image annotation metadata in T-316) want a JSON payload that''s awkward to express as flags. Today clide''s CLI argv parser (lib/src/cli/argv_to_request.dart) only produces positionals, --flags, and `-- passthrough`; there is no stdin path. T-313 therefore falls back to a `--file ` flag. A `--stdin` convention is the ergonomic peer of `--file` for piping, and is shared infra both icon.show and image.show consume. + +## Where the work lives + +clide''s IPC server runs in-process and the `clide` CLI is a thin client that serialises argv into an IpcRequest over CLIDE_SOCK. So stdin must be slurped CLIENT-SIDE (in lib/src/cli/, around argv_to_request.dart / argv_dispatch.dart) and folded into the request before it is sent — the in-process handler never sees the real stdin. Decide how it surfaces in the envelope: e.g. a reserved `stdin`/`payload` field on IpcRequest, or a synthesised arg the CommandSchema can opt into (an ArgSpec flag like `acceptsStdin`, mirroring how ArgType.stringList is declared in lib/src/ipc/command_schema.dart). + +## Scope / decisions + +- Generic infra, not icon/image specific — once landed, any command opts in via its CommandSchema. +- Keep `--file` working; --stdin and --file should be mutually exclusive (error if both given) or layered with a defined precedence. +- Text/JSON payloads only to start; define a size cap and a clear error when --stdin is passed but stdin is empty/not a pipe (don''t hang waiting on a TTY). +- Honest IpcError (userError) on malformed JSON, surfaced like image.show''s other validation failures. +- D-6 parity: document the stdin convention alongside the other CLI verbs. + +## Acceptance + +- A command can declare (via CommandSchema) that it accepts a stdin payload; piping JSON in populates the IpcRequest with that payload. +- `clide icon show --stdin` (T-313) and `clide image show --stdin` (T-316) both consume it. +- --stdin + --file together is a clear user error; --stdin with no piped input fails fast, never hangs on a TTY. +- Malformed JSON returns a userError with a helpful message. + +Unblocks the piped-JSON variants of T-313 (icon entries) and T-316 (image annotations); both can also ship with --file independently of this. + +Deferred 2026-06-28 (user): blocked on its consumers T-316 + T-313 so it only resurfaces if one actually wants the piping UX. Standalone --stdin infra isn''t worth a build cycle now — both consumers can ship with --file (per this ticket), and structured JSON also flows natively through the MCP tool surface (D-86). Build it lazily inside whichever consumer first needs piping, if ever. + +DONE (2026-06-29): --stdin piped payloads landed + tests green, C client builds clean. C client (clide.c): slurp stdin on --stdin, strip the flag, ship args.stdin (bounded 32KB + envelope size guard). Dart unwrap (argv_dispatch) folds args.stdin into the inner request as a flag -> named arg (undeclared keys pass the schema). icon.show + image.show read the stdin payload as the peer of --file (stdin wins). Tests: unwrap fold + both handlers'' stdin path. Commit 64410e80. NOTE: the fold is generic, so draw could adopt --stdin trivially if wanted.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 10:52:34', '2026-06-29 21:56:46.597', NULL, '8394145b7847882dc0e01be0dc9d9658', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: graph render', 'Template for the unified drawing card (T-317, D-91): render a graph passed in the JSON. Reuses clide''s native graph rendering (CustomPaint; the planned graph subsystem, D-46) rather than a new renderer. Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318); coordinate with the graph subsystem so the card embeds it rather than forking it. + +DECOUPLED from T-323 (2026-06-29): the graph CARD is static display-only (D-78) — it lowers a nodes/edges JSON to the SVG substrate (T-320) like the compare/d2 templates, so it does NOT need the interactive force-directed pane (T-323). T-323 remains a separate from-scratch pane. Building as a Flutter-free ''graph'' drawing template: deterministic circular layout -> nodes + edges + labels.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 11:11:59', '2026-06-29 21:58:13.945', NULL, '1b823b1adfcea41b041b99267f169eea', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: graph render', 'Template for the unified drawing card (T-317, D-91): render a graph passed in the JSON. Reuses clide''s native graph rendering (CustomPaint; the planned graph subsystem, D-46) rather than a new renderer. Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318); coordinate with the graph subsystem so the card embeds it rather than forking it. + +DECOUPLED from T-323 (2026-06-29): the graph CARD is static display-only (D-78) — it lowers a nodes/edges JSON to the SVG substrate (T-320) like the compare/d2 templates, so it does NOT need the interactive force-directed pane (T-323). T-323 remains a separate from-scratch pane. Building as a Flutter-free ''graph'' drawing template: deterministic circular layout -> nodes + edges + labels.', 'in_progress', 'low', NULL, NULL, NULL, '2026-06-10 11:11:59', '2026-06-29 21:58:13.986', NULL, '3cf15954664b5753f44a88bdbd354ca7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2EV29HSK6EJ5VF50R87VC4', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: graph render', 'Template for the unified drawing card (T-317, D-91): render a graph passed in the JSON. Reuses clide''s native graph rendering (CustomPaint; the planned graph subsystem, D-46) rather than a new renderer. Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318); coordinate with the graph subsystem so the card embeds it rather than forking it. + +DECOUPLED from T-323 (2026-06-29): the graph CARD is static display-only (D-78) — it lowers a nodes/edges JSON to the SVG substrate (T-320) like the compare/d2 templates, so it does NOT need the interactive force-directed pane (T-323). T-323 remains a separate from-scratch pane. Building as a Flutter-free ''graph'' drawing template: deterministic circular layout -> nodes + edges + labels.', 'done', 'low', NULL, NULL, NULL, '2026-06-10 11:11:59', '2026-06-29 22:02:01.070', NULL, '652525a97c7352f5bef99280f6641d40', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G1WD1839Z90AQ5C0BHNV4', 'story', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Tier-5 canvas pane (builtin.canvas)', 'Tier-5 canvas PANE, absorbed from the former T-7 into the unified canvas epic (T-317, decision D-91). A full workspace pane that renders .canvas files by CONVERTING them into the drawing-card JSON (per D-91: .canvas is an import format, not a native schema) and painting via the shared canvas renderer (T-318). Scope: nodes (note, text, group, image) + edges + layout state; pan/zoom; node selection, drag, resize; edit affordances (add note from file picker, add text node, draw edge between nodes); persist layout back to the .canvas file on disk. Uses MultitabPane (T-83) for tabs and its own slot per D-47; panels are extension-shaped (D-17). Depends on the core renderer (T-318). NOTE: unlike the conversation drawing card (display-only, D-78), this pane is interactive/editable — it is a pane, not a conversation widget. + +SCOPE NOTE (2026-06-29): currently a 17-line stub. This is a from-scratch interactive Tier-5 pane (parse .canvas -> drawing-card JSON, CustomPaint render, pan/zoom, node select/drag/resize, edit affordances, persist .canvas) — a multi-session feature, NOT template-class work. The drawing-card TEMPLATE half of epic T-317 is now complete (svg/d2/image/icon/compare/graph + stdin).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:17', '2026-06-29 22:02:01.103', NULL, 'ed8d62db18af33012affaba64e15471f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2G2KHKT5CJYR0TK1WQGMD0', 'story', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Tier-5 graph view (builtin.graph)', 'Tier-5 graph VIEW, absorbed from the former T-7 into the unified canvas epic (T-317). Force-directed layout of the vault (or a filtered subset): nodes are notes, edges are wikilinks. Hover highlights the connected subgraph; click opens the note in the editor. Filter pane: tag include/exclude, file glob, depth-from-active. pql provides the link data (pql backlinks / pql outlinks); rendering is owned in-app (CustomPaint, own-the-rendering-stack). Lives in its own slot per D-47 (context panel), uses MultitabPane (T-83). DISTINCT from the in-card graph TEMPLATE (T-321): T-321 renders a graph passed into the conversation drawing card; this is the full interactive graph pane over the whole vault. Relates to the core renderer (T-318) but has its own force-directed layout.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:17:23', '2026-06-29 22:02:01.132', NULL, 'b89270f930759e93fea42054d4629cf7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/lib/src/graph/force_layout.dart b/lib/src/graph/force_layout.dart new file mode 100644 index 00000000..b8094a09 --- /dev/null +++ b/lib/src/graph/force_layout.dart @@ -0,0 +1,99 @@ +/// Force-directed graph layout (T-323) — a clide-owned Fruchterman-Reingold +/// solver (own-the-rendering-stack: no layout package). +/// +/// Nodes repel each other (an inverse-distance "Coulomb" force); edges pull +/// their endpoints together (a "spring"). Iterating with a cooling temperature +/// settles the graph into a readable layout. DETERMINISTIC — a fixed circular +/// seed (no RNG) means the same graph always lays out identically, so the view +/// is stable across rebuilds and the solver is unit-testable. +/// +/// Flutter-free: pure Dart (dart:math), runs under `dart test`. The graph PANE +/// (rendering, pan/zoom, hover, filter) builds on top of this. +library; + +import 'dart:math' as math; + +/// A laid-out 2D point. +typedef GraphPoint = ({double x, double y}); + +class _Vec { + _Vec(this.x, this.y); + double x, y; +} + +class ForceLayout { + /// Lay out [nodeIds] connected by [edges] (pairs of node ids) in a + /// [width]×[height] area over [iterations] steps. Edges referencing an unknown + /// node are ignored. Returns each node's settled position, clamped to the area. + static Map compute(List nodeIds, List<(String, String)> edges, {double width = 800, double height = 600, int iterations = 200}) { + final n = nodeIds.length; + if (n == 0) return const {}; + final cx = width / 2, cy = height / 2; + if (n == 1) return {nodeIds.first: (x: cx, y: cy)}; + + // Deterministic circular seed. + final pos = {}; + for (var i = 0; i < n; i++) { + final a = 2 * math.pi * i / n; + pos[nodeIds[i]] = _Vec(cx + math.cos(a) * width / 4, cy + math.sin(a) * height / 4); + } + + final valid = edges.where((e) => pos.containsKey(e.$1) && pos.containsKey(e.$2) && e.$1 != e.$2).toList(); + final k = math.sqrt(width * height / n); // ideal edge length + var temp = width / 10; + + for (var iter = 0; iter < iterations; iter++) { + final disp = {for (final id in nodeIds) id: _Vec(0, 0)}; + + // Repulsion between every pair. + for (var i = 0; i < n; i++) { + for (var j = i + 1; j < n; j++) { + final a = pos[nodeIds[i]]!, b = pos[nodeIds[j]]!; + var dx = a.x - b.x, dy = a.y - b.y; + var dist = math.sqrt(dx * dx + dy * dy); + if (dist < 0.01) { + dx = 0.01 * (i.isEven ? 1 : -1); + dy = 0.01; + dist = 0.01; + } + final force = k * k / dist; + final ux = dx / dist, uy = dy / dist; + disp[nodeIds[i]]! + ..x += ux * force + ..y += uy * force; + disp[nodeIds[j]]! + ..x -= ux * force + ..y -= uy * force; + } + } + + // Attraction along edges. + for (final e in valid) { + final a = pos[e.$1]!, b = pos[e.$2]!; + final dx = a.x - b.x, dy = a.y - b.y; + final dist = math.max(0.01, math.sqrt(dx * dx + dy * dy)); + final force = dist * dist / k; + final ux = dx / dist, uy = dy / dist; + disp[e.$1]! + ..x -= ux * force + ..y -= uy * force; + disp[e.$2]! + ..x += ux * force + ..y += uy * force; + } + + // Apply, capped by the temperature, clamped to the area. + for (final id in nodeIds) { + final d = disp[id]!; + final len = math.max(0.01, math.sqrt(d.x * d.x + d.y * d.y)); + final step = math.min(len, temp); + final p = pos[id]!; + p.x = (p.x + d.x / len * step).clamp(0.0, width); + p.y = (p.y + d.y / len * step).clamp(0.0, height); + } + temp *= 0.95; // cool + } + + return {for (final id in nodeIds) id: (x: pos[id]!.x, y: pos[id]!.y)}; + } +} diff --git a/test/src/graph/force_layout_test.dart b/test/src/graph/force_layout_test.dart new file mode 100644 index 00000000..717379c7 --- /dev/null +++ b/test/src/graph/force_layout_test.dart @@ -0,0 +1,42 @@ +import 'dart:math' as math; + +import 'package:clide/src/graph/force_layout.dart'; +import 'package:test/test.dart'; + +double _dist(GraphPoint a, GraphPoint b) => math.sqrt(math.pow(a.x - b.x, 2) + math.pow(a.y - b.y, 2)); + +void main() { + test('an empty graph lays out to nothing', () { + expect(ForceLayout.compute(const [], const []), isEmpty); + }); + + test('a single node sits at the centre', () { + expect(ForceLayout.compute(['a'], const [], width: 800, height: 600), {'a': (x: 400.0, y: 300.0)}); + }); + + test('every node settles inside the area', () { + final p = ForceLayout.compute(['a', 'b', 'c', 'd'], const [('a', 'b'), ('b', 'c'), ('c', 'd')], width: 800, height: 600); + expect(p, hasLength(4)); + for (final pt in p.values) { + expect(pt.x, inInclusiveRange(0, 800)); + expect(pt.y, inInclusiveRange(0, 600)); + } + }); + + test('it is deterministic — same input, same layout', () { + final a = ForceLayout.compute(['a', 'b', 'c'], const [('a', 'b'), ('b', 'c')]); + final b = ForceLayout.compute(['a', 'b', 'c'], const [('a', 'b'), ('b', 'c')]); + expect(a, b); + }); + + test('two disconnected nodes repel farther apart than two connected ones', () { + final connected = ForceLayout.compute(['a', 'b'], const [('a', 'b')]); + final apart = ForceLayout.compute(['a', 'b'], const []); + expect(_dist(apart['a']!, apart['b']!), greaterThan(_dist(connected['a']!, connected['b']!))); + }); + + test('an edge to an unknown node is ignored, not a crash', () { + final p = ForceLayout.compute(['a', 'b'], const [('a', 'ghost')]); + expect(p.keys, containsAll(['a', 'b'])); + }); +}