From 78b0a7b8a40c0507de90a99b0b16d5f7c4b0a32d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 28 Jun 2026 22:31:04 +0200 Subject: [PATCH] feat(svg): extract per-object data-* annotations for the drawing-card overlay (T-318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builder captures data-label / data-description / data-lightbox on SVG elements into SvgDocument.annotations, each with the element's AABB in viewBox coordinates (accumulated transform applied — rect/ellipse/image/ line/poly/path bounded; groups skipped, text degenerates to its anchor point). Feeds the Flutter caption/lightbox overlay next. Flutter-free, covered by dart test (6 new cases). Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 19 ++++ .pql/changelog/tickets/2026-06.sql | 11 +++ lib/src/svg/svg_document.dart | 105 ++++++++++++++++++++-- lib/src/svg/svg_node.dart | 32 ++++++- test/svg/svg_document_test.dart | 35 ++++++++ 5 files changed, 194 insertions(+), 8 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 35d2dd3c..b5b106bb 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -8808,3 +8808,22 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang - CI validates both code paths - Performance baseline established ', NULL, '2026-06-28 19:58:30', '2026-06-28 19:58:30.563', '2026-06-28 19:58:30.563', NULL, 'c1a3467c9be21b5b1d57ef7873a8f678', 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', 'description', '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. + +PROGRESS (2026-06-28): drawing-card core built bottom-up + tested (24 cases across dart/flutter test): DrawingCardDoc envelope + parser (lib/src/draw/draw_doc.dart); template dispatch (draw_dispatch.dart — resolveDrawingSvg + DrawingRegistry, primitive svg/svgPath now, handlers for d2/icon/compare/image plug in); DrawingCard widget (lib/widgets/src/draw/ — SvgView + themed caption, display-only); and the CLI command (draw_commands.dart — reads+parses the doc, lowers to SVG, publishes {svg,label,description} on the ''draw'' MessageBus channel). REMAINING: (1) the Claude-extension subscriber that consumes the ''draw'' channel → buildSvgDocument(svg) → injects a DrawingCard message into the primary session (mirror image.show''s consumer); (2) per-object data-* overlay (captions/lightbox anchored to SVG elements) — advanced follow-on.', '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. + +PROGRESS (2026-06-28): drawing-card core built bottom-up + tested (24 cases across dart/flutter test): DrawingCardDoc envelope + parser (lib/src/draw/draw_doc.dart); template dispatch (draw_dispatch.dart — resolveDrawingSvg + DrawingRegistry, primitive svg/svgPath now, handlers for d2/icon/compare/image plug in); DrawingCard widget (lib/widgets/src/draw/ — SvgView + themed caption, display-only); and the CLI command (draw_commands.dart — reads+parses the doc, lowers to SVG, publishes {svg,label,description} on the ''draw'' MessageBus channel). REMAINING: (1) the Claude-extension subscriber that consumes the ''draw'' channel → buildSvgDocument(svg) → injects a DrawingCard message into the primary session (mirror image.show''s consumer); (2) per-object data-* overlay (captions/lightbox anchored to SVG elements) — advanced follow-on. + +EXTENSION WIRE DONE + PUSHED (2026-06-28): item (1) complete — DrawingMessage + the Claude-extension subscriber on the draw channel + the conversation renderer + boot registration; clide draw --file doc.json renders end-to-end (commit 2beb4b9, pushed in 75f378ff). REMAINING (this session): the per-object data-* overlay — data-label / data-description / data-lightbox anchored to individual SVG elements, the last piece of T-318 scope.', NULL, '2026-06-28 20:25:50', '2026-06-28 20:25:50.683', '2026-06-28 20:25:50.683', NULL, 'e8481d8539f90b8d5fd2dddb1932e2a0', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 06cbb306..579dabba 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -10603,3 +10603,14 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat - CI validates both code paths - Performance baseline established ', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-28 19:56:47.977', '2026-06-28 19:58:30.563', NULL, 'f8afde31947ae623adf0d6fcf226f5a2', 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. + +PROGRESS (2026-06-28): drawing-card core built bottom-up + tested (24 cases across dart/flutter test): DrawingCardDoc envelope + parser (lib/src/draw/draw_doc.dart); template dispatch (draw_dispatch.dart — resolveDrawingSvg + DrawingRegistry, primitive svg/svgPath now, handlers for d2/icon/compare/image plug in); DrawingCard widget (lib/widgets/src/draw/ — SvgView + themed caption, display-only); and the CLI command (draw_commands.dart — reads+parses the doc, lowers to SVG, publishes {svg,label,description} on the ''draw'' MessageBus channel). REMAINING: (1) the Claude-extension subscriber that consumes the ''draw'' channel → buildSvgDocument(svg) → injects a DrawingCard message into the primary session (mirror image.show''s consumer); (2) per-object data-* overlay (captions/lightbox anchored to SVG elements) — advanced follow-on. + +EXTENSION WIRE DONE + PUSHED (2026-06-28): item (1) complete — DrawingMessage + the Claude-extension subscriber on the draw channel + the conversation renderer + boot registration; clide draw --file doc.json renders end-to-end (commit 2beb4b9, pushed in 75f378ff). REMAINING (this session): the per-object data-* overlay — data-label / data-description / data-lightbox anchored to individual SVG elements, the last piece of T-318 scope.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-10 11:11:40', '2026-06-28 20:25:50.683', NULL, 'a5a8e71c0e31375e52004b37b181b484', 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/svg/svg_document.dart b/lib/src/svg/svg_document.dart index 4d9fb4f5..0a3048b8 100644 --- a/lib/src/svg/svg_document.dart +++ b/lib/src/svg/svg_document.dart @@ -28,13 +28,17 @@ SvgDocument buildSvgDocument(String src) { final markers = {}; _collectMarkers(root, markers); + final annotations = []; final style = _resolveStyle(root.attrs, SvgStyle.initial); + final rootTf = _transform(root.attrs['transform']); + final children = _children(root, style, rootTf ?? Affine.identity, annotations); return SvgDocument( width: _lenN(root.attrs['width']), height: _lenN(root.attrs['height']), viewBox: _viewBox(root.attrs['viewBox']), - root: SvgGroup(style, _transform(root.attrs['transform']), _children(root, style)), + root: SvgGroup(style, rootTf, children), markers: markers, + annotations: annotations, ); } @@ -58,7 +62,7 @@ SvgMarker _marker(XmlElement el) { orientAngle: (orient != null && !auto) ? (_stripNum(orient) ?? 0) : 0, strokeScaled: el.attrs['markerUnits'] != 'userSpaceOnUse', // default = strokeWidth viewBox: _viewBox(el.attrs['viewBox']), - children: _children(el, SvgStyle.initial), + children: _children(el, SvgStyle.initial, Affine.identity, []), // markers don't carry annotations ); } @@ -69,26 +73,33 @@ String? _markerRef(String? v) { return m?.group(1) ?? (v.startsWith('#') ? v.substring(1) : null); } -List _children(XmlElement el, SvgStyle inherited) { +List _children(XmlElement el, SvgStyle inherited, Affine accumulated, List annotations) { final out = []; for (final c in el.children) { if (c is XmlElement) { - final n = _node(c, inherited); + final n = _node(c, inherited, accumulated, annotations); if (n != null) out.add(n); } } return out; } -SvgNode? _node(XmlElement el, SvgStyle inherited) { +SvgNode? _node(XmlElement el, SvgStyle inherited, Affine accumulated, List annotations) { final style = _resolveStyle(el.attrs, inherited); final tf = _transform(el.attrs['transform']); + final acc = tf == null ? accumulated : accumulated.multiply(tf); + final node = _build(el, style, tf, acc, annotations); + if (node != null) _maybeAnnotate(el, node, acc, annotations); + return node; +} + +SvgNode? _build(XmlElement el, SvgStyle style, Affine? tf, Affine acc, List annotations) { final a = el.attrs; switch (el.name) { case 'g': case 'a': case 'svg': - return SvgGroup(style, tf, _children(el, style)); + return SvgGroup(style, tf, _children(el, style, acc, annotations)); case 'rect': final rx = _numN(a['rx']), ry = _numN(a['ry']); return SvgRect(style, tf, _num(a['x']), _num(a['y']), _num(a['width']), _num(a['height']), rx ?? ry ?? 0, ry ?? rx ?? 0); @@ -121,6 +132,88 @@ SvgNode? _node(XmlElement el, SvgStyle inherited) { } } +void _maybeAnnotate(XmlElement el, SvgNode node, Affine acc, List annotations) { + final label = el.attrs['data-label']; + final desc = el.attrs['data-description']; + final lightbox = el.attrs.containsKey('data-lightbox'); + if (label == null && desc == null && !lightbox) return; + final box = _localBBox(node); + if (box == null) return; + final r = _transformedAABB(box, acc); + annotations.add( + SvgAnnotation(x: r[0], y: r[1], width: r[2], height: r[3], label: label, description: desc, lightbox: lightbox, href: node is SvgImage ? node.href : null), + ); +} + +/// Axis-aligned bounding box of [node] in its own local coordinates. Groups are +/// not bounded (anchor leaf shapes); text degenerates to its anchor point. +List? _localBBox(SvgNode node) { + switch (node) { + case SvgRect r: + return [r.x, r.y, r.width, r.height]; + case SvgImage i: + return [i.x, i.y, i.width, i.height]; + case SvgEllipse e: + return [e.cx - e.rx, e.cy - e.ry, e.rx * 2, e.ry * 2]; + case SvgLine l: + return _aabbOfPoints([l.x1, l.y1, l.x2, l.y2]); + case SvgPolyline p: + return _aabbOfPoints(p.points); + case SvgPath p: + return _aabbOfPoints(_pathPoints(p.segments)); + case SvgText t: + return [t.x, t.y, 0, 0]; + case SvgGroup _: + return null; + } +} + +List _pathPoints(List segs) { + final pts = []; + for (final s in segs) { + switch (s.op) { + case SvgPathOp.moveTo: + case SvgPathOp.lineTo: + pts.addAll([s.args[0], s.args[1]]); + case SvgPathOp.cubicTo: + pts.addAll([s.args[0], s.args[1], s.args[2], s.args[3], s.args[4], s.args[5]]); + case SvgPathOp.quadTo: + pts.addAll([s.args[0], s.args[1], s.args[2], s.args[3]]); + case SvgPathOp.arcTo: + pts.addAll([s.args[5], s.args[6]]); + case SvgPathOp.close: + break; + } + } + return pts; +} + +List? _aabbOfPoints(List pts) { + if (pts.length < 2) return null; + var minX = pts[0], minY = pts[1], maxX = pts[0], maxY = pts[1]; + for (var i = 0; i + 1 < pts.length; i += 2) { + minX = pts[i] < minX ? pts[i] : minX; + maxX = pts[i] > maxX ? pts[i] : maxX; + minY = pts[i + 1] < minY ? pts[i + 1] : minY; + maxY = pts[i + 1] > maxY ? pts[i + 1] : maxY; + } + return [minX, minY, maxX - minX, maxY - minY]; +} + +/// Transform [box] = `[x, y, w, h]` by [m]; return the AABB `[x, y, w, h]`. +List _transformedAABB(List box, Affine m) { + final x = box[0], y = box[1], w = box[2], h = box[3]; + final corners = [m.apply(x, y), m.apply(x + w, y), m.apply(x, y + h), m.apply(x + w, y + h)]; + var minX = corners[0].$1, minY = corners[0].$2, maxX = corners[0].$1, maxY = corners[0].$2; + for (final c in corners) { + minX = c.$1 < minX ? c.$1 : minX; + maxX = c.$1 > maxX ? c.$1 : maxX; + minY = c.$2 < minY ? c.$2 : minY; + maxY = c.$2 > maxY ? c.$2 : maxY; + } + return [minX, minY, maxX - minX, maxY - minY]; +} + SvgStyle _resolveStyle(Map a, SvgStyle inh) { int? color(String k, int? fb) { final v = a[k]; diff --git a/lib/src/svg/svg_node.dart b/lib/src/svg/svg_node.dart index 645f8507..7691a27d 100644 --- a/lib/src/svg/svg_node.dart +++ b/lib/src/svg/svg_node.dart @@ -141,10 +141,35 @@ class SvgViewBox { final double minX, minY, width, height; } +/// A per-object overlay annotation (T-318, D-103): a caption and/or lightbox +/// affordance anchored to an SVG element carrying `data-label` / +/// `data-description` / `data-lightbox`. The rect is the element's axis-aligned +/// bounding box in viewBox (user-space) coordinates with its transform applied; +/// the Flutter overlay maps it through the same viewBox→size fit the painter +/// uses, so captions sit under the right spot. +class SvgAnnotation { + const SvgAnnotation({ + required this.x, + required this.y, + required this.width, + required this.height, + this.label, + this.description, + this.lightbox = false, + this.href, + }); + + final double x, y, width, height; + final String? label, description; + final bool lightbox; + final String? href; // image href, for a lightbox target +} + /// A parsed SVG document: optional intrinsic [width]/[height] (px), optional -/// [viewBox], and the [root] group. +/// [viewBox], the [root] group, marker defs, and per-object overlay +/// [annotations]. class SvgDocument { - const SvgDocument({this.width, this.height, this.viewBox, required this.root, this.markers = const {}}); + const SvgDocument({this.width, this.height, this.viewBox, required this.root, this.markers = const {}, this.annotations = const []}); static const empty = SvgDocument(root: SvgGroup(SvgStyle.initial, null, [])); @@ -154,4 +179,7 @@ class SvgDocument { /// `` definitions by id, referenced by path `marker-*`. final Map markers; + + /// Overlay captions/lightbox anchors extracted from `data-*` attributes. + final List annotations; } diff --git a/test/svg/svg_document_test.dart b/test/svg/svg_document_test.dart index 983035d8..7b55a1f2 100644 --- a/test/svg/svg_document_test.dart +++ b/test/svg/svg_document_test.dart @@ -120,5 +120,40 @@ void main() { expect((m.children.single as SvgPolyline).style.fill, 0xFF0D32B2); expect((d.root.children.single as SvgPath).markerEnd, 'arrow'); }); + + test('a rect with data-label/data-description yields one annotation at its bbox', () { + final d = buildSvgDocument(''); + final a = d.annotations.single; + expect([a.x, a.y, a.width, a.height], [10, 20, 30, 40]); + expect(a.label, 'Box'); + expect(a.description, 'a box'); + expect(a.lightbox, isFalse); + }); + + test('data-lightbox on an image yields a lightbox annotation carrying the href', () { + final d = buildSvgDocument(''); + final a = d.annotations.single; + expect(a.lightbox, isTrue); + expect(a.href, 'pic.png'); + }); + + test('the annotation bbox respects the element transform', () { + final d = buildSvgDocument(''); + expect([d.annotations.single.x, d.annotations.single.y], [5, 5]); + }); + + test('the annotation bbox respects an inherited group transform', () { + final d = buildSvgDocument(''); + final a = d.annotations.single; + expect([a.x, a.y, a.width, a.height], [100, 0, 10, 10]); + }); + + test('elements without data-* produce no annotations', () { + expect(buildSvgDocument('').annotations, isEmpty); + }); + + test('a group with data-label is skipped — annotations anchor leaf shapes', () { + expect(buildSvgDocument('').annotations, isEmpty); + }); }); }