sketchmark 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -225,7 +225,7 @@ function tokenize$1(src) {
225
225
  }
226
226
 
227
227
  // ============================================================
228
- // sketchmark Parser (Tokens DiagramAST)
228
+ // sketchmark - Parser (Tokens -> DiagramAST)
229
229
  // ============================================================
230
230
  let _uid = 0;
231
231
  function uid(prefix) {
@@ -284,15 +284,16 @@ function propsToStyle(p) {
284
284
  s.textAlign = p["text-align"];
285
285
  if (p.padding)
286
286
  s.padding = parseFloat(p.padding);
287
- if (p["vertical-align"])
287
+ if (p["vertical-align"]) {
288
288
  s.verticalAlign = p["vertical-align"];
289
+ }
289
290
  if (p["line-height"])
290
291
  s.lineHeight = parseFloat(p["line-height"]);
291
292
  if (p["letter-spacing"])
292
293
  s.letterSpacing = parseFloat(p["letter-spacing"]);
293
294
  if (p.font)
294
295
  s.font = p.font;
295
- const dashVal = p["dash"] || p["stroke-dash"];
296
+ const dashVal = p.dash || p["stroke-dash"];
296
297
  if (dashVal) {
297
298
  const parts = dashVal
298
299
  .split(",")
@@ -303,10 +304,15 @@ function propsToStyle(p) {
303
304
  }
304
305
  return s;
305
306
  }
307
+ function isValueToken(t) {
308
+ return !!t && (t.type === "IDENT" || t.type === "STRING" || t.type === "KEYWORD");
309
+ }
310
+ function isPropKeyToken(t) {
311
+ return !!t && (t.type === "IDENT" || t.type === "KEYWORD");
312
+ }
306
313
  function parse(src) {
307
314
  resetUid();
308
315
  const tokens = tokenize$1(src).filter((t) => t.type !== "NEWLINE" || t.value === "\n");
309
- // Collapse multiple consecutive NEWLINEs into one
310
316
  const flat = [];
311
317
  let lastNL = false;
312
318
  for (const t of tokens) {
@@ -335,11 +341,9 @@ function parse(src) {
335
341
  config: {},
336
342
  rootOrder: [],
337
343
  };
338
- const nodeIds = new Set();
339
- const tableIds = new Set();
340
- const chartIds = new Set();
341
- const groupIds = new Set();
342
- const markdownIds = new Set();
344
+ const authoredEntityKinds = new Map();
345
+ const unresolvedGroupItems = new Map();
346
+ const groupTokens = new Map();
343
347
  let i = 0;
344
348
  const cur = () => flat[i] ?? flat[flat.length - 1];
345
349
  const peek1 = () => flat[i + 1] ?? flat[flat.length - 1];
@@ -348,7 +352,6 @@ function parse(src) {
348
352
  while (cur().type === "NEWLINE")
349
353
  skip();
350
354
  };
351
- // Consume until EOL, return all tokens
352
355
  function lineTokens() {
353
356
  const acc = [];
354
357
  while (cur().type !== "NEWLINE" && cur().type !== "EOF") {
@@ -359,24 +362,104 @@ function parse(src) {
359
362
  skip();
360
363
  return acc;
361
364
  }
365
+ function requireExplicitId(keywordTok, toks) {
366
+ const first = toks[0];
367
+ if (!isValueToken(first) || toks[1]?.type === "EQUALS") {
368
+ throw new ParseError(`${keywordTok.value} requires an explicit id before properties`, keywordTok.line, keywordTok.col);
369
+ }
370
+ return first.value;
371
+ }
372
+ function parseSimpleProps(toks, startIndex) {
373
+ const props = {};
374
+ let j = startIndex;
375
+ while (j < toks.length - 1) {
376
+ const key = toks[j];
377
+ const eq = toks[j + 1];
378
+ if (isPropKeyToken(key) && eq?.type === "EQUALS" && j + 2 < toks.length) {
379
+ props[key.value] = toks[j + 2].value;
380
+ j += 3;
381
+ }
382
+ else {
383
+ j++;
384
+ }
385
+ }
386
+ return props;
387
+ }
388
+ function parseGroupProps(toks, startIndex) {
389
+ const props = {};
390
+ const itemIds = [];
391
+ let j = startIndex;
392
+ while (j < toks.length) {
393
+ const key = toks[j];
394
+ const eq = toks[j + 1];
395
+ if (!isPropKeyToken(key) || eq?.type !== "EQUALS") {
396
+ j++;
397
+ continue;
398
+ }
399
+ if (key.value === "items") {
400
+ const open = toks[j + 2];
401
+ if (open?.type !== "LBRACKET") {
402
+ throw new ParseError(`items must use bracket syntax like items=[a,b]`, key.line, key.col);
403
+ }
404
+ j += 3;
405
+ while (j < toks.length && toks[j].type !== "RBRACKET") {
406
+ const tok = toks[j];
407
+ if (tok.type === "COMMA") {
408
+ j++;
409
+ continue;
410
+ }
411
+ if (!isValueToken(tok)) {
412
+ const invalidTok = toks[j];
413
+ throw new ParseError(`items can only contain ids like items=[a,b]`, invalidTok.line, invalidTok.col);
414
+ }
415
+ itemIds.push(tok.value);
416
+ j++;
417
+ if (toks[j]?.type === "COMMA") {
418
+ j++;
419
+ }
420
+ else if (toks[j] && toks[j].type !== "RBRACKET") {
421
+ throw new ParseError(`Expected ',' or ']' in items list`, toks[j].line, toks[j].col);
422
+ }
423
+ }
424
+ if (toks[j]?.type !== "RBRACKET") {
425
+ throw new ParseError(`Unterminated items list; expected ']'`, key.line, key.col);
426
+ }
427
+ j++;
428
+ continue;
429
+ }
430
+ if (j + 2 < toks.length) {
431
+ props[key.value] = toks[j + 2].value;
432
+ j += 3;
433
+ }
434
+ else {
435
+ j++;
436
+ }
437
+ }
438
+ return { props, itemIds };
439
+ }
440
+ function registerAuthoredId(id, kind, tok) {
441
+ const existing = authoredEntityKinds.get(id);
442
+ if (existing) {
443
+ throw new ParseError(`Duplicate id "${id}" already declared as a ${existing}`, tok.line, tok.col);
444
+ }
445
+ authoredEntityKinds.set(id, kind);
446
+ }
362
447
  function parseDataArray() {
363
448
  const rows = [];
364
449
  while (cur().type !== "LBRACKET" && cur().type !== "EOF")
365
450
  skip();
366
- skip(); // outer [
451
+ skip();
367
452
  skipNL();
368
453
  while (cur().type !== "RBRACKET" && cur().type !== "EOF") {
369
454
  skipNL();
370
455
  if (cur().type === "RBRACKET" || cur().type === "EOF")
371
- break; // ← ADD THIS LINE
456
+ break;
372
457
  if (cur().type === "LBRACKET") {
373
458
  skip();
374
459
  const row = [];
375
460
  while (cur().type !== "RBRACKET" && cur().type !== "EOF") {
376
461
  const v = cur();
377
- if (v.type === "STRING" ||
378
- v.type === "IDENT" ||
379
- v.type === "KEYWORD") {
462
+ if (v.type === "STRING" || v.type === "IDENT" || v.type === "KEYWORD") {
380
463
  row.push(v.value);
381
464
  skip();
382
465
  }
@@ -387,8 +470,9 @@ function parse(src) {
387
470
  else if (v.type === "COMMA" || v.type === "NEWLINE") {
388
471
  skip();
389
472
  }
390
- else
473
+ else {
391
474
  break;
475
+ }
392
476
  }
393
477
  if (cur().type === "RBRACKET")
394
478
  skip();
@@ -397,46 +481,25 @@ function parse(src) {
397
481
  else if (cur().type === "COMMA" || cur().type === "NEWLINE") {
398
482
  skip();
399
483
  }
400
- else
484
+ else {
401
485
  skip();
486
+ }
402
487
  }
403
488
  if (cur().type === "RBRACKET")
404
489
  skip();
405
490
  return rows;
406
491
  }
407
- function parseNode(shape, groupId) {
408
- skip(); // shape keyword
492
+ function parseNode(shape) {
493
+ const keywordTok = cur();
494
+ skip();
409
495
  const toks = lineTokens();
410
- let id = groupId ? groupId + "_" + uid(shape) : uid(shape);
411
- const props = {};
412
- let j = 0;
413
- // First token may be the node id
414
- if (j < toks.length &&
415
- (toks[j].type === "IDENT" || toks[j].type === "STRING")) {
416
- id = toks[j++].value;
417
- }
418
- // Remaining tokens are key=value pairs
419
- while (j < toks.length) {
420
- const t = toks[j];
421
- if ((t.type === "IDENT" || t.type === "KEYWORD") &&
422
- j + 1 < toks.length &&
423
- toks[j + 1].type === "EQUALS") {
424
- const key = t.value;
425
- j += 2;
426
- if (j < toks.length) {
427
- props[key] = toks[j].value;
428
- j++;
429
- }
430
- }
431
- else
432
- j++;
433
- }
496
+ const id = requireExplicitId(keywordTok, toks);
497
+ const props = parseSimpleProps(toks, 1);
434
498
  const node = {
435
499
  kind: "node",
436
500
  id,
437
501
  shape,
438
502
  label: props.label || "",
439
- ...(groupId ? { groupId } : {}),
440
503
  ...(props.width ? { width: parseFloat(props.width) } : {}),
441
504
  ...(props.height ? { height: parseFloat(props.height) } : {}),
442
505
  ...(props.deg ? { deg: parseFloat(props.deg) } : {}),
@@ -454,113 +517,56 @@ function parse(src) {
454
517
  node.pathData = props.value;
455
518
  return node;
456
519
  }
457
- function parseEdge(fromId, connector, rest) {
458
- const toTok = rest.shift();
459
- if (!toTok)
460
- throw new ParseError("Expected edge target", 0, 0);
461
- const toId = toTok.value;
462
- const props = {};
463
- let j = 0;
464
- while (j < rest.length) {
465
- const t = rest[j];
466
- if ((t.type === "IDENT" || t.type === "KEYWORD") &&
467
- j + 1 < rest.length &&
468
- rest[j + 1].type === "EQUALS") {
469
- const key = t.value;
470
- j += 2;
471
- if (j < rest.length) {
472
- props[key] = rest[j].value;
473
- j++;
474
- }
475
- }
476
- else
477
- j++;
478
- }
479
- const dashed = connector.includes("--") ||
480
- connector.includes(".-") ||
481
- connector.includes("-.");
482
- const bidir = connector.includes("<") && connector.includes(">");
483
- return {
484
- kind: "edge",
485
- id: uid("edge"),
486
- from: fromId,
487
- to: toId,
488
- connector: connector,
489
- label: props.label,
490
- dashed,
491
- bidirectional: bidir,
492
- style: propsToStyle(props),
493
- };
494
- }
495
- // ── parseNote → returns ASTNode with shape='note' ────────
496
- function parseNote(groupId) {
497
- skip(); // 'note'
520
+ function parseNote() {
521
+ const keywordTok = cur();
522
+ skip();
498
523
  const toks = lineTokens();
499
- let id = groupId ? groupId + "_" + uid("note") : uid("note");
500
- if (toks[0])
501
- id = toks[0].value;
524
+ const id = requireExplicitId(keywordTok, toks);
502
525
  const props = {};
503
526
  let j = 1;
504
- // Backward compat: second token is a bare/quoted string → label
505
527
  if (toks[1] &&
506
528
  (toks[1].type === "STRING" ||
507
529
  (toks[1].type === "IDENT" && toks[2]?.type !== "EQUALS"))) {
508
530
  props.label = toks[1].value;
509
531
  j = 2;
510
532
  }
511
- // Parse remaining key=value props
512
- while (j < toks.length - 1) {
513
- const k = toks[j];
514
- const eq = toks[j + 1];
515
- if (eq && eq.type === "EQUALS" && j + 2 < toks.length) {
516
- props[k.value] = toks[j + 2].value;
517
- j += 3;
518
- }
519
- else
520
- j++;
521
- }
522
- // Support multiline via literal \n in label string
523
- const rawLabel = props.label ?? "";
533
+ Object.assign(props, parseSimpleProps(toks, j));
524
534
  return {
525
535
  kind: "node",
526
536
  id,
527
537
  shape: "note",
528
- label: rawLabel.replace(/\\n/g, "\n"),
529
- groupId,
538
+ label: (props.label ?? "").replace(/\\n/g, "\n"),
530
539
  theme: props.theme,
531
540
  style: propsToStyle(props),
532
541
  ...(props.width ? { width: parseFloat(props.width) } : {}),
533
542
  ...(props.height ? { height: parseFloat(props.height) } : {}),
534
543
  };
535
544
  }
536
- // ── parseGroup ───────────────────────────────────────────
537
- function parseGroup(parentGroupId) {
538
- skip(); // 'group'
545
+ function parseGroup() {
546
+ const keywordTok = cur();
547
+ skip();
539
548
  const toks = lineTokens();
540
- let id = uid("group");
541
- if (toks[0])
542
- id = toks[0].value;
549
+ if (toks.some((t) => t.type === "LBRACE" || t.type === "RBRACE")) {
550
+ throw new ParseError(`Nested group blocks were removed. Use ${keywordTok.value} <id> items=[...] instead.`, keywordTok.line, keywordTok.col);
551
+ }
552
+ const id = requireExplicitId(keywordTok, toks);
543
553
  const props = {};
544
554
  let j = 1;
545
- // Backward compat: second token is a quoted/bare string (old label syntax)
546
555
  if (toks[1] &&
547
556
  (toks[1].type === "STRING" ||
548
557
  (toks[1].type === "IDENT" && toks[2]?.type !== "EQUALS"))) {
549
558
  props.label = toks[1].value;
550
559
  j = 2;
551
560
  }
552
- // Parse remaining key=value props
553
- while (j < toks.length - 1) {
554
- const k = toks[j];
555
- const eq = toks[j + 1];
556
- if (eq && eq.type === "EQUALS" && j + 2 < toks.length) {
557
- props[k.value] = toks[j + 2].value;
558
- j += 3;
559
- }
560
- else
561
- j++;
561
+ const parsed = parseGroupProps(toks, j);
562
+ Object.assign(props, parsed.props);
563
+ skipNL();
564
+ if (cur().type === "LBRACE") {
565
+ throw new ParseError(`Nested group blocks were removed. Use ${keywordTok.value} ${id} items=[...] instead.`, cur().line, cur().col);
562
566
  }
563
- const group = {
567
+ unresolvedGroupItems.set(id, parsed.itemIds);
568
+ groupTokens.set(id, keywordTok);
569
+ return {
564
570
  kind: "group",
565
571
  id,
566
572
  label: props.label ?? "",
@@ -573,109 +579,43 @@ function parse(src) {
573
579
  justify: props.justify,
574
580
  theme: props.theme,
575
581
  style: propsToStyle(props),
576
- width: props.width !== undefined ? parseFloat(props.width) : undefined, // ← add
582
+ width: props.width !== undefined ? parseFloat(props.width) : undefined,
577
583
  height: props.height !== undefined ? parseFloat(props.height) : undefined,
578
584
  };
579
- skipNL();
580
- if (cur().type === "LBRACE") {
581
- skip();
582
- skipNL();
583
- }
584
- while (cur().type !== "RBRACE" &&
585
- cur().value !== "end" &&
586
- cur().type !== "EOF") {
587
- skipNL();
588
- if (cur().type === "RBRACE")
589
- break;
590
- const v = cur().value;
591
- // ── Nested group ──────────────────────────────────
592
- if (v === "group" || v === "bare") {
593
- const isBare = v === "bare";
594
- const nested = parseGroup();
595
- if (isBare) {
596
- nested.label = "";
597
- nested.style = {
598
- ...nested.style,
599
- fill: nested.style?.fill ?? "none",
600
- stroke: nested.style?.stroke ?? "none",
601
- strokeWidth: nested.style?.strokeWidth ?? 0,
602
- };
603
- }
604
- ast.groups.push(nested);
605
- groupIds.add(nested.id);
606
- group.children.push({ kind: "group", id: nested.id });
607
- continue;
608
- }
609
- // ── Table ─────────────────────────────────────────
610
- if (v === "table") {
611
- const tbl = parseTable();
612
- ast.tables.push(tbl);
613
- tableIds.add(tbl.id);
614
- group.children.push({ kind: "table", id: tbl.id });
615
- continue;
616
- }
617
- // ── Note (parsed as node with shape='note') ──────
618
- if (v === "note") {
619
- const note = parseNote(id);
620
- ast.nodes.push(note);
621
- nodeIds.add(note.id);
622
- group.children.push({ kind: "node", id: note.id });
623
- continue;
624
- }
625
- // ── Markdown ───────────────────────────────────────
626
- if (v === "markdown") {
627
- const md = parseMarkdown(id);
628
- ast.markdowns.push(md);
629
- markdownIds.add(md.id);
630
- group.children.push({ kind: "markdown", id: md.id });
631
- continue;
632
- }
633
- if (v === "bare") {
634
- // treat exactly like 'group' but inject defaults
635
- const grp = parseGroup(); // reuse parseGroup
636
- grp.label = "";
637
- grp.style = { ...grp.style, stroke: "none", fill: "none" };
638
- // rest is identical to group handling
639
- }
640
- // ── Chart ──────────────────────────────────────────
641
- if (CHART_TYPES.includes(v)) {
642
- const chart = parseChart(v);
643
- ast.charts.push(chart);
644
- chartIds.add(chart.id);
645
- group.children.push({ kind: "chart", id: chart.id });
646
- continue;
647
- }
648
- // ── Node shape ────────────────────────────────────
649
- if (SHAPES$1.includes(v)) {
650
- const node = parseNode(v, id);
651
- if (!nodeIds.has(node.id)) {
652
- nodeIds.add(node.id);
653
- ast.nodes.push(node);
654
- }
655
- group.children.push({ kind: "node", id: node.id });
656
- continue;
585
+ }
586
+ function parseEdge(fromId, connector, rest) {
587
+ const toTok = rest.shift();
588
+ if (!toTok)
589
+ throw new ParseError("Expected edge target", 0, 0);
590
+ const props = {};
591
+ let j = 0;
592
+ while (j < rest.length) {
593
+ const t = rest[j];
594
+ if ((t.type === "IDENT" || t.type === "KEYWORD") &&
595
+ j + 1 < rest.length &&
596
+ rest[j + 1].type === "EQUALS") {
597
+ props[t.value] = rest[j + 2]?.value ?? "";
598
+ j += 3;
657
599
  }
658
- // ── Edge inside group ─────────────────────────────
659
- if (cur().type === "IDENT" ||
660
- cur().type === "STRING" ||
661
- cur().type === "KEYWORD") {
662
- const nextTok = flat[i + 1];
663
- if (nextTok && nextTok.type === "ARROW") {
664
- const lineToks = lineTokens();
665
- if (lineToks.length >= 3 && lineToks[1].type === "ARROW") {
666
- const fromId = lineToks[0].value;
667
- const conn = lineToks[1].value;
668
- const edge = parseEdge(fromId, conn, lineToks.slice(2));
669
- ast.edges.push(edge);
670
- }
671
- continue;
672
- }
600
+ else {
601
+ j++;
673
602
  }
674
- skip();
675
603
  }
676
- if (cur().type === "RBRACE")
677
- skip();
678
- return group;
604
+ const dashed = connector.includes("--") ||
605
+ connector.includes(".-") ||
606
+ connector.includes("-.");
607
+ const bidirectional = connector.includes("<") && connector.includes(">");
608
+ return {
609
+ kind: "edge",
610
+ id: uid("edge"),
611
+ from: fromId,
612
+ to: toTok.value,
613
+ connector: connector,
614
+ label: props.label,
615
+ dashed,
616
+ bidirectional,
617
+ style: propsToStyle(props),
618
+ };
679
619
  }
680
620
  function parseStep() {
681
621
  skip();
@@ -686,12 +626,10 @@ function parse(src) {
686
626
  target = `${toks[1].value}${toks[2].value}${toks[3].value}`;
687
627
  }
688
628
  const step = { kind: "step", action, target };
689
- // narrate: text is the value, not a target
690
629
  if (action === "narrate") {
691
630
  step.target = "";
692
631
  step.value = toks[1]?.value ?? "";
693
632
  }
694
- // bracket: needs two targets
695
633
  if (action === "bracket" && toks.length >= 3) {
696
634
  step.target = toks[1]?.value ?? "";
697
635
  step.target2 = toks[2]?.value ?? "";
@@ -701,7 +639,6 @@ function parse(src) {
701
639
  const k = toks[j]?.value;
702
640
  const eq = toks[j + 1];
703
641
  const vt = toks[j + 2];
704
- // key=value form
705
642
  if (eq?.type === "EQUALS" && vt) {
706
643
  if (k === "dx") {
707
644
  step.dx = parseFloat(vt.value);
@@ -733,12 +670,7 @@ function parse(src) {
733
670
  j += 2;
734
671
  continue;
735
672
  }
736
- if (k === "fill") {
737
- step.value = vt.value;
738
- j += 2;
739
- continue;
740
- }
741
- if (k === "color") {
673
+ if (k === "fill" || k === "color") {
742
674
  step.value = vt.value;
743
675
  j += 2;
744
676
  continue;
@@ -749,7 +681,6 @@ function parse(src) {
749
681
  continue;
750
682
  }
751
683
  }
752
- // bare key value (legacy)
753
684
  if (k === "delay" && eq?.type === "NUMBER") {
754
685
  step.delay = parseFloat(eq.value);
755
686
  j++;
@@ -763,89 +694,16 @@ function parse(src) {
763
694
  if (k === "trigger") {
764
695
  step.trigger = eq?.value;
765
696
  j++;
766
- continue;
767
697
  }
768
698
  }
769
699
  return step;
770
700
  }
771
- // function parseStep(): ASTStep {
772
- // skip(); // 'step'
773
- // const toks = lineTokens();
774
- // const action = (toks[0]?.value ?? "highlight") as AnimationAction;
775
- // let target = toks[1]?.value ?? "";
776
- // if (toks[2]?.type === "ARROW" && toks[3]) {
777
- // target = `${toks[1].value}${toks[2].value}${toks[3].value}`;
778
- // }
779
- // const step: ASTStep = { kind: "step", action, target };
780
- // for (let j = 2; j < toks.length - 1; j++) {
781
- // const k = toks[j].value;
782
- // const eq = toks[j + 1];
783
- // const vt = toks[j + 2];
784
- // // key=value form (dx=50, dy=-80, duration=600)
785
- // if (eq?.type === "EQUALS" && vt) {
786
- // if (k === "dx") {
787
- // step.dx = parseFloat(vt.value);
788
- // j += 2;
789
- // continue;
790
- // }
791
- // if (k === "dy") {
792
- // step.dy = parseFloat(vt.value);
793
- // j += 2;
794
- // continue;
795
- // }
796
- // if (k === "duration") {
797
- // step.duration = parseFloat(vt.value);
798
- // j += 2;
799
- // continue;
800
- // }
801
- // if (k === "delay") {
802
- // step.delay = parseFloat(vt.value);
803
- // j += 2;
804
- // continue;
805
- // }
806
- // }
807
- // // bare key value form (legacy: delay 500, duration 400)
808
- // if (k === "delay" && eq?.type === "NUMBER") {
809
- // step.delay = parseFloat(eq.value);
810
- // j++;
811
- // }
812
- // if (k === "duration" && eq?.type === "NUMBER") {
813
- // step.duration = parseFloat(eq.value);
814
- // j++;
815
- // }
816
- // if (k === "trigger") {
817
- // step.trigger = eq?.value as AnimationTrigger;
818
- // j++;
819
- // }
820
- // if (k === "factor") {
821
- // step.factor = parseFloat(vt.value);
822
- // j += 2;
823
- // continue;
824
- // }
825
- // if (k === "deg") {
826
- // step.deg = parseFloat(vt.value);
827
- // j += 2;
828
- // continue;
829
- // }
830
- // }
831
- // return step;
832
- // }
833
701
  function parseChart(chartType) {
702
+ const keywordTok = cur();
834
703
  skip();
835
704
  const toks = lineTokens();
836
- const id = toks[0]?.value ?? uid("chart");
837
- const props = {};
838
- let j = 1;
839
- while (j < toks.length - 1) {
840
- const k = toks[j];
841
- const eq = toks[j + 1];
842
- if (eq?.type === "EQUALS" && j + 2 < toks.length) {
843
- props[k.value] = toks[j + 2].value;
844
- j += 3;
845
- }
846
- else
847
- j++;
848
- }
705
+ const id = requireExplicitId(keywordTok, toks);
706
+ const props = parseSimpleProps(toks, 1);
849
707
  let dataRows = [];
850
708
  skipNL();
851
709
  while (cur().type !== "EOF" && cur().value !== "end") {
@@ -858,30 +716,31 @@ function parse(src) {
858
716
  }
859
717
  else if ((cur().type === "IDENT" || cur().type === "KEYWORD") &&
860
718
  peek1().type === "EQUALS") {
861
- const k = cur().value;
719
+ const key = cur().value;
862
720
  skip();
863
721
  skip();
864
- props[k] = cur().value;
722
+ props[key] = cur().value;
865
723
  skip();
866
724
  }
867
725
  else if (SHAPES$1.includes(v) ||
868
726
  v === "step" ||
869
727
  v === "group" ||
870
- v === "note" || // ← ADD
728
+ v === "bare" ||
729
+ v === "note" ||
871
730
  v === "table" ||
872
- v === "config" || // ← ADD
873
- v === "theme" || // ← ADD
731
+ v === "config" ||
732
+ v === "theme" ||
874
733
  v === "style" ||
875
734
  v === "markdown" ||
876
735
  CHART_TYPES.includes(v)) {
877
736
  break;
878
737
  }
879
738
  else if (peek1().type === "ARROW") {
880
- // ← ADD THIS WHOLE BLOCK
881
739
  break;
882
740
  }
883
- else
741
+ else {
884
742
  skip();
743
+ }
885
744
  }
886
745
  const headers = dataRows[0]?.map(String) ?? [];
887
746
  const rows = dataRows.slice(1);
@@ -898,29 +757,19 @@ function parse(src) {
898
757
  };
899
758
  }
900
759
  function parseTable() {
901
- skip(); // 'table'
760
+ const keywordTok = cur();
761
+ skip();
902
762
  const toks = lineTokens();
903
- let id = uid("table");
904
- if (toks[0])
905
- id = toks[0].value;
763
+ const id = requireExplicitId(keywordTok, toks);
906
764
  const props = {};
907
765
  let j = 1;
908
- // label="..." or bare second token
909
766
  if (toks[1] &&
910
767
  (toks[1].type === "STRING" ||
911
768
  (toks[1].type === "IDENT" && toks[2]?.type !== "EQUALS"))) {
912
769
  props.label = toks[1].value;
913
770
  j = 2;
914
771
  }
915
- while (j < toks.length - 1) {
916
- const k = toks[j], eq = toks[j + 1];
917
- if (eq?.type === "EQUALS" && j + 2 < toks.length) {
918
- props[k.value] = toks[j + 2].value;
919
- j += 3;
920
- }
921
- else
922
- j++;
923
- }
772
+ Object.assign(props, parseSimpleProps(toks, j));
924
773
  const table = {
925
774
  kind: "table",
926
775
  id,
@@ -947,7 +796,8 @@ function parse(src) {
947
796
  while (cur().type !== "NEWLINE" && cur().type !== "EOF") {
948
797
  if (cur().type === "STRING" ||
949
798
  cur().type === "IDENT" ||
950
- cur().type === "NUMBER") {
799
+ cur().type === "NUMBER" ||
800
+ cur().type === "KEYWORD") {
951
801
  cells.push(cur().value);
952
802
  }
953
803
  skip();
@@ -956,31 +806,20 @@ function parse(src) {
956
806
  skip();
957
807
  table.rows.push({ kind: v === "header" ? "header" : "data", cells });
958
808
  }
959
- else
809
+ else {
960
810
  skip();
811
+ }
961
812
  }
962
813
  if (cur().type === "RBRACE")
963
814
  skip();
964
815
  return table;
965
816
  }
966
- // ── parseMarkdown ─────────────────────────────────────────
967
- function parseMarkdown(groupId) {
968
- skip(); // 'markdown'
817
+ function parseMarkdown() {
818
+ const keywordTok = cur();
819
+ skip();
969
820
  const toks = lineTokens();
970
- let id = groupId ? groupId + "_" + uid("md") : uid("md");
971
- if (toks[0])
972
- id = toks[0].value;
973
- const props = {};
974
- let j = 1;
975
- while (j < toks.length - 1) {
976
- const k = toks[j], eq = toks[j + 1];
977
- if (eq?.type === "EQUALS" && j + 2 < toks.length) {
978
- props[k.value] = toks[j + 2].value;
979
- j += 3;
980
- }
981
- else
982
- j++;
983
- }
821
+ const id = requireExplicitId(keywordTok, toks);
822
+ const props = parseSimpleProps(toks, 1);
984
823
  skipNL();
985
824
  let content = "";
986
825
  if (cur().type === "STRING_BLOCK") {
@@ -997,7 +836,6 @@ function parse(src) {
997
836
  style: propsToStyle(props),
998
837
  };
999
838
  }
1000
- // ── Main parse loop ─────────────────────────────────────
1001
839
  skipNL();
1002
840
  if (cur().value === "diagram")
1003
841
  skip();
@@ -1016,19 +854,16 @@ function parse(src) {
1016
854
  }
1017
855
  if (v === "end")
1018
856
  break;
1019
- // direction — silently ignored (removed from engine)
1020
857
  if (v === "direction") {
1021
858
  lineTokens();
1022
859
  continue;
1023
860
  }
1024
- // layout
1025
861
  if (v === "layout") {
1026
862
  skip();
1027
863
  ast.layout = cur().value ?? "column";
1028
864
  skip();
1029
865
  continue;
1030
866
  }
1031
- // title
1032
867
  if (v === "title") {
1033
868
  skip();
1034
869
  const toks = lineTokens();
@@ -1038,14 +873,10 @@ function parse(src) {
1038
873
  ast.title = toks[idx + 2]?.value ?? "";
1039
874
  }
1040
875
  else {
1041
- ast.title = toks
1042
- .map((t2) => t2.value)
1043
- .join(" ")
1044
- .replace(/"/g, "");
876
+ ast.title = toks.map((t2) => t2.value).join(" ").replace(/"/g, "");
1045
877
  }
1046
878
  continue;
1047
879
  }
1048
- // description
1049
880
  if (v === "description") {
1050
881
  skip();
1051
882
  ast.description = lineTokens()
@@ -1054,65 +885,38 @@ function parse(src) {
1054
885
  .replace(/"/g, "");
1055
886
  continue;
1056
887
  }
1057
- // config
1058
888
  if (v === "config") {
1059
889
  skip();
1060
- const k = cur().value;
890
+ const key = cur().value;
1061
891
  skip();
1062
892
  if (cur().type === "EQUALS")
1063
893
  skip();
1064
- const cv = cur().value;
894
+ const value = cur().value;
1065
895
  skip();
1066
- ast.config[k] = cv;
896
+ ast.config[key] = value;
1067
897
  continue;
1068
898
  }
1069
- // style
1070
899
  if (v === "style") {
1071
900
  skip();
1072
901
  const targetId = cur().value;
1073
902
  skip();
1074
- const lineToks = lineTokens();
1075
- const p = {};
1076
- let j = 0;
1077
- while (j < lineToks.length - 1) {
1078
- const k2 = lineToks[j];
1079
- const eq = lineToks[j + 1];
1080
- if (eq.type === "EQUALS") {
1081
- p[k2.value] = lineToks[j + 2]?.value ?? "";
1082
- j += 3;
1083
- }
1084
- else
1085
- j++;
1086
- }
1087
- ast.styles[targetId] = { ...ast.styles[targetId], ...propsToStyle(p) };
903
+ const props = parseSimpleProps(lineTokens(), 0);
904
+ ast.styles[targetId] = { ...ast.styles[targetId], ...propsToStyle(props) };
1088
905
  continue;
1089
906
  }
1090
- // theme
1091
907
  if (v === "theme") {
1092
908
  skip();
1093
909
  const toks = lineTokens();
1094
910
  const themeId = toks[0]?.value;
1095
911
  if (!themeId)
1096
912
  continue;
1097
- const props = {};
1098
- let j = 1;
1099
- while (j < toks.length - 1) {
1100
- const k2 = toks[j];
1101
- const eq = toks[j + 1];
1102
- if (eq && eq.type === "EQUALS" && j + 2 < toks.length) {
1103
- props[k2.value] = toks[j + 2].value;
1104
- j += 3;
1105
- }
1106
- else
1107
- j++;
1108
- }
1109
- ast.themes[themeId] = propsToStyle(props);
913
+ ast.themes[themeId] = propsToStyle(parseSimpleProps(toks, 1));
1110
914
  continue;
1111
915
  }
1112
- // group
1113
916
  if (v === "group" || v === "bare") {
1114
917
  const isBare = v === "bare";
1115
918
  const grp = parseGroup();
919
+ registerAuthoredId(grp.id, "group", t);
1116
920
  if (isBare) {
1117
921
  grp.label = "";
1118
922
  grp.style = {
@@ -1123,36 +927,34 @@ function parse(src) {
1123
927
  };
1124
928
  }
1125
929
  ast.groups.push(grp);
1126
- groupIds.add(grp.id);
1127
930
  ast.rootOrder.push({ kind: "group", id: grp.id });
1128
931
  continue;
1129
932
  }
1130
- // table
1131
933
  if (v === "table") {
1132
934
  const tbl = parseTable();
935
+ registerAuthoredId(tbl.id, "table", t);
1133
936
  ast.tables.push(tbl);
1134
- tableIds.add(tbl.id);
1135
937
  ast.rootOrder.push({ kind: "table", id: tbl.id });
1136
938
  continue;
1137
939
  }
1138
- // note (parsed as node with shape='note')
1139
940
  if (v === "note") {
1140
941
  const note = parseNote();
942
+ registerAuthoredId(note.id, "node", t);
1141
943
  ast.nodes.push(note);
1142
- nodeIds.add(note.id);
1143
944
  ast.rootOrder.push({ kind: "node", id: note.id });
1144
945
  continue;
1145
946
  }
1146
- // beat { ... } — parallel steps
1147
947
  if (v === "beat") {
1148
- skip(); // 'beat'
948
+ skip();
1149
949
  skipNL();
1150
950
  if (cur().type === "LBRACE") {
1151
951
  skip();
1152
952
  skipNL();
1153
953
  }
1154
954
  const children = [];
1155
- while (cur().type !== "RBRACE" && cur().value !== "end" && cur().type !== "EOF") {
955
+ while (cur().type !== "RBRACE" &&
956
+ cur().value !== "end" &&
957
+ cur().type !== "EOF") {
1156
958
  skipNL();
1157
959
  if (cur().type === "RBRACE")
1158
960
  break;
@@ -1168,69 +970,120 @@ function parse(src) {
1168
970
  ast.steps.push({ kind: "beat", children });
1169
971
  continue;
1170
972
  }
1171
- // step
1172
973
  if (v === "step") {
1173
974
  ast.steps.push(parseStep());
1174
975
  continue;
1175
976
  }
1176
- // charts
1177
977
  if (CHART_TYPES.includes(v)) {
1178
978
  const chart = parseChart(v);
979
+ registerAuthoredId(chart.id, "chart", t);
1179
980
  ast.charts.push(chart);
1180
- chartIds.add(chart.id);
1181
- ast.rootOrder.push({ kind: "chart", id: chart.id }); // ← ADD
981
+ ast.rootOrder.push({ kind: "chart", id: chart.id });
1182
982
  continue;
1183
983
  }
1184
984
  if (v === "markdown") {
1185
985
  const md = parseMarkdown();
986
+ registerAuthoredId(md.id, "markdown", t);
1186
987
  ast.markdowns.push(md);
1187
- markdownIds.add(md.id);
1188
988
  ast.rootOrder.push({ kind: "markdown", id: md.id });
1189
989
  continue;
1190
990
  }
1191
- // edge: A -> B (MUST come before shape check)
1192
991
  if (t.type === "IDENT" || t.type === "STRING" || t.type === "KEYWORD") {
1193
992
  const nextTok = flat[i + 1];
1194
993
  if (nextTok && nextTok.type === "ARROW") {
1195
994
  const lineToks = lineTokens();
1196
995
  if (lineToks.length >= 3 && lineToks[1].type === "ARROW") {
1197
996
  const fromId = lineToks[0].value;
1198
- const conn = lineToks[1].value;
1199
- const edge = parseEdge(fromId, conn, lineToks.slice(2));
1200
- ast.edges.push(edge);
1201
- // Auto-create implied nodes if they don't exist yet
1202
- for (const nid of [fromId, edge.to]) {
1203
- if (!nodeIds.has(nid) &&
1204
- !tableIds.has(nid) &&
1205
- !chartIds.has(nid) &&
1206
- !groupIds.has(nid)) {
1207
- nodeIds.add(nid);
1208
- ast.nodes.push({
1209
- kind: "node",
1210
- id: nid,
1211
- shape: "box",
1212
- label: nid,
1213
- style: {},
1214
- });
1215
- }
1216
- }
997
+ const connector = lineToks[1].value;
998
+ ast.edges.push(parseEdge(fromId, connector, lineToks.slice(2)));
1217
999
  continue;
1218
1000
  }
1219
1001
  }
1220
1002
  }
1221
- // node shapes — only reached if NOT followed by an arrow
1222
1003
  if (SHAPES$1.includes(v)) {
1223
1004
  const node = parseNode(v);
1224
- if (!nodeIds.has(node.id)) {
1225
- nodeIds.add(node.id);
1226
- ast.nodes.push(node);
1227
- ast.rootOrder.push({ kind: "node", id: node.id });
1228
- }
1005
+ registerAuthoredId(node.id, "node", t);
1006
+ ast.nodes.push(node);
1007
+ ast.rootOrder.push({ kind: "node", id: node.id });
1229
1008
  continue;
1230
1009
  }
1231
1010
  skip();
1232
1011
  }
1233
- // Merge global styles into node styles
1012
+ const allKnownIds = new Set(authoredEntityKinds.keys());
1013
+ for (const edge of ast.edges) {
1014
+ for (const id of [edge.from, edge.to]) {
1015
+ if (allKnownIds.has(id))
1016
+ continue;
1017
+ allKnownIds.add(id);
1018
+ ast.nodes.push({
1019
+ kind: "node",
1020
+ id,
1021
+ shape: "box",
1022
+ label: id,
1023
+ style: {},
1024
+ });
1025
+ }
1026
+ }
1027
+ const entityKindById = new Map();
1028
+ ast.nodes.forEach((node) => entityKindById.set(node.id, "node"));
1029
+ ast.groups.forEach((group) => entityKindById.set(group.id, "group"));
1030
+ ast.tables.forEach((table) => entityKindById.set(table.id, "table"));
1031
+ ast.charts.forEach((chart) => entityKindById.set(chart.id, "chart"));
1032
+ ast.markdowns.forEach((md) => entityKindById.set(md.id, "markdown"));
1033
+ for (const group of ast.groups) {
1034
+ const itemIds = unresolvedGroupItems.get(group.id) ?? [];
1035
+ group.children = itemIds.map((itemId) => {
1036
+ if (itemId === group.id) {
1037
+ const tok = groupTokens.get(group.id) ?? cur();
1038
+ throw new ParseError(`Group "${group.id}" cannot include itself in items=[...]`, tok.line, tok.col);
1039
+ }
1040
+ const kind = entityKindById.get(itemId);
1041
+ if (!kind) {
1042
+ const tok = groupTokens.get(group.id) ?? cur();
1043
+ throw new ParseError(`Group "${group.id}" references unknown item "${itemId}" in items=[...]`, tok.line, tok.col);
1044
+ }
1045
+ return { kind, id: itemId };
1046
+ });
1047
+ }
1048
+ const parentByItemId = new Map();
1049
+ for (const group of ast.groups) {
1050
+ for (const child of group.children) {
1051
+ const existingParent = parentByItemId.get(child.id);
1052
+ if (existingParent) {
1053
+ const tok = groupTokens.get(group.id) ?? cur();
1054
+ throw new ParseError(`Item "${child.id}" cannot belong to both "${existingParent}" and "${group.id}"`, tok.line, tok.col);
1055
+ }
1056
+ parentByItemId.set(child.id, group.id);
1057
+ }
1058
+ }
1059
+ const groupsById = new Map(ast.groups.map((group) => [group.id, group]));
1060
+ const visiting = new Set();
1061
+ const visited = new Set();
1062
+ const stack = [];
1063
+ function visitGroup(groupId) {
1064
+ if (visiting.has(groupId)) {
1065
+ const start = stack.indexOf(groupId);
1066
+ const cycle = (start >= 0 ? stack.slice(start) : stack).concat(groupId);
1067
+ const tok = groupTokens.get(groupId) ?? cur();
1068
+ throw new ParseError(`Group cycle detected: ${cycle.join(" -> ")}`, tok.line, tok.col);
1069
+ }
1070
+ if (visited.has(groupId))
1071
+ return;
1072
+ visiting.add(groupId);
1073
+ stack.push(groupId);
1074
+ const group = groupsById.get(groupId);
1075
+ if (group) {
1076
+ for (const child of group.children) {
1077
+ if (child.kind === "group")
1078
+ visitGroup(child.id);
1079
+ }
1080
+ }
1081
+ stack.pop();
1082
+ visiting.delete(groupId);
1083
+ visited.add(groupId);
1084
+ }
1085
+ for (const group of ast.groups)
1086
+ visitGroup(group.id);
1234
1087
  for (const node of ast.nodes) {
1235
1088
  if (ast.styles[node.id]) {
1236
1089
  node.style = { ...ast.styles[node.id], ...node.style };
@@ -3565,6 +3418,16 @@ function calcMarkdownWidth(lines, fontFamily = DEFAULT_FONT, pad = MARKDOWN.defa
3565
3418
  // ============================================================
3566
3419
  // ── Build scene graph from AST ────────────────────────────
3567
3420
  function buildSceneGraph(ast) {
3421
+ const nodeParentById = new Map();
3422
+ const groupParentById = new Map();
3423
+ for (const g of ast.groups) {
3424
+ for (const child of g.children) {
3425
+ if (child.kind === "node")
3426
+ nodeParentById.set(child.id, g.id);
3427
+ if (child.kind === "group")
3428
+ groupParentById.set(child.id, g.id);
3429
+ }
3430
+ }
3568
3431
  const nodes = ast.nodes.map((n) => {
3569
3432
  const themeStyle = n.theme ? (ast.themes[n.theme] ?? {}) : {};
3570
3433
  return {
@@ -3572,7 +3435,7 @@ function buildSceneGraph(ast) {
3572
3435
  shape: n.shape,
3573
3436
  label: n.label,
3574
3437
  style: { ...ast.styles[n.id], ...themeStyle, ...n.style },
3575
- groupId: n.groupId,
3438
+ groupId: nodeParentById.get(n.id),
3576
3439
  width: n.width,
3577
3440
  height: n.height,
3578
3441
  deg: n.deg,
@@ -3594,7 +3457,7 @@ function buildSceneGraph(ast) {
3594
3457
  return {
3595
3458
  id: g.id,
3596
3459
  label: g.label,
3597
- parentId: undefined, // set below
3460
+ parentId: groupParentById.get(g.id),
3598
3461
  children: g.children,
3599
3462
  layout: (g.layout ?? "column"),
3600
3463
  columns: g.columns ?? 1,
@@ -3642,28 +3505,21 @@ function buildSceneGraph(ast) {
3642
3505
  h: c.height ?? CHART.defaultH,
3643
3506
  };
3644
3507
  });
3645
- const markdowns = (ast.markdowns ?? []).map(m => {
3508
+ const markdowns = (ast.markdowns ?? []).map((m) => {
3646
3509
  const themeStyle = m.theme ? (ast.themes[m.theme] ?? {}) : {};
3647
3510
  return {
3648
3511
  id: m.id,
3649
3512
  content: m.content,
3650
3513
  lines: parseMarkdownContent(m.content),
3651
- style: { ...themeStyle, ...m.style },
3514
+ style: { ...ast.styles[m.id], ...themeStyle, ...m.style },
3652
3515
  width: m.width,
3653
3516
  height: m.height,
3654
- x: 0, y: 0, w: 0, h: 0,
3517
+ x: 0,
3518
+ y: 0,
3519
+ w: 0,
3520
+ h: 0,
3655
3521
  };
3656
3522
  });
3657
- // Set parentId for nested groups
3658
- for (const g of groups) {
3659
- for (const child of g.children) {
3660
- if (child.kind === "group") {
3661
- const nested = groups.find((gg) => gg.id === child.id);
3662
- if (nested)
3663
- nested.parentId = g.id;
3664
- }
3665
- }
3666
- }
3667
3523
  const edges = ast.edges.map((e) => ({
3668
3524
  id: e.id,
3669
3525
  from: e.from,
@@ -4790,15 +4646,30 @@ function layout(sg) {
4790
4646
  // 4. Build root order
4791
4647
  // sg.rootOrder preserves DSL declaration order.
4792
4648
  // Fall back: groups, then nodes, then tables.
4793
- const rootOrder = sg.rootOrder?.length
4794
- ? sg.rootOrder
4795
- : [
4796
- ...rootGroups.map((g) => ({ kind: "group", id: g.id })),
4797
- ...rootNodes.map((n) => ({ kind: "node", id: n.id })),
4798
- ...rootTables.map((t) => ({ kind: "table", id: t.id })),
4799
- ...rootCharts.map((c) => ({ kind: "chart", id: c.id })),
4800
- ...rootMarkdowns.map((m) => ({ kind: "markdown", id: m.id })),
4801
- ];
4649
+ const defaultRootOrder = [
4650
+ ...rootGroups.map((g) => ({ kind: "group", id: g.id })),
4651
+ ...rootNodes.map((n) => ({ kind: "node", id: n.id })),
4652
+ ...rootTables.map((t) => ({ kind: "table", id: t.id })),
4653
+ ...rootCharts.map((c) => ({ kind: "chart", id: c.id })),
4654
+ ...rootMarkdowns.map((m) => ({ kind: "markdown", id: m.id })),
4655
+ ];
4656
+ const rootOrderSource = sg.rootOrder?.length ? sg.rootOrder : defaultRootOrder;
4657
+ const rootOrder = rootOrderSource.filter((ref) => {
4658
+ switch (ref.kind) {
4659
+ case "group":
4660
+ return !nestedGroupIds.has(ref.id);
4661
+ case "node":
4662
+ return !groupedNodeIds.has(ref.id);
4663
+ case "table":
4664
+ return !groupedTableIds.has(ref.id);
4665
+ case "chart":
4666
+ return !groupedChartIds.has(ref.id);
4667
+ case "markdown":
4668
+ return !groupedMarkdownIds.has(ref.id);
4669
+ default:
4670
+ return true;
4671
+ }
4672
+ });
4802
4673
  // 5. Root-level layout
4803
4674
  // sg.layout:
4804
4675
  // 'row' → items flow left to right (default)
@@ -7701,6 +7572,21 @@ function mkGroup(id, cls) {
7701
7572
  g.setAttribute("class", cls);
7702
7573
  return g;
7703
7574
  }
7575
+ function buildParentGroupLookup(sg) {
7576
+ const parentGroups = new Map();
7577
+ for (const g of sg.groups) {
7578
+ if (g.parentId)
7579
+ parentGroups.set(`group:${g.id}`, g.parentId);
7580
+ for (const child of g.children) {
7581
+ parentGroups.set(`${child.kind}:${child.id}`, g.id);
7582
+ }
7583
+ }
7584
+ return parentGroups;
7585
+ }
7586
+ function setParentGroupData(el, groupId) {
7587
+ if (groupId)
7588
+ el.dataset.parentGroup = groupId;
7589
+ }
7704
7590
  // ── Node shapes ───────────────────────────────────────────────────────────
7705
7591
  function renderShape$1(rc, n, palette) {
7706
7592
  const s = n.style ?? {};
@@ -7797,6 +7683,7 @@ function renderToSVG(sg, container, options = {}) {
7797
7683
  }
7798
7684
  // ── Groups ───────────────────────────────────────────────
7799
7685
  const gmMap = new Map(sg.groups.map((g) => [g.id, g]));
7686
+ const parentGroups = buildParentGroupLookup(sg);
7800
7687
  const sortedGroups = [...sg.groups].sort((a, b) => groupDepth(a, gmMap) - groupDepth(b, gmMap));
7801
7688
  const GL = mkGroup("grp-layer");
7802
7689
  for (const g of sortedGroups) {
@@ -7804,6 +7691,7 @@ function renderToSVG(sg, container, options = {}) {
7804
7691
  continue;
7805
7692
  const gs = g.style ?? {};
7806
7693
  const gg = mkGroup(`group-${g.id}`, "gg");
7694
+ setParentGroupData(gg, g.parentId);
7807
7695
  if (gs.opacity != null)
7808
7696
  gg.setAttribute("opacity", String(gs.opacity));
7809
7697
  gg.appendChild(rc.rectangle(g.x, g.y, g.w, g.h, {
@@ -7908,6 +7796,7 @@ function renderToSVG(sg, container, options = {}) {
7908
7796
  const idPrefix = shapeDef?.idPrefix ?? "node";
7909
7797
  const cssClass = shapeDef?.cssClass ?? "ng";
7910
7798
  const ng = mkGroup(`${idPrefix}-${n.id}`, cssClass);
7799
+ setParentGroupData(ng, n.groupId ?? parentGroups.get(`node:${n.id}`));
7911
7800
  ng.dataset.nodeShape = n.shape;
7912
7801
  ng.dataset.x = String(n.x);
7913
7802
  ng.dataset.y = String(n.y);
@@ -7989,6 +7878,7 @@ function renderToSVG(sg, container, options = {}) {
7989
7878
  const TL = mkGroup("table-layer");
7990
7879
  for (const t of sg.tables) {
7991
7880
  const tg = mkGroup(`table-${t.id}`, "tg");
7881
+ setParentGroupData(tg, parentGroups.get(`table:${t.id}`));
7992
7882
  const gs = t.style ?? {};
7993
7883
  const fill = String(gs.fill ?? palette.tableFill);
7994
7884
  const strk = String(gs.stroke ?? palette.tableStroke);
@@ -8089,6 +7979,7 @@ function renderToSVG(sg, container, options = {}) {
8089
7979
  const MDL = mkGroup('markdown-layer');
8090
7980
  for (const m of sg.markdowns) {
8091
7981
  const mg = mkGroup(`markdown-${m.id}`, 'mdg');
7982
+ setParentGroupData(mg, parentGroups.get(`markdown:${m.id}`));
8092
7983
  const gs = m.style ?? {};
8093
7984
  const mFont = resolveStyleFont(gs, diagramFont);
8094
7985
  const baseColor = String(gs.color ?? palette.nodeText);
@@ -8152,7 +8043,9 @@ function renderToSVG(sg, container, options = {}) {
8152
8043
  // ── Charts ────────────────────────────────────────────────
8153
8044
  const CL = mkGroup("chart-layer");
8154
8045
  for (const c of sg.charts) {
8155
- CL.appendChild(renderRoughChartSVG(rc, c, palette, themeName !== "light"));
8046
+ const cg = renderRoughChartSVG(rc, c, palette, themeName !== "light");
8047
+ setParentGroupData(cg, parentGroups.get(`chart:${c.id}`));
8048
+ CL.appendChild(cg);
8156
8049
  }
8157
8050
  svg.appendChild(CL);
8158
8051
  return svg;
@@ -8865,18 +8758,33 @@ const getTableEl = (svg, id) => getEl(svg, `table-${id}`);
8865
8758
  const getNoteEl = (svg, id) => getEl(svg, `note-${id}`);
8866
8759
  const getChartEl = (svg, id) => getEl(svg, `chart-${id}`);
8867
8760
  const getMarkdownEl = (svg, id) => getEl(svg, `markdown-${id}`);
8761
+ const POSITIONABLE_SELECTOR = ".ng, .gg, .tg, .ntg, .cg, .mdg";
8762
+ function resolveNonEdgeDrawEl(svg, target) {
8763
+ return (getGroupEl(svg, target) ??
8764
+ getTableEl(svg, target) ??
8765
+ getNoteEl(svg, target) ??
8766
+ getChartEl(svg, target) ??
8767
+ getMarkdownEl(svg, target) ??
8768
+ getNodeEl(svg, target) ??
8769
+ null);
8770
+ }
8771
+ function hideDrawEl(el) {
8772
+ if (el.classList.contains("ng")) {
8773
+ el.classList.add("hidden");
8774
+ return;
8775
+ }
8776
+ el.classList.add("gg-hidden");
8777
+ }
8778
+ function showDrawEl(el) {
8779
+ el.classList.remove("hidden", "gg-hidden");
8780
+ }
8868
8781
  function resolveEl(svg, target) {
8869
8782
  // check edge first — target contains connector like "a-->b"
8870
8783
  const edge = parseEdgeTarget(target);
8871
8784
  if (edge)
8872
8785
  return getEdgeEl(svg, edge.from, edge.to);
8873
8786
  // everything else resolved by prefixed id
8874
- return (getNodeEl(svg, target) ??
8875
- getGroupEl(svg, target) ??
8876
- getTableEl(svg, target) ??
8877
- getNoteEl(svg, target) ??
8878
- getChartEl(svg, target) ??
8879
- getMarkdownEl(svg, target) ??
8787
+ return (resolveNonEdgeDrawEl(svg, target) ??
8880
8788
  null);
8881
8789
  }
8882
8790
  function pathLength(p) {
@@ -9066,6 +8974,7 @@ function prepareNodeForDraw(el) {
9066
8974
  el.appendChild(guide);
9067
8975
  }
9068
8976
  function revealNodeInstant(el) {
8977
+ showDrawEl(el);
9069
8978
  clearNodeDrawStyles(el);
9070
8979
  }
9071
8980
  // ── Text writing reveal (clipPath) ───────────────────────
@@ -9114,6 +9023,7 @@ function animateTextReveal(textEl, delayMs, durationMs = ANIMATION.textRevealMs)
9114
9023
  }, delayMs);
9115
9024
  }
9116
9025
  function animateNodeDraw(el, strokeDur = ANIMATION.nodeStrokeDur) {
9026
+ showDrawEl(el);
9117
9027
  const guide = nodeGuidePathEl(el);
9118
9028
  if (!guide) {
9119
9029
  const firstPath = el.querySelector("path");
@@ -9168,6 +9078,15 @@ function flattenSteps(items) {
9168
9078
  }
9169
9079
  return out;
9170
9080
  }
9081
+ function forEachPlaybackStep(items, visit) {
9082
+ items.forEach((item, stepIndex) => {
9083
+ if (item.kind === "beat") {
9084
+ item.children.forEach((child) => visit(child, stepIndex));
9085
+ return;
9086
+ }
9087
+ visit(item, stepIndex);
9088
+ });
9089
+ }
9171
9090
  // ── Draw target helpers ───────────────────────────────────
9172
9091
  function getDrawTargetEdgeIds(steps) {
9173
9092
  const ids = new Set();
@@ -9352,7 +9271,7 @@ class AnimationController {
9352
9271
  for (const s of flattenSteps(steps)) {
9353
9272
  if (s.action !== "draw" || parseEdgeTarget(s.target))
9354
9273
  continue;
9355
- if (svg.querySelector(`#group-${s.target}`)) {
9274
+ if (resolveNonEdgeDrawEl(svg, s.target)?.id === `group-${s.target}`) {
9356
9275
  this.drawTargetGroups.add(`group-${s.target}`);
9357
9276
  this.drawTargetNodes.delete(`node-${s.target}`);
9358
9277
  }
@@ -9373,6 +9292,10 @@ class AnimationController {
9373
9292
  this.drawTargetNodes.delete(`node-${s.target}`);
9374
9293
  }
9375
9294
  }
9295
+ this._drawStepIndexByElementId = this._buildDrawStepIndex();
9296
+ const { parentGroupByElementId, groupDescendantIds } = this._buildGroupVisibilityIndex();
9297
+ this._parentGroupByElementId = parentGroupByElementId;
9298
+ this._groupDescendantIds = groupDescendantIds;
9376
9299
  this._clearAll();
9377
9300
  // Init narration caption
9378
9301
  if (this._container)
@@ -9391,6 +9314,104 @@ class AnimationController {
9391
9314
  if (this._tts)
9392
9315
  this._warmUpSpeech();
9393
9316
  }
9317
+ _buildDrawStepIndex() {
9318
+ const drawStepIndexByElementId = new Map();
9319
+ forEachPlaybackStep(this.steps, (step, stepIndex) => {
9320
+ if (step.action !== "draw" || parseEdgeTarget(step.target))
9321
+ return;
9322
+ const el = resolveNonEdgeDrawEl(this.svg, step.target);
9323
+ if (el && !drawStepIndexByElementId.has(el.id)) {
9324
+ drawStepIndexByElementId.set(el.id, stepIndex);
9325
+ }
9326
+ });
9327
+ return drawStepIndexByElementId;
9328
+ }
9329
+ _buildGroupVisibilityIndex() {
9330
+ const parentGroupByElementId = new Map();
9331
+ const directChildIdsByGroup = new Map();
9332
+ this.svg.querySelectorAll(POSITIONABLE_SELECTOR).forEach((el) => {
9333
+ const parentGroupId = el.dataset.parentGroup;
9334
+ if (!parentGroupId)
9335
+ return;
9336
+ const parentGroupElId = `group-${parentGroupId}`;
9337
+ parentGroupByElementId.set(el.id, parentGroupElId);
9338
+ const children = directChildIdsByGroup.get(parentGroupElId) ?? new Set();
9339
+ children.add(el.id);
9340
+ directChildIdsByGroup.set(parentGroupElId, children);
9341
+ });
9342
+ const groupDescendantIds = new Map();
9343
+ const visit = (groupElId) => {
9344
+ if (groupDescendantIds.has(groupElId))
9345
+ return groupDescendantIds.get(groupElId);
9346
+ const descendants = new Set();
9347
+ const directChildren = directChildIdsByGroup.get(groupElId);
9348
+ if (directChildren) {
9349
+ for (const childId of directChildren) {
9350
+ descendants.add(childId);
9351
+ if (childId.startsWith("group-")) {
9352
+ visit(childId).forEach((nestedId) => descendants.add(nestedId));
9353
+ }
9354
+ }
9355
+ }
9356
+ groupDescendantIds.set(groupElId, descendants);
9357
+ return descendants;
9358
+ };
9359
+ this.svg.querySelectorAll(".gg").forEach((el) => {
9360
+ visit(el.id);
9361
+ });
9362
+ return { parentGroupByElementId, groupDescendantIds };
9363
+ }
9364
+ _hideGroupDescendants(groupElId) {
9365
+ const descendants = this._groupDescendantIds.get(groupElId);
9366
+ if (!descendants)
9367
+ return;
9368
+ for (const descendantId of descendants) {
9369
+ const el = getEl(this.svg, descendantId);
9370
+ if (el)
9371
+ hideDrawEl(el);
9372
+ }
9373
+ }
9374
+ _isDeferredForGroupReveal(elementId, stepIndex, groupElId) {
9375
+ let currentId = elementId;
9376
+ while (currentId) {
9377
+ const firstDrawStep = this._drawStepIndexByElementId.get(currentId);
9378
+ if (firstDrawStep != null && firstDrawStep > stepIndex)
9379
+ return true;
9380
+ if (currentId === groupElId)
9381
+ break;
9382
+ currentId = this._parentGroupByElementId.get(currentId);
9383
+ }
9384
+ return false;
9385
+ }
9386
+ _revealGroupSubtree(groupElId, stepIndex) {
9387
+ const descendants = this._groupDescendantIds.get(groupElId);
9388
+ if (!descendants)
9389
+ return;
9390
+ for (const descendantId of descendants) {
9391
+ if (this._isDeferredForGroupReveal(descendantId, stepIndex, groupElId))
9392
+ continue;
9393
+ const el = getEl(this.svg, descendantId);
9394
+ if (el)
9395
+ showDrawEl(el);
9396
+ }
9397
+ }
9398
+ _resolveCascadeTargets(target) {
9399
+ const edge = parseEdgeTarget(target);
9400
+ if (edge) {
9401
+ const el = getEdgeEl(this.svg, edge.from, edge.to);
9402
+ return el ? [el] : [];
9403
+ }
9404
+ const el = resolveEl(this.svg, target);
9405
+ if (!el)
9406
+ return [];
9407
+ if (!el.id.startsWith("group-"))
9408
+ return [el];
9409
+ const ids = new Set([el.id]);
9410
+ this._groupDescendantIds.get(el.id)?.forEach((id) => ids.add(id));
9411
+ return Array.from(ids)
9412
+ .map((id) => getEl(this.svg, id))
9413
+ .filter((candidate) => candidate != null);
9414
+ }
9394
9415
  /** The narration caption element — mount it anywhere via `yourContainer.appendChild(anim.captionElement)` */
9395
9416
  get captionElement() {
9396
9417
  return this._captionEl;
@@ -9660,6 +9681,9 @@ class AnimationController {
9660
9681
  el.style.opacity = "";
9661
9682
  el.classList.remove("hl", "faded");
9662
9683
  });
9684
+ for (const groupElId of this.drawTargetGroups) {
9685
+ this._hideGroupDescendants(groupElId);
9686
+ }
9663
9687
  // Clear narration caption
9664
9688
  if (this._captionEl) {
9665
9689
  this._captionEl.style.opacity = "0";
@@ -9722,7 +9746,7 @@ class AnimationController {
9722
9746
  this._doDraw(s, silent);
9723
9747
  break;
9724
9748
  case "erase":
9725
- this._doErase(s.target, s.duration);
9749
+ this._doErase(s.target, silent, s.duration);
9726
9750
  break;
9727
9751
  case "show":
9728
9752
  this._doShowHide(s.target, true, silent, s.duration);
@@ -9778,7 +9802,9 @@ class AnimationController {
9778
9802
  }
9779
9803
  // ── fade / unfade ─────────────────────────────────────────
9780
9804
  _doFade(target, doFade) {
9781
- resolveEl(this.svg, target)?.classList.toggle("faded", doFade);
9805
+ for (const el of this._resolveCascadeTargets(target)) {
9806
+ el.classList.toggle("faded", doFade);
9807
+ }
9782
9808
  }
9783
9809
  _writeTransform(el, target, silent, duration = 420) {
9784
9810
  const t = this._transforms.get(target) ?? {
@@ -9877,11 +9903,12 @@ class AnimationController {
9877
9903
  // Check if target is a group (has #group-{target} element)
9878
9904
  const groupEl = getGroupEl(this.svg, target);
9879
9905
  if (groupEl) {
9906
+ showDrawEl(groupEl);
9907
+ this._revealGroupSubtree(groupEl.id, this._step);
9880
9908
  // ── Group draw ──────────────────────────────────────
9881
9909
  if (silent) {
9882
9910
  clearDrawStyles(groupEl);
9883
9911
  groupEl.style.transition = "none";
9884
- groupEl.classList.remove("gg-hidden");
9885
9912
  groupEl.style.opacity = "1";
9886
9913
  requestAnimationFrame(() => requestAnimationFrame(() => {
9887
9914
  groupEl.style.transition = "";
@@ -9889,7 +9916,6 @@ class AnimationController {
9889
9916
  }));
9890
9917
  }
9891
9918
  else {
9892
- groupEl.classList.remove("gg-hidden");
9893
9919
  // Groups use slightly longer stroke-draw (bigger box, dashed border = more paths)
9894
9920
  const firstPath = groupEl.querySelector("path");
9895
9921
  if (!firstPath?.style.strokeDasharray)
@@ -10000,6 +10026,7 @@ class AnimationController {
10000
10026
  const nodeEl = getNodeEl(this.svg, target);
10001
10027
  if (!nodeEl)
10002
10028
  return;
10029
+ showDrawEl(nodeEl);
10003
10030
  if (silent) {
10004
10031
  revealNodeInstant(nodeEl);
10005
10032
  }
@@ -10011,20 +10038,20 @@ class AnimationController {
10011
10038
  }
10012
10039
  }
10013
10040
  // ── erase ─────────────────────────────────────────────────
10014
- _doErase(target, duration = 400) {
10015
- const el = resolveEl(this.svg, target); // handles edges too now
10016
- if (el) {
10017
- el.style.transition = `opacity ${duration}ms`;
10041
+ _doErase(target, silent, duration = 400) {
10042
+ for (const el of this._resolveCascadeTargets(target)) {
10043
+ el.style.transition = silent ? "none" : `opacity ${duration}ms`;
10018
10044
  el.style.opacity = "0";
10019
10045
  }
10020
10046
  }
10021
10047
  // ── show / hide ───────────────────────────────────────────
10022
10048
  _doShowHide(target, show, silent, duration = 400) {
10023
- const el = resolveEl(this.svg, target);
10024
- if (!el)
10025
- return;
10026
- el.style.transition = silent ? "none" : `opacity ${duration}ms`;
10027
- el.style.opacity = show ? "1" : "0";
10049
+ for (const el of this._resolveCascadeTargets(target)) {
10050
+ if (show)
10051
+ showDrawEl(el);
10052
+ el.style.transition = silent ? "none" : `opacity ${duration}ms`;
10053
+ el.style.opacity = show ? "1" : "0";
10054
+ }
10028
10055
  }
10029
10056
  // ── pulse ─────────────────────────────────────────────────
10030
10057
  _doPulse(target, duration = 500) {