feat(svg): extract per-object data-* annotations for the drawing-card overlay (T-318)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 22:31:04 +02:00
co-authored by Claude Opus 4.8
parent 75f378ff20
commit 78b0a7b8a4
5 changed files with 194 additions and 8 deletions
+19
View File
@@ -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;
+11
View File
@@ -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);
+99 -6
View File
@@ -28,13 +28,17 @@ SvgDocument buildSvgDocument(String src) {
final markers = <String, SvgMarker>{};
_collectMarkers(root, markers);
final annotations = <SvgAnnotation>[];
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<SvgNode> _children(XmlElement el, SvgStyle inherited) {
List<SvgNode> _children(XmlElement el, SvgStyle inherited, Affine accumulated, List<SvgAnnotation> annotations) {
final out = <SvgNode>[];
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<SvgAnnotation> 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<SvgAnnotation> 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<SvgAnnotation> 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<double>? _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<double> _pathPoints(List<SvgPathSeg> segs) {
final pts = <double>[];
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<double>? _aabbOfPoints(List<double> 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<double> _transformedAABB(List<double> 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<String, String> a, SvgStyle inh) {
int? color(String k, int? fb) {
final v = a[k];
+30 -2
View File
@@ -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 {
/// `<marker>` definitions by id, referenced by path `marker-*`.
final Map<String, SvgMarker> markers;
/// Overlay captions/lightbox anchors extracted from `data-*` attributes.
final List<SvgAnnotation> annotations;
}
+35
View File
@@ -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('<svg viewBox="0 0 100 100"><rect x="10" y="20" width="30" height="40" data-label="Box" data-description="a box"/></svg>');
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('<svg><image x="0" y="0" width="8" height="8" href="pic.png" data-lightbox=""/></svg>');
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('<svg><rect x="0" y="0" width="10" height="10" transform="translate(5,5)" data-label="x"/></svg>');
expect([d.annotations.single.x, d.annotations.single.y], [5, 5]);
});
test('the annotation bbox respects an inherited group transform', () {
final d = buildSvgDocument('<svg><g transform="translate(100,0)"><rect x="0" y="0" width="10" height="10" data-label="x"/></g></svg>');
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('<svg><rect width="10" height="10"/></svg>').annotations, isEmpty);
});
test('a group with data-label is skipped — annotations anchor leaf shapes', () {
expect(buildSvgDocument('<svg><g data-label="grp"><rect width="10" height="10"/></g></svg>').annotations, isEmpty);
});
});
}