feat(draw): drawing-card document envelope model + parser (T-318)

DrawingCardDoc + parseDrawingCardDoc read the `clide draw` JSON: card
label/description, primitive svg/svgPath, or a template name plus its
fields (for a handler to lower to SVG). Tolerant — a non-object payload
is null, blank strings are absent, never throws. Flutter-free, covered by
dart test (10 cases). First slice of the drawing-card core (D-103); not
yet wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 22:18:53 +02:00
co-authored by Claude Opus 4.8
parent 406da1e0c5
commit 1a0a69df5f
4 changed files with 227 additions and 0 deletions
+41
View File
@@ -8143,3 +8143,44 @@ CSS DECISION — inline-normalize (option A): d2 emits a <style> block of FLAT s
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.
RENDERER CORE DONE (2026-06-28): the pure-Dart pipeline (5 parsers + typed model + builder; 82 dart-test cases incl. a real-d2 fixture) plus the CustomPaint painter (lib/widgets/src/svg/svg_painter.dart; 8 pixel-probe cases under flutter test) render real d2 output end to end shapes/text/transforms/group-opacity/viewBox all draw. Exposes paintSvg + SvgScenePainter + SvgView. REMAINING for T-320: (1) markers/arrowheads collect <marker> defs in the builder + paint marker-end at path endpoints (orient=auto); (2) image painting async href ui.Image via an injected resolver (like image.show), currently skipped. After those, T-318 (envelope/dispatch/overlay) consumes SvgView.', NULL, '2026-06-28 18:59:56', '2026-06-28 18:59:56.371', '2026-06-28 18:59:56.371', NULL, '46585e90df499aa7dce4886336e56752', 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 ('06FB2ETJQP0CT6X7W3CWZ6NS9G', 'description', 'Template for the unified drawing card (T-317, D-91): render an SVG file referenced in the JSON. clide-owned rendering per the own-the-rendering-stack guardrail — no opinionated SVG package without justification; evaluate what is feasible via CustomPaint / a minimal path parser, and scope accordingly (SVG is a broad spec; define the supported subset). Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318).
PROMOTED TO ENGINE (D-103, 2026-06-28): this is now the drawing-card''s rendering FOUNDATION, not one template among many. Build the clide-owned CustomPaint SVG renderer over a BOUNDED subset (rect/line/polyline/polygon/circle/ellipse/path, text incl. the Phosphor font, image, g + transform, basic fill/stroke/opacity/rx). T-318 (envelope + dispatch + Flutter overlay) and every template lower onto this; d2/graphviz/mermaid output renders for free. No longer blocked by T-318 (dependency inverted). Open: pin the subset to what our own templates + d2/graphviz emit. Schema: docs/design/drawing-card-schema.md.
SVG SUBSET FINALIZED (2026-06-28, grounded in a real d2 sample + our own templates):
STRUCTURE: <svg> (viewBox/width/height, incl. nested svg), <g> (transform, opacity, class), <defs>, <marker> (+ marker-start/mid/end; orient=auto, refX/refY, viewBox) edge arrowheads.
SHAPES: rect (rx/ry), circle, ellipse, line, polyline, polygon, path (full data: M L H V C S Q T A Z + relatives).
TEXT: text + tspan (x/y/dx/dy, font-family incl. Phosphor, font-size/weight, text-anchor, dominant-baseline).
RASTER: image (href/xlink:href, x/y/w/h, preserveAspectRatio) our image/compare templates.
STYLING: presentation attrs (fill, fill-opacity, stroke, stroke-width, stroke-linecap/linejoin, stroke-dasharray, opacity, color), transform (translate/scale/rotate/matrix), class= resolved via the normalizer below.
DEFERRED v1: <mask> (d2 masks connections for clean edge/node joins) ignore and rely on node-over-edge paint order; add basic mask only if d2 output looks wrong.
OUT: foreignObject, filters, <animate>/SMIL, scripting, <use>/<symbol>, gradients, patterns, clipPath. (So mermaid, which leans on foreignObject, is not a launch target.)
CSS DECISION inline-normalize (option A): d2 emits a <style> block of FLAT single-class selectors (.fill-B1, .color-N1, .shape, .connection, .text-bold presentation props). A preprocessing normalizer parses <style> into class->props, merges each element''s class props into inline presentation attributes (inline wins), and drops <style>; the painter then only ever sees inline attrs a pure, testable presentation-attribute renderer. Chosen over a render-time CSS resolver to keep the painter free of any CSS concept.
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.
RENDERER CORE DONE (2026-06-28): the pure-Dart pipeline (5 parsers + typed model + builder; 82 dart-test cases incl. a real-d2 fixture) plus the CustomPaint painter (lib/widgets/src/svg/svg_painter.dart; 8 pixel-probe cases under flutter test) render real d2 output end to end shapes/text/transforms/group-opacity/viewBox all draw. Exposes paintSvg + SvgScenePainter + SvgView. REMAINING for T-320: (1) markers/arrowheads collect <marker> defs in the builder + paint marker-end at path endpoints (orient=auto); (2) image painting async href ui.Image via an injected resolver (like image.show), currently skipped. After those, T-318 (envelope/dispatch/overlay) consumes SvgView.', 'Template for the unified drawing card (T-317, D-91): render an SVG file referenced in the JSON. clide-owned rendering per the own-the-rendering-stack guardrail no opinionated SVG package without justification; evaluate what is feasible via CustomPaint / a minimal path parser, and scope accordingly (SVG is a broad spec; define the supported subset). Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318).
PROMOTED TO ENGINE (D-103, 2026-06-28): this is now the drawing-card''s rendering FOUNDATION, not one template among many. Build the clide-owned CustomPaint SVG renderer over a BOUNDED subset (rect/line/polyline/polygon/circle/ellipse/path, text incl. the Phosphor font, image, g + transform, basic fill/stroke/opacity/rx). T-318 (envelope + dispatch + Flutter overlay) and every template lower onto this; d2/graphviz/mermaid output renders for free. No longer blocked by T-318 (dependency inverted). Open: pin the subset to what our own templates + d2/graphviz emit. Schema: docs/design/drawing-card-schema.md.
SVG SUBSET FINALIZED (2026-06-28, grounded in a real d2 sample + our own templates):
STRUCTURE: <svg> (viewBox/width/height, incl. nested svg), <g> (transform, opacity, class), <defs>, <marker> (+ marker-start/mid/end; orient=auto, refX/refY, viewBox) edge arrowheads.
SHAPES: rect (rx/ry), circle, ellipse, line, polyline, polygon, path (full data: M L H V C S Q T A Z + relatives).
TEXT: text + tspan (x/y/dx/dy, font-family incl. Phosphor, font-size/weight, text-anchor, dominant-baseline).
RASTER: image (href/xlink:href, x/y/w/h, preserveAspectRatio) our image/compare templates.
STYLING: presentation attrs (fill, fill-opacity, stroke, stroke-width, stroke-linecap/linejoin, stroke-dasharray, opacity, color), transform (translate/scale/rotate/matrix), class= resolved via the normalizer below.
DEFERRED v1: <mask> (d2 masks connections for clean edge/node joins) ignore and rely on node-over-edge paint order; add basic mask only if d2 output looks wrong.
OUT: foreignObject, filters, <animate>/SMIL, scripting, <use>/<symbol>, gradients, patterns, clipPath. (So mermaid, which leans on foreignObject, is not a launch target.)
CSS DECISION inline-normalize (option A): d2 emits a <style> block of FLAT single-class selectors (.fill-B1, .color-N1, .shape, .connection, .text-bold presentation props). A preprocessing normalizer parses <style> into class->props, merges each element''s class props into inline presentation attributes (inline wins), and drops <style>; the painter then only ever sees inline attrs a pure, testable presentation-attribute renderer. Chosen over a render-time CSS resolver to keep the painter free of any CSS concept.
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.
RENDERER CORE DONE (2026-06-28): the pure-Dart pipeline (5 parsers + typed model + builder; 82 dart-test cases incl. a real-d2 fixture) plus the CustomPaint painter (lib/widgets/src/svg/svg_painter.dart; 8 pixel-probe cases under flutter test) render real d2 output end to end shapes/text/transforms/group-opacity/viewBox all draw. Exposes paintSvg + SvgScenePainter + SvgView. REMAINING for T-320: (1) markers/arrowheads collect <marker> defs in the builder + paint marker-end at path endpoints (orient=auto); (2) image painting async href ui.Image via an injected resolver (like image.show), currently skipped. After those, T-318 (envelope/dispatch/overlay) consumes SvgView.
COMPLETE (2026-06-28): the clide-owned CustomPaint SVG renderer is feature-complete for the finalized subset. Renders shapes (rect/rrect/ellipse/circle/line/polyline/polygon/path incl. arcs), text (anchor + baseline), per-node transforms, group opacity, viewBox fit, marker-start/end arrowheads (rotated to the path tangent), and <image> via an injected resolver (caller owns href loading). 93 tests: 82 dart-test (pure-Dart pipeline incl. a real-d2 fixture) + 11 flutter-test pixel-probe. Known v1 limitations (within the deferred subset, not blockers): <mask> ignored (relies on node-over-edge paint order); marker viewBox->viewport scaling approximated 1:1; marker-mid unpainted; <image> preserveAspectRatio is stretch-fit. Next: T-318 (envelope + dispatch + Flutter overlay) consumes SvgView.', NULL, '2026-06-28 19:26:43', '2026-06-28 19:26:43.693', '2026-06-28 19:26:43.693', NULL, '87901e4089b90d59450382e907e81fc6', 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 ('06FB2ETJQP0CT6X7W3CWZ6NS9G', 'status', 'in_progress', 'done', NULL, '2026-06-28 19:36:13', '2026-06-28 19:36:13.904', '2026-06-28 19:36:13.904', NULL, '4915f72f465afeeec178d6bcc830cfb4', 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 ('06FB2ERREMEEF26KKHGNZBWW64', 'status', 'backlog', 'in_progress', NULL, '2026-06-28 19:36:13', '2026-06-28 19:36:13.943', '2026-06-28 19:36:13.943', NULL, 'fabe425a8e04875205406b3146e43240', 2) ON CONFLICT(hash) DO NOTHING;
+49
View File
@@ -9999,3 +9999,52 @@ CSS DECISION — inline-normalize (option A): d2 emits a <style> block of FLAT s
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.
RENDERER CORE DONE (2026-06-28): the pure-Dart pipeline (5 parsers + typed model + builder; 82 dart-test cases incl. a real-d2 fixture) plus the CustomPaint painter (lib/widgets/src/svg/svg_painter.dart; 8 pixel-probe cases under flutter test) render real d2 output end to end shapes/text/transforms/group-opacity/viewBox all draw. Exposes paintSvg + SvgScenePainter + SvgView. REMAINING for T-320: (1) markers/arrowheads collect <marker> defs in the builder + paint marker-end at path endpoints (orient=auto); (2) image painting async href ui.Image via an injected resolver (like image.show), currently skipped. After those, T-318 (envelope/dispatch/overlay) consumes SvgView.', 'in_progress', 'low', NULL, NULL, NULL, '2026-06-10 11:11:55', '2026-06-28 18:59:56.371', NULL, '3748efc57b0f8cd46f1665d805f491c6', 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 ('06FB2ETJQP0CT6X7W3CWZ6NS9G', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: SVG render', 'Template for the unified drawing card (T-317, D-91): render an SVG file referenced in the JSON. clide-owned rendering per the own-the-rendering-stack guardrail — no opinionated SVG package without justification; evaluate what is feasible via CustomPaint / a minimal path parser, and scope accordingly (SVG is a broad spec; define the supported subset). Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318).
PROMOTED TO ENGINE (D-103, 2026-06-28): this is now the drawing-card''s rendering FOUNDATION, not one template among many. Build the clide-owned CustomPaint SVG renderer over a BOUNDED subset (rect/line/polyline/polygon/circle/ellipse/path, text incl. the Phosphor font, image, g + transform, basic fill/stroke/opacity/rx). T-318 (envelope + dispatch + Flutter overlay) and every template lower onto this; d2/graphviz/mermaid output renders for free. No longer blocked by T-318 (dependency inverted). Open: pin the subset to what our own templates + d2/graphviz emit. Schema: docs/design/drawing-card-schema.md.
SVG SUBSET FINALIZED (2026-06-28, grounded in a real d2 sample + our own templates):
STRUCTURE: <svg> (viewBox/width/height, incl. nested svg), <g> (transform, opacity, class), <defs>, <marker> (+ marker-start/mid/end; orient=auto, refX/refY, viewBox) edge arrowheads.
SHAPES: rect (rx/ry), circle, ellipse, line, polyline, polygon, path (full data: M L H V C S Q T A Z + relatives).
TEXT: text + tspan (x/y/dx/dy, font-family incl. Phosphor, font-size/weight, text-anchor, dominant-baseline).
RASTER: image (href/xlink:href, x/y/w/h, preserveAspectRatio) our image/compare templates.
STYLING: presentation attrs (fill, fill-opacity, stroke, stroke-width, stroke-linecap/linejoin, stroke-dasharray, opacity, color), transform (translate/scale/rotate/matrix), class= resolved via the normalizer below.
DEFERRED v1: <mask> (d2 masks connections for clean edge/node joins) ignore and rely on node-over-edge paint order; add basic mask only if d2 output looks wrong.
OUT: foreignObject, filters, <animate>/SMIL, scripting, <use>/<symbol>, gradients, patterns, clipPath. (So mermaid, which leans on foreignObject, is not a launch target.)
CSS DECISION inline-normalize (option A): d2 emits a <style> block of FLAT single-class selectors (.fill-B1, .color-N1, .shape, .connection, .text-bold presentation props). A preprocessing normalizer parses <style> into class->props, merges each element''s class props into inline presentation attributes (inline wins), and drops <style>; the painter then only ever sees inline attrs a pure, testable presentation-attribute renderer. Chosen over a render-time CSS resolver to keep the painter free of any CSS concept.
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.
RENDERER CORE DONE (2026-06-28): the pure-Dart pipeline (5 parsers + typed model + builder; 82 dart-test cases incl. a real-d2 fixture) plus the CustomPaint painter (lib/widgets/src/svg/svg_painter.dart; 8 pixel-probe cases under flutter test) render real d2 output end to end shapes/text/transforms/group-opacity/viewBox all draw. Exposes paintSvg + SvgScenePainter + SvgView. REMAINING for T-320: (1) markers/arrowheads collect <marker> defs in the builder + paint marker-end at path endpoints (orient=auto); (2) image painting async href ui.Image via an injected resolver (like image.show), currently skipped. After those, T-318 (envelope/dispatch/overlay) consumes SvgView.
COMPLETE (2026-06-28): the clide-owned CustomPaint SVG renderer is feature-complete for the finalized subset. Renders shapes (rect/rrect/ellipse/circle/line/polyline/polygon/path incl. arcs), text (anchor + baseline), per-node transforms, group opacity, viewBox fit, marker-start/end arrowheads (rotated to the path tangent), and <image> via an injected resolver (caller owns href loading). 93 tests: 82 dart-test (pure-Dart pipeline incl. a real-d2 fixture) + 11 flutter-test pixel-probe. Known v1 limitations (within the deferred subset, not blockers): <mask> ignored (relies on node-over-edge paint order); marker viewBox->viewport scaling approximated 1:1; marker-mid unpainted; <image> preserveAspectRatio is stretch-fit. Next: T-318 (envelope + dispatch + Flutter overlay) consumes SvgView.', 'in_progress', 'low', NULL, NULL, NULL, '2026-06-10 11:11:55', '2026-06-28 19:26:43.693', NULL, 'd5b55e5b69ce5ba68b3dbdaf36932d05', 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 ('06FB2ETJQP0CT6X7W3CWZ6NS9G', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: SVG render', 'Template for the unified drawing card (T-317, D-91): render an SVG file referenced in the JSON. clide-owned rendering per the own-the-rendering-stack guardrail — no opinionated SVG package without justification; evaluate what is feasible via CustomPaint / a minimal path parser, and scope accordingly (SVG is a broad spec; define the supported subset). Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318).
PROMOTED TO ENGINE (D-103, 2026-06-28): this is now the drawing-card''s rendering FOUNDATION, not one template among many. Build the clide-owned CustomPaint SVG renderer over a BOUNDED subset (rect/line/polyline/polygon/circle/ellipse/path, text incl. the Phosphor font, image, g + transform, basic fill/stroke/opacity/rx). T-318 (envelope + dispatch + Flutter overlay) and every template lower onto this; d2/graphviz/mermaid output renders for free. No longer blocked by T-318 (dependency inverted). Open: pin the subset to what our own templates + d2/graphviz emit. Schema: docs/design/drawing-card-schema.md.
SVG SUBSET FINALIZED (2026-06-28, grounded in a real d2 sample + our own templates):
STRUCTURE: <svg> (viewBox/width/height, incl. nested svg), <g> (transform, opacity, class), <defs>, <marker> (+ marker-start/mid/end; orient=auto, refX/refY, viewBox) edge arrowheads.
SHAPES: rect (rx/ry), circle, ellipse, line, polyline, polygon, path (full data: M L H V C S Q T A Z + relatives).
TEXT: text + tspan (x/y/dx/dy, font-family incl. Phosphor, font-size/weight, text-anchor, dominant-baseline).
RASTER: image (href/xlink:href, x/y/w/h, preserveAspectRatio) our image/compare templates.
STYLING: presentation attrs (fill, fill-opacity, stroke, stroke-width, stroke-linecap/linejoin, stroke-dasharray, opacity, color), transform (translate/scale/rotate/matrix), class= resolved via the normalizer below.
DEFERRED v1: <mask> (d2 masks connections for clean edge/node joins) ignore and rely on node-over-edge paint order; add basic mask only if d2 output looks wrong.
OUT: foreignObject, filters, <animate>/SMIL, scripting, <use>/<symbol>, gradients, patterns, clipPath. (So mermaid, which leans on foreignObject, is not a launch target.)
CSS DECISION inline-normalize (option A): d2 emits a <style> block of FLAT single-class selectors (.fill-B1, .color-N1, .shape, .connection, .text-bold presentation props). A preprocessing normalizer parses <style> into class->props, merges each element''s class props into inline presentation attributes (inline wins), and drops <style>; the painter then only ever sees inline attrs a pure, testable presentation-attribute renderer. Chosen over a render-time CSS resolver to keep the painter free of any CSS concept.
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.
RENDERER CORE DONE (2026-06-28): the pure-Dart pipeline (5 parsers + typed model + builder; 82 dart-test cases incl. a real-d2 fixture) plus the CustomPaint painter (lib/widgets/src/svg/svg_painter.dart; 8 pixel-probe cases under flutter test) render real d2 output end to end shapes/text/transforms/group-opacity/viewBox all draw. Exposes paintSvg + SvgScenePainter + SvgView. REMAINING for T-320: (1) markers/arrowheads collect <marker> defs in the builder + paint marker-end at path endpoints (orient=auto); (2) image painting async href ui.Image via an injected resolver (like image.show), currently skipped. After those, T-318 (envelope/dispatch/overlay) consumes SvgView.
COMPLETE (2026-06-28): the clide-owned CustomPaint SVG renderer is feature-complete for the finalized subset. Renders shapes (rect/rrect/ellipse/circle/line/polyline/polygon/path incl. arcs), text (anchor + baseline), per-node transforms, group opacity, viewBox fit, marker-start/end arrowheads (rotated to the path tangent), and <image> via an injected resolver (caller owns href loading). 93 tests: 82 dart-test (pure-Dart pipeline incl. a real-d2 fixture) + 11 flutter-test pixel-probe. Known v1 limitations (within the deferred subset, not blockers): <mask> ignored (relies on node-over-edge paint order); marker viewBox->viewport scaling approximated 1:1; marker-mid unpainted; <image> preserveAspectRatio is stretch-fit. Next: T-318 (envelope + dispatch + Flutter overlay) consumes SvgView.', 'done', 'low', NULL, NULL, NULL, '2026-06-10 11:11:55', '2026-06-28 19:36:13.904', NULL, 'aac34b971922f539cc893d324589bc21', 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 ('06FB2ERREMEEF26KKHGNZBWW64', 'story', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card core: canvas engine + JSON schema + template dispatch', 'Foundational build for the unified drawing card (epic T-317, decision D-91). A clide-owned canvas (CustomPaint) that renders from a JSON document: a PRIMITIVE scene-graph layer (rect/line/text/glyph/image at coordinates) plus a TEMPLATE-dispatch layer that maps a named component in the JSON to a predefined renderer. Templates lower onto the same primitive scene (hybrid model). Per drawn object, an optional label + description widget renders beneath it, only when those fields are present in the JSON. Display-only (D-78) — no inline selection. Driven via the clide CLI (D-6 parity), consuming the JSON input plumbing (T-315 / --file); mirror image.show''s Flutter-free handler + MessageBus publish + Claude-extension injection pattern. THIS ticket = the engine, the JSON schema, the primitive renderer, the dispatch mechanism, and the shared per-object label/description widget. Individual templates (image, icon, compare, svg, graph) are separate children. Acceptance: a JSON doc with raw primitives draws; a JSON doc naming a template dispatches to it; an object with label/description renders the caption widget; unknown template/primitive fails with a clear userError.
SCHEMA DRAFTED (2026-06-28): docs/design/drawing-card-schema.md declarative JSON scene-graph (document envelope, primitive types rect/line/text/glyph/image, template envelope, shared label/description, arbitrary-hex color, CLI ''clide draw --file'', error contract). Refined from the T-317 wireframe set.
MODEL CLARIFICATION (user): we build clide''s OWN native Flutter CustomPaint interpreting a declarative scene-graph document. We do NOT port the HTML Canvas 2D API ''HTML canvas'' in D-91 is only the mental model (a general drawing surface, chosen to reject Obsidian''s .canvas schema), not an API to implement. The declarative model is closer to SVG/a retained scene-graph than to canvas''s imperative 2D context.
RESCOPED (D-103, 2026-06-28): the primitive layer is now SVG and the SVG renderer (T-320) is the engine so T-318 is NO LONGER a primitive renderer. T-318 = the document envelope ({template? | svg/svgPath}, card metadata), the template DISPATCH, and the clide FLUTTER OVERLAY: per-object label/description caption widgets + lightbox affordance, anchored to SVG elements via data-label / data-description / data-lightbox. Now blocked by T-320 (engine first). Schema: docs/design/drawing-card-schema.md.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:11:40', '2026-06-28 19:36:13.943', NULL, '9cb51abdf14d5fc47d0bf25d75e79a68', 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);
+56
View File
@@ -0,0 +1,56 @@
/// The drawing-card document model + envelope parser (T-318 / D-91 / D-103).
///
/// A `clide draw` payload is a JSON document. In PRIMITIVE mode it carries raw
/// SVG (`svg` inline or `svgPath`); in TEMPLATE mode it names a `template`
/// (`d2`, `icon`, `compare`, `image`, …) whose fields a registered handler
/// lowers to SVG. Either way an optional card-level `label`/`description`
/// renders as a caption beneath the drawing (D-103: SVG is the substrate; a thin
/// Flutter overlay carries the chrome).
///
/// This is just the typed envelope + a tolerant parser — template lowering and
/// painting live elsewhere. Never throws; a non-object payload yields `null`.
///
/// Flutter-free: pure Dart, runs under `dart test`.
library;
/// A parsed drawing-card document.
class DrawingCardDoc {
const DrawingCardDoc({this.label, this.description, this.template, this.svg, this.svgPath, this.fields = const {}});
/// Card-level caption, shown beneath the drawing when present.
final String? label, description;
/// Template name (`d2`, `icon`, …). `null` or `svg` ⇒ primitive mode.
final String? template;
/// Primitive-mode SVG: inline source, or a path to an `.svg`.
final String? svg, svgPath;
/// The full document map, so a template handler can read its own fields
/// (`source`, `items`, `path`, …).
final Map<String, Object?> fields;
/// True when the card is raw SVG rather than a named template.
bool get isPrimitive => template == null || template == 'svg';
}
/// Parse a decoded-JSON drawing-card document (a `Map`). Returns `null` only
/// when [json] isn't a JSON object; never throws.
DrawingCardDoc? parseDrawingCardDoc(Object? json) {
if (json is! Map) return null;
final card = json['card'];
final cardMap = card is Map ? card : const {};
return DrawingCardDoc(
label: _str(cardMap['label']) ?? _str(json['label']),
description: _str(cardMap['description']) ?? _str(json['description']),
template: _str(json['template']),
svg: _str(json['svg']),
svgPath: _str(json['svgPath']),
fields: {
for (final e in json.entries)
if (e.key is String) e.key as String: e.value,
},
);
}
String? _str(Object? v) => v is String && v.isNotEmpty ? v : null;
+81
View File
@@ -0,0 +1,81 @@
import 'package:clide/src/draw/draw_doc.dart';
import 'package:test/test.dart';
void main() {
group('parseDrawingCardDoc', () {
test('primitive mode: inline svg', () {
final d = parseDrawingCardDoc({'svg': '<svg/>'})!;
expect(d.isPrimitive, isTrue);
expect(d.svg, '<svg/>');
expect(d.template, isNull);
});
test('card metadata via the card object', () {
final d = parseDrawingCardDoc({
'card': {'label': 'Build pipeline', 'description': 'how it connects'},
'svgPath': 'd.svg',
})!;
expect(d.label, 'Build pipeline');
expect(d.description, 'how it connects');
expect(d.svgPath, 'd.svg');
});
test('card metadata also accepted at the top level', () {
final d = parseDrawingCardDoc({'label': 'L', 'description': 'D', 'svg': '<svg/>'})!;
expect(d.label, 'L');
expect(d.description, 'D');
});
test('the card object wins over a top-level field', () {
final d = parseDrawingCardDoc({
'card': {'label': 'inner'},
'label': 'outer',
'svg': '<svg/>',
})!;
expect(d.label, 'inner');
});
test('template mode exposes its fields', () {
final d = parseDrawingCardDoc({'template': 'd2', 'source': 'a -> b'})!;
expect(d.template, 'd2');
expect(d.isPrimitive, isFalse);
expect(d.fields['source'], 'a -> b');
});
test('template "svg" is still primitive', () {
final d = parseDrawingCardDoc({'template': 'svg', 'svg': '<svg/>'})!;
expect(d.isPrimitive, isTrue);
});
test('compare template carries its items list', () {
final d = parseDrawingCardDoc({
'template': 'compare',
'items': [
{'path': 'a.png', 'label': 'Before'},
{'path': 'b.png', 'label': 'After'},
],
})!;
expect(d.template, 'compare');
expect((d.fields['items'] as List).length, 2);
});
test('empty object is a valid (primitive, empty) doc', () {
final d = parseDrawingCardDoc(const {})!;
expect(d.isPrimitive, isTrue);
expect(d.svg, isNull);
expect(d.label, isNull);
});
test('a non-object payload is null, never throws', () {
expect(parseDrawingCardDoc('nope'), isNull);
expect(parseDrawingCardDoc(42), isNull);
expect(parseDrawingCardDoc(null), isNull);
});
test('blank strings are treated as absent', () {
final d = parseDrawingCardDoc({'label': '', 'svg': ''})!;
expect(d.label, isNull);
expect(d.svg, isNull);
});
});
}