@litfamily/litclaude 1.0.19 → 1.0.21

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.
Files changed (101) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -8
  3. package/README_ko-KR.md +13 -8
  4. package/docs/assets/readme/badge-version.svg +1 -1
  5. package/docs/hooks.md +3 -2
  6. package/package.json +1 -1
  7. package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
  8. package/plugins/litclaude/bin/litclaude-hook.js +4 -4
  9. package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
  10. package/plugins/litclaude/lib/canonical-skill-resources.mjs +91 -31
  11. package/plugins/litclaude/skills/frontend-ui-ux/ATTRIBUTION.md +3 -6
  12. package/plugins/litclaude/skills/lit-docx/SKILL.md +50 -19
  13. package/plugins/litclaude/skills/lit-docx/references/components.md +207 -0
  14. package/plugins/litclaude/skills/lit-docx/references/direction-step.md +277 -0
  15. package/plugins/litclaude/skills/lit-docx/references/examples/01-report-ko.md +166 -0
  16. package/plugins/litclaude/skills/lit-docx/references/examples/02-brief-ko.md +105 -0
  17. package/plugins/litclaude/skills/lit-docx/references/examples/03-manual-ko.md +164 -0
  18. package/plugins/litclaude/skills/lit-docx/references/examples/04-proposal-ko.md +138 -0
  19. package/plugins/litclaude/skills/lit-docx/references/examples/05-memo-en.md +55 -0
  20. package/plugins/litclaude/skills/lit-docx/references/examples/06-journal-en.md +130 -0
  21. package/plugins/litclaude/skills/lit-docx/references/examples/07-report-en.md +123 -0
  22. package/plugins/litclaude/skills/lit-docx/references/examples/08-proposal-en.md +123 -0
  23. package/plugins/litclaude/skills/lit-docx/references/examples/09-memo-ko.md +45 -0
  24. package/plugins/litclaude/skills/lit-docx/references/page-composition.md +221 -0
  25. package/plugins/litclaude/skills/lit-docx/references/tonalities/brief.md +143 -0
  26. package/plugins/litclaude/skills/lit-docx/references/tonalities/journal.md +142 -0
  27. package/plugins/litclaude/skills/lit-docx/references/tonalities/manual.md +148 -0
  28. package/plugins/litclaude/skills/lit-docx/references/tonalities/memo.md +137 -0
  29. package/plugins/litclaude/skills/lit-docx/references/tonalities/proposal.md +140 -0
  30. package/plugins/litclaude/skills/lit-docx/references/tonalities/report.md +156 -0
  31. package/plugins/litclaude/skills/lit-docx/scripts/convert_md_to_docx.py +274 -73
  32. package/plugins/litclaude/skills/lit-docx/scripts/docx_design.py +2449 -0
  33. package/plugins/litclaude/skills/lit-docx/scripts/docx_layout.py +959 -0
  34. package/plugins/litclaude/skills/lit-docx/scripts/qa_docx.py +66 -9
  35. package/plugins/litclaude/skills/lit-docx/scripts/slop_lint.py +18 -6
  36. package/plugins/litclaude/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
  37. package/plugins/litclaude/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
  38. package/plugins/litclaude/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
  39. package/plugins/litclaude/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
  40. package/plugins/litclaude/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
  41. package/plugins/litclaude/skills/lit-docx/templates/tonalities/report.yaml +26 -0
  42. package/plugins/litclaude/skills/lit-pptx/SKILL.md +105 -48
  43. package/plugins/litclaude/skills/lit-pptx/references/agents/analyst.md +1 -1
  44. package/plugins/litclaude/skills/lit-pptx/references/agents/thesis.md +1 -1
  45. package/plugins/litclaude/skills/lit-pptx/references/anti-slop-checklist.md +59 -30
  46. package/plugins/litclaude/skills/lit-pptx/references/authoring-guide.md +119 -42
  47. package/plugins/litclaude/skills/lit-pptx/references/consensus-protocol.md +1 -1
  48. package/plugins/litclaude/skills/lit-pptx/references/data-display-cookbook.md +13 -6
  49. package/plugins/litclaude/skills/lit-pptx/references/density-and-fill.md +164 -0
  50. package/plugins/litclaude/skills/lit-pptx/references/design-system-azure.md +7 -4
  51. package/plugins/litclaude/skills/lit-pptx/references/direction-step.md +186 -0
  52. package/plugins/litclaude/skills/lit-pptx/references/examples/01-pitch-signal-ko.md +210 -0
  53. package/plugins/litclaude/skills/lit-pptx/references/examples/02-pitch-studio-ko.md +147 -0
  54. package/plugins/litclaude/skills/lit-pptx/references/examples/03-business-review-ledger-ko.md +199 -0
  55. package/plugins/litclaude/skills/lit-pptx/references/examples/04-business-review-night-en.md +203 -0
  56. package/plugins/litclaude/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +179 -0
  57. package/plugins/litclaude/skills/lit-pptx/references/examples/06-lecture-chalk-ko.md +199 -0
  58. package/plugins/litclaude/skills/lit-pptx/references/examples/07-data-review-night-en.md +194 -0
  59. package/plugins/litclaude/skills/lit-pptx/references/examples/08-image-led-atlas-ko.md +175 -0
  60. package/plugins/litclaude/skills/lit-pptx/references/examples/09-briefing-gazette-ko.md +204 -0
  61. package/plugins/litclaude/skills/lit-pptx/references/examples/10-status-update-ledger-ko.md +179 -0
  62. package/plugins/litclaude/skills/lit-pptx/references/examples/11-portfolio-studio-en.md +164 -0
  63. package/plugins/litclaude/skills/lit-pptx/references/examples/12-research-tutorial-chalk-en.md +174 -0
  64. package/plugins/litclaude/skills/lit-pptx/references/interview-protocol.md +1 -1
  65. package/plugins/litclaude/skills/lit-pptx/references/layout-families.md +198 -0
  66. package/plugins/litclaude/skills/lit-pptx/references/stages/autopilot.md +1 -1
  67. package/plugins/litclaude/skills/lit-pptx/references/stages/interview.md +2 -2
  68. package/plugins/litclaude/skills/lit-pptx/references/stages/plan.md +1 -1
  69. package/plugins/litclaude/skills/lit-pptx/references/title-treatments.md +109 -0
  70. package/plugins/litclaude/skills/lit-pptx/references/tonalities/atlas.md +139 -0
  71. package/plugins/litclaude/skills/lit-pptx/references/tonalities/chalk.md +148 -0
  72. package/plugins/litclaude/skills/lit-pptx/references/tonalities/gazette.md +149 -0
  73. package/plugins/litclaude/skills/lit-pptx/references/tonalities/ledger.md +149 -0
  74. package/plugins/litclaude/skills/lit-pptx/references/tonalities/night.md +147 -0
  75. package/plugins/litclaude/skills/lit-pptx/references/tonalities/paper.md +151 -0
  76. package/plugins/litclaude/skills/lit-pptx/references/tonalities/signal.md +150 -0
  77. package/plugins/litclaude/skills/lit-pptx/references/tonalities/studio.md +132 -0
  78. package/plugins/litclaude/skills/lit-pptx/scripts/compile-deck.js +64 -16
  79. package/plugins/litclaude/skills/lit-pptx/scripts/craft_extras.py +664 -3
  80. package/plugins/litclaude/skills/lit-pptx/scripts/deck_craft.py +48 -6
  81. package/plugins/litclaude/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  82. package/plugins/litclaude/skills/lit-pptx/scripts/lib/layout-resolver.js +362 -11
  83. package/plugins/litclaude/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  84. package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +211 -65
  85. package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-pack.js +3559 -0
  86. package/plugins/litclaude/skills/lit-pptx/scripts/lib/slide-ast.js +10 -6
  87. package/plugins/litclaude/skills/lit-pptx/scripts/lib/template-registry.js +286 -1
  88. package/plugins/litclaude/skills/lit-pptx/scripts/qa_deck.py +39 -10
  89. package/plugins/litclaude/skills/lit-pptx/scripts/validate_pptx.py +22 -7
  90. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  91. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
  92. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
  93. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
  94. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +92 -0
  95. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +97 -0
  96. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +93 -0
  97. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +94 -0
  98. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/night/pack.yaml +93 -0
  99. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/paper/pack.yaml +98 -0
  100. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/signal/pack.yaml +94 -0
  101. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/studio/pack.yaml +92 -0
@@ -246,22 +246,25 @@ function blockToRegion(blockType, block, layout) {
246
246
  * much vertical room the block needs, so the rule lives here where the geometry
247
247
  * is worked out and the renderer reads it from here.
248
248
  */
249
- function looksLikeKpi(content) {
249
+ function looksLikeKpi(content, o = {}) {
250
250
  if (content && content.chart) return false;
251
251
  const headers = (content && content.headers) || [];
252
252
  const rows = (content && content.rows) || [];
253
253
  const cols = headers.length;
254
- if (cols < 2 || cols > 4) return false;
254
+ // Under a tonality a row may hold up to six figures (spec-v2 Amendments 6); legacy cards stop at four.
255
+ if (cols < 2 || cols > (o.basis ? 6 : 4)) return false;
255
256
  if (cols === 2) {
256
257
  const pairs = [headers, ...rows];
257
258
  return pairs.length <= 4 && pairs.every((p) => String(p[0]).length <= 12);
258
259
  }
260
+ // Under a tonality a second row may give each figure's basis or comparison (spec-v2 Amendments 5).
261
+ if (o.basis && rows.length === 2) return rows[0].every((c) => String(c).length <= 12) && rows[1].every((c) => String(c).length <= 24);
259
262
  return rows.length === 1 && rows[0].every((c) => String(c).length <= 12);
260
263
  }
261
264
 
262
- // A KPI badge stacks a small label over a large value, so it stands roughly
263
- // this tall no matter how few rows fed it.
264
- const KPI_BLOCK_HEIGHT_IN = 1.75;
265
+ // A KPI badge stacks a small label over a large value, so it stands roughly as tall as the
266
+ // template's kpi.blockHeight token (1.75in on the legacy templates) no matter how few rows fed it.
267
+ const grid = require("./grid-resolver");
265
268
 
266
269
  // ---------------------------------------------------------------------------
267
270
  // Capability validation
@@ -274,7 +277,7 @@ const KPI_BLOCK_HEIGHT_IN = 1.75;
274
277
  */
275
278
  const CAPABILITY_RULES = {
276
279
  cover: new Set(["title", "notes"]),
277
- section: new Set(["title", "notes"]),
280
+ section: new Set(["title", "notes", "image", "figure-caption"]),
278
281
  content: new Set(["title", "key-message", "body", "image", "notes", "kpi-table", "figure-caption", "table-caption"]),
279
282
  main: new Set(["title", "key-message", "body", "main-box", "kpi-table", "image", "notes", "figure-caption", "table-caption"]),
280
283
  summary: new Set(["title", "key-message", "summary-group", "image", "kpi-table", "figure-caption", "table-caption"]),
@@ -468,6 +471,7 @@ function resolve(ast, templateObj) {
468
471
  if (!templateObj) {
469
472
  throw new Error("resolve(): templateObj is required");
470
473
  }
474
+ if (templateObj.pack && !templateObj.pack.legacy) return resolvePack(ast, templateObj);
471
475
 
472
476
  // Parse YAML strings from template object
473
477
  const mapping = typeof templateObj.mapping === "string"
@@ -490,8 +494,16 @@ function resolve(ast, templateObj) {
490
494
  const deckMeta = ast.deck || {};
491
495
  const [FIGURE, TABLE] = captionPrefixes(ast);
492
496
 
497
+ const tokens = (templateObj.pack && templateObj.pack.tokens) || {};
498
+ const kpiBlockHeight = (tokens.kpi && tokens.kpi.blockHeight) || 1.75;
493
499
  for (const slide of ast.slides) {
494
- const resolved = resolveSlide(slide, mapping, capabilities, deckMeta);
500
+ if (slide.meta && slide.meta.title) {
501
+ throw new Error(
502
+ `Slide ${slide.index + 1}: "title: ${slide.meta.title}" picks a title treatment, which needs a tonality; ` +
503
+ `${templateObj.name || "this template"} is a legacy template with one title look`
504
+ );
505
+ }
506
+ const resolved = resolveSlide(slide, mapping, capabilities, deckMeta, kpiBlockHeight);
495
507
  // Auto-number captions across the deck
496
508
  for (const [name, region] of Object.entries(resolved.regions || {})) {
497
509
  if (region.content && region.content.type === "figure-caption") {
@@ -527,10 +539,13 @@ function resolve(ast, templateObj) {
527
539
  * @param {object} [capabilities] — Parsed capabilities.yaml
528
540
  * @returns {object} — Resolved slide spec
529
541
  */
530
- function resolveSlide(slide, mapping, capabilities, deckMeta) {
531
- const { index, layout, blocks } = slide;
542
+ function resolveSlide(slide, mapping, capabilities, deckMeta, kpiBlockHeight) {
543
+ const { index, blocks } = slide;
532
544
 
533
- // 1. Look up layout configuration
545
+ // 1. Look up layout configuration. A layout family the template does not declare (kpi-row,
546
+ // chart-insight, …) is drawn on the template layout that hosts its kind of slide.
547
+ const family = familyOf(slide.layout, mapping, index);
548
+ const layout = (mapping.layouts[slide.layout] || slide.layout === "free") ? slide.layout : family.host;
534
549
  const layoutConfig = mapping.layouts[layout] ||
535
550
  (layout === "free" ? FREE_LAYOUT : null);
536
551
  if (!layoutConfig) {
@@ -689,7 +704,7 @@ function resolveSlide(slide, mapping, capabilities, deckMeta) {
689
704
  // lands on top of them.
690
705
  const tableContent = tableRegion.content || {};
691
706
  const needed = looksLikeKpi(tableContent)
692
- ? KPI_BLOCK_HEIGHT_IN
707
+ ? kpiBlockHeight
693
708
  : rowCount * rowH;
694
709
  const tableBottom = tblY + Math.max(needed, tableRegion.h || 0) + 0.3;
695
710
  if ((regions.body.y || 1.044) < tableBottom) {
@@ -733,6 +748,7 @@ function resolveSlide(slide, mapping, capabilities, deckMeta) {
733
748
  return {
734
749
  index,
735
750
  layout,
751
+ family: family.id,
736
752
  layoutSource: layoutConfig.layout_source,
737
753
  decorations,
738
754
  regions,
@@ -761,6 +777,341 @@ function extractContent(block) {
761
777
  return "";
762
778
  }
763
779
 
780
+ // ---------------------------------------------------------------------------
781
+ // Layout families
782
+ // ---------------------------------------------------------------------------
783
+
784
+ /**
785
+ * The family a slide is stamped with, and the template layout that hosts it when the template
786
+ * does not declare the name itself. A name that is neither keeps the template's own error.
787
+ */
788
+ function familyOf(layout, mapping, index) {
789
+ if (grid.LEGACY_LAYOUTS[layout]) return { id: grid.LEGACY_LAYOUTS[layout], host: layout };
790
+ if (mapping.layouts[layout]) return { id: layout, host: layout };
791
+ try {
792
+ const f = grid.resolveFamily(layout, index + 1);
793
+ return { id: f.family || layout, host: f.kind === "cover" || f.kind === "section" ? f.kind : "content" };
794
+ } catch (_) {
795
+ return { id: layout, host: layout };
796
+ }
797
+ }
798
+
799
+ // ---------------------------------------------------------------------------
800
+ // Tonality packs: regions on the 12-column grid
801
+ // ---------------------------------------------------------------------------
802
+
803
+ const PACK_BLOCKS = {
804
+ cover: new Set(["title", "notes"]),
805
+ section: new Set(["title", "notes", "image", "figure-caption"]),
806
+ content: new Set(["title", "key-message", "body", "main-box", "kpi-table", "image", "notes", "figure-caption", "table-caption", "summary-group"]),
807
+ };
808
+
809
+ const plain = (text) => String(text || "").replace(/\*\*/g, "").trim();
810
+
811
+ /** What a slide holds, as the treatment rules need it. */
812
+ function slideFacts(blocks) {
813
+ const all = blocks.flatMap((b) => (b.type === "columns" ? b.columns.flatMap((c) => c.blocks || []) : [b]));
814
+ const has = (type) => all.some((b) => b.type === type);
815
+ const textBlocks = all.filter((b) => ["body", "summary-group", "main-box", "key-message"].includes(b.type)).length +
816
+ (blocks.some((b) => b.type === "columns") ? 1 : 0);
817
+ const bodyItems = all.filter((b) => b.type === "body").reduce((n, b) => n + (b.items || []).length, 0);
818
+ return {
819
+ image: has("image"),
820
+ visual: has("image") || has("kpi-table"),
821
+ textBlocks: textBlocks + (bodyItems > 1 ? 1 : 0),
822
+ };
823
+ }
824
+
825
+ /** Comparison halves of numbers are data; halves of arguments are content. */
826
+ function comparisonRole(blocks) {
827
+ const text = JSON.stringify(blocks);
828
+ const digits = (text.match(/\d/g) || []).length;
829
+ return digits / Math.max(1, plain(text).length) >= 0.04 ? "data" : "content";
830
+ }
831
+
832
+ /** The ordered content of a pack slide, captions bound to the visual before them. */
833
+ // Families whose geometry is a chart: a table of numbers there is drawn as one.
834
+ const CHART_FAMILIES = new Set(["chart-insight", "full-chart", "kpi-over-chart", "dashboard-grid"]);
835
+ const NUMBER_CELL = /^[+\-−±▲▼△▽]?\s*[\d.,]+\s*(?:%p?|배|x|pt|bp|[가-힣]{1,3}|[A-Za-z]{1,3})?$/u;
836
+
837
+ /** A table whose data columns are all numbers, over three or more rows, can be drawn as a chart. */
838
+ function chartable(c) {
839
+ const rows = c.rows || [];
840
+ const headers = c.headers || [];
841
+ return headers.length >= 2 && rows.length >= 3 &&
842
+ headers.slice(1).every((_, ci) => rows.every((r) => r[ci + 1] != null && NUMBER_CELL.test(plain(r[ci + 1]))));
843
+ }
844
+
845
+ function packItems(blocks, numbering, charts = false) {
846
+ const items = [];
847
+ for (const block of blocks) {
848
+ if (["title", "notes", "key-message", "box", "shape"].includes(block.type)) continue;
849
+ if (block.type === "columns") {
850
+ items.push({ type: "columns", tracks: block.tracks, columns: block.columns.map((c) => packItems(c.blocks || [], numbering, charts)) });
851
+ continue;
852
+ }
853
+ const content = extractContent(block);
854
+ if (charts && block.type === "kpi-table" && !content.chart && chartable(content)) {
855
+ content.chart = { type: (content.rows || []).length > 8 ? "line" : "column" };
856
+ }
857
+ if (block.type === "figure-caption" || block.type === "table-caption") {
858
+ // A table drawn as a chart is captioned as a figure.
859
+ const charted = [...items].reverse().find((i) => i.type === "kpi-table");
860
+ const figure = block.type === "figure-caption" || block.figure || Boolean(charted && charted.content.chart);
861
+ content.number = figure ? ++numbering.figure : ++numbering.table;
862
+ content.prefix = figure ? numbering.FIGURE : numbering.TABLE;
863
+ const target = [...items].reverse().find((i) => ["kpi-table", "image"].includes(i.type));
864
+ if (target && !target.caption) target.caption = content;
865
+ else items.push({ type: "caption", content });
866
+ continue;
867
+ }
868
+ items.push({ type: block.type, content });
869
+ }
870
+ return items;
871
+ }
872
+
873
+ /**
874
+ * What covers and sections can draw from the deck itself: the first image, the part names (from
875
+ * the agenda heads, else the section titles), the first row of headline numbers and a real year.
876
+ */
877
+ function deckFacts(ast) {
878
+ const facts = { image: null, index: [], figures: [], numeral: null };
879
+ const sections = [];
880
+ for (const slide of ast.slides) {
881
+ for (const b of slide.blocks) {
882
+ if (!facts.image && b.type === "image" && b.src) facts.image = { src: b.src, caption: b.caption };
883
+ if (!facts.figures.length && b.type === "kpi-table" && !b.chart && looksLikeKpi(b, { basis: true })) {
884
+ const headers = b.headers || [];
885
+ const row = (b.rows || [])[0] || [];
886
+ facts.figures = headers.slice(0, 4).map((h, i) => ({ label: plain(h), value: plain(row[i]) })).filter((f) => f.value);
887
+ }
888
+ }
889
+ if (slide.layout === "agenda" && !facts.index.length) {
890
+ const body = slide.blocks.find((b) => b.type === "body");
891
+ const items = ((body && body.items) || []).filter((i) => plain(i.heading || i.text)).slice(0, 6);
892
+ facts.index = items.map((i) => plain(i.heading || String(i.text || "").replace(/^\*\*(.+?)\*\*.*$/u, "$1")));
893
+ // What each part is about, so a section titled with an entry's description finds its place too.
894
+ facts.indexAbout = items.map((i) => plain(String(i.text || "").replace(/^\*\*(.+?)\*\*/u, "")));
895
+ }
896
+ if (/^section/u.test(slide.layout)) {
897
+ const t = slide.blocks.find((b) => b.type === "title");
898
+ if (t) sections.push(plain(t.content));
899
+ }
900
+ }
901
+ if (!facts.index.length && sections.length >= 2) facts.index = sections.slice(0, 6);
902
+ facts.indexAbout = facts.indexAbout || [];
903
+ const meta = (ast.deck && ast.deck.metadata) || {};
904
+ const year = `${ast.deck && ast.deck.title ? ast.deck.title : ""} ${meta.date || ""}`.match(/\b(19|20)\d{2}\b/u);
905
+ facts.numeral = year ? year[0] : null;
906
+ return facts;
907
+ }
908
+
909
+ /** Titles of the content slides a section opens, up to the next section or closing (at most three). */
910
+ function sectionContents(ast, from) {
911
+ const out = [];
912
+ for (const s of ast.slides.slice(from + 1)) {
913
+ if (/^(section|closing)/u.test(s.layout || "")) break;
914
+ const t = s.blocks.find((b) => b.type === "title");
915
+ if (t && plain(t.content)) out.push(plain(t.content));
916
+ }
917
+ return out.slice(0, 3);
918
+ }
919
+
920
+ /**
921
+ * A preview line: a bold run-in label stays bold and is followed by a colon ("**결과:** 공부 시간이 …"),
922
+ * unless the label ends in its own separator; any other line is plain text.
923
+ */
924
+ function runIn(text) {
925
+ const m = /^\*\*(.+?)\*\*\s*(.*)$/u.exec(String(text || "").trim());
926
+ if (!m || !plain(m[2])) return plain(text);
927
+ const label = plain(m[1]);
928
+ return `**${/[::.)\]–—-]$/u.test(label) ? label : `${label}:`}** ${plain(m[2])}`;
929
+ }
930
+
931
+ /** The first body lines (bullets or text, up to five) of the slides a section opens, by title. */
932
+ function sectionDigest(ast, from) {
933
+ const out = [];
934
+ for (const s of ast.slides.slice(from + 1)) {
935
+ if (/^(section|closing)/u.test(s.layout || "")) break;
936
+ const t = s.blocks.find((b) => b.type === "title");
937
+ if (!t || !plain(t.content)) continue;
938
+ const lines = s.blocks.filter((b) => b.type === "body").flatMap((b) => b.items || [])
939
+ .filter((i) => ["bullet", "text", "numbered"].includes(i.type) && plain(i.text)).slice(0, 5).map((i) => runIn(i.text));
940
+ out.push({ title: plain(t.content), lines });
941
+ }
942
+ return out.slice(0, 3);
943
+ }
944
+
945
+ /** Position of a section title in the deck's part index (by entry or by its description), or -1. */
946
+ function indexOf(facts, title) {
947
+ const t = String(title || "").trim();
948
+ if (!t || facts.index.length < 2) return -1;
949
+ const at = facts.index.findIndex((name) => name === t);
950
+ return at >= 0 ? at : facts.indexAbout.findIndex((about) => about === t);
951
+ }
952
+
953
+ /** Can this cover, section or closing variant be drawn with what the deck and slide hold? true or the reason. */
954
+ function variantApplies(variant, facts, slide) {
955
+ const ownImage = slide.blocks.find((b) => b.type === "image");
956
+ if (["cover-split-image", "cover-full-image"].includes(variant) && !facts.image) return "the deck has no image";
957
+ if (variant === "section-image" && !ownImage) return "the section slide has no image";
958
+ if (variant === "cover-numeral" && !facts.numeral) return "the deck names no year or real figure";
959
+ if (variant === "cover-index" && facts.index.length < 2) return "the deck has no agenda or sections to list";
960
+ if (variant === "cover-figures" && facts.figures.length < 2) return "the deck has no row of headline numbers";
961
+ if (variant === "closing-statement" && slide.blocks.some((b) => ["kpi-table", "image"].includes(b.type))) return "a statement closing has no table or image";
962
+ return true;
963
+ }
964
+
965
+ /**
966
+ * The variant of a cover, section or closing: the one the source names when the pack lists it and
967
+ * it can be drawn, else the pack's first that can. A generic name (cover, section, closing) asks
968
+ * for the pack's own first choice.
969
+ */
970
+ function chooseVariant(kind, named, pack, facts, slide, notes) {
971
+ const list = pack[kind === "cover" ? "covers" : kind === "section" ? "sections" : "closings"] || [];
972
+ const n = slide.index + 1;
973
+ if (named && list.includes(named)) {
974
+ const why = variantApplies(named, facts, slide);
975
+ if (why === true) return named;
976
+ notes.push(`slide ${n}: ${named} cannot be drawn (${why})`);
977
+ } else if (named && !["cover", "section", "closing"].includes(named)) {
978
+ notes.push(`slide ${n}: ${named} is not a ${pack.id} ${kind} variant (${list.join(", ")})`);
979
+ }
980
+ const pick = list.find((v) => v !== named && variantApplies(v, facts, slide) === true);
981
+ if (!pick) throw new Error(`Slide ${n}: no ${pack.id} ${kind} variant (${list.join(", ")}) can be drawn here`);
982
+ if (named) notes.push(`slide ${n}: drawn as ${pick}`);
983
+ return pick;
984
+ }
985
+
986
+ /**
987
+ * Resolve every slide of a deck against a tonality pack: its family, the title treatments it may
988
+ * take (the pack's role default first, then the others the family allows; the renderer picks among
989
+ * them by fill and the variance dial), the treatment geometry inputs and its content in reading
990
+ * order. Notes go to the build log.
991
+ */
992
+ function resolvePack(ast, templateObj) {
993
+ const pack = templateObj.pack;
994
+ const sizes = pack.tok.sizes;
995
+ const decoration = new Set(pack.decoration || []);
996
+ const notes = [];
997
+ const [FIGURE, TABLE] = captionPrefixes(ast);
998
+ const numbering = { figure: 0, table: 0, FIGURE, TABLE };
999
+ const facts = deckFacts(ast);
1000
+ const families = pack["layout-families"] || [];
1001
+ const wide = { title: grid.faceWidth(pack.faces.title), display: grid.faceWidth(pack.faces.display) };
1002
+ let part = 0;
1003
+
1004
+ const slides = ast.slides.map((slide) => {
1005
+ const n = slide.index + 1;
1006
+ const meta = slide.meta || {};
1007
+ const fam = grid.resolveFamily(slide.layout, n);
1008
+ const kind = fam.kind === "free" ? "content" : fam.kind;
1009
+ const allowed = PACK_BLOCKS[kind];
1010
+ for (const block of slide.blocks) {
1011
+ if (["box", "shape", "columns"].includes(block.type) || allowed.has(block.type)) continue;
1012
+ throw new Error(`Slide ${n} (layout: ${slide.layout}): block type "${block.type}" is not allowed on a ${kind} slide. ` +
1013
+ `Allowed: ${[...allowed].join(", ")}`);
1014
+ }
1015
+ const titleBlock = slide.blocks.find((b) => b.type === "title");
1016
+ const title = titleBlock ? titleBlock.content : "";
1017
+ const lead = slide.blocks.find((b) => b.type === "key-message");
1018
+ const placements = slide.blocks
1019
+ .filter((b) => b.type === "box" || b.type === "shape")
1020
+ .map(({ type, ...rest }) => ({ kind: type === "shape" ? "shape" : "box", ...rest }));
1021
+ const base = { index: slide.index, layout: slide.layout, kind, title, lead: lead ? lead.content : null, placements, blocks: slide.blocks, meta, regions: {} };
1022
+
1023
+ if (kind === "cover" || kind === "section") {
1024
+ if (meta.title) throw new Error(`Slide ${n}: a ${kind} slide takes its variant's title, not "title: ${meta.title}"`);
1025
+ const variant = chooseVariant(kind, fam.family || fam.legacyLayout, pack, facts, slide, notes);
1026
+ let parts = null;
1027
+ if (kind === "section") {
1028
+ // A section numbers itself by its place in the deck's agenda when its title is an agenda
1029
+ // entry (or that entry's description); otherwise it counts the sections so far.
1030
+ const at = indexOf(facts, plain(title));
1031
+ part = at >= 0 ? at + 1 : part + 1;
1032
+ parts = at >= 0 ? facts.index.length : null;
1033
+ }
1034
+ const ownImage = slide.blocks.find((b) => b.type === "image");
1035
+ const cover = { ...facts, image: kind === "section" && ownImage ? { src: ownImage.src, caption: ownImage.caption } : facts.image };
1036
+ const contents = kind === "section" ? sectionContents(ast, slide.index) : [];
1037
+ const digest = kind === "section" ? sectionDigest(ast, slide.index) : [];
1038
+ return { ...base, family: variant, variant, part, parts, cover, contents, digest, regions: { title: { content: title } } };
1039
+ }
1040
+
1041
+ let family = fam.family === "free" ? "text-column" : fam.family;
1042
+ let role = family === "comparison" ? comparisonRole(slide.blocks) : fam.role;
1043
+ if (grid.VARIANTS.closing.includes(family) || fam.legacyLayout === "closing") {
1044
+ family = chooseVariant("closing", fam.legacyLayout === "closing" ? "closing" : family, pack, facts, slide, notes);
1045
+ role = family === "closing-statement" ? "statement" : "content";
1046
+ } else if (!families.includes(family)) {
1047
+ notes.push(`slide ${n}: ${family} is not a ${pack.id} layout family (${families.join(", ")}); drawn with its own geometry`);
1048
+ }
1049
+ let titleOnly = null;
1050
+ if (family === "statement" && !pack.treatments.includes("statement")) {
1051
+ // A pack without a statement title sets the sentence as a top title, the largest it has.
1052
+ titleOnly = ["top-plain-large", "band", "top-rule"].find((t) => pack.treatments.includes(t));
1053
+ if (!titleOnly) throw new Error(`Slide ${n}: ${pack.id} has no statement or top title to set a statement slide with; choose another tonality`);
1054
+ notes.push(`slide ${n}: ${pack.id} has no statement title, so the sentence is drawn as a ${titleOnly} title`);
1055
+ family = "text-column";
1056
+ role = "content";
1057
+ }
1058
+ const items = packItems(slide.blocks, numbering, CHART_FAMILIES.has(family));
1059
+ const paras = slide.blocks.filter((b) => b.type === "body").flatMap((b) => b.items || []);
1060
+ // A closing is not part of a section, so it never carries the part number.
1061
+ const closing = grid.VARIANTS.closing.includes(family);
1062
+ // An agenda numbers its own rows: a count beside its title reads as a stray section number (spec-v2 Amendments 8).
1063
+ const numeral = family === "agenda" ? null : part > 0 && !closing ? part : null;
1064
+ const statementCols = (pack.display || {}).statement === "offset" ? [5, 12] : null;
1065
+ const ctx = { grid: pack.grid, sizes, title: plain(title), hero: pack.hero, decoration, numeral, rail: pack.rail, wide, statementCols, tight: Boolean((pack.tok || {}).tight) };
1066
+ const facts1 = slideFacts(slide.blocks);
1067
+ // A quote under a statement title says the quote; the source line becomes the support line.
1068
+ let statementText = null;
1069
+ let supportText = null;
1070
+ if (family === "quote") {
1071
+ const texts = paras.filter((i) => i.type === "text").map((i) => plain(i.text));
1072
+ const by = texts.find((t) => /^[—–-]\s*/u.test(t));
1073
+ statementText = texts.filter((t) => t !== by).join(" ") || null;
1074
+ supportText = by || null;
1075
+ }
1076
+ const applicable = (t) => {
1077
+ const c = t === "statement" && statementText ? { ...ctx, title: statementText } : ctx;
1078
+ const f = t === "statement" && family === "quote" ? { ...facts1, textBlocks: 1 } : facts1;
1079
+ return grid.treatmentApplies(t, f, c);
1080
+ };
1081
+ let allow = grid.familyAllows(family);
1082
+ let treatment;
1083
+ if (titleOnly) {
1084
+ treatment = titleOnly;
1085
+ } else if (!families.includes(family) && !meta.title && !pack.treatments.some((t) => allow.includes(t) && applicable(t) === true)) {
1086
+ // A family from outside the pack that takes none of its titles gets the pack's title for its role.
1087
+ allow = [pack["role-defaults"][role] || pack.treatments[0], ...grid.familyAllows("text-column")];
1088
+ treatment = pack.treatments.find((t) => allow.includes(t) && applicable(t) === true) || allow[0];
1089
+ notes.push(`slide ${n}: the ${family} family takes none of the ${pack.id} titles; drawn under ${treatment}`);
1090
+ } else {
1091
+ treatment = grid.pickTreatment({
1092
+ pack, family, role, override: meta.title, slideNumber: n, used: new Set(), max: Infinity,
1093
+ applicable, onNote: (note) => notes.push(note),
1094
+ });
1095
+ }
1096
+ const candidates = meta.title || titleOnly ? [treatment] : [treatment, ...pack.treatments.filter((t) =>
1097
+ t !== treatment && allow.includes(t) && !["statement", "overlay"].includes(t) && applicable(t) === true)];
1098
+ return {
1099
+ ...base,
1100
+ family,
1101
+ role,
1102
+ treatment,
1103
+ candidates,
1104
+ ctx,
1105
+ statementText,
1106
+ supportText,
1107
+ items,
1108
+ regions: { title: { content: title } },
1109
+ };
1110
+ });
1111
+
1112
+ return { deck: { ...ast.deck }, slides, notes };
1113
+ }
1114
+
764
1115
  // ---------------------------------------------------------------------------
765
1116
  // Exports
766
1117
  // ---------------------------------------------------------------------------
@@ -25,7 +25,8 @@ function parseSimpleYaml(raw) {
25
25
  // Slide-local keys the engine acts on. This set is deliberately closed: a key
26
26
  // the engine ignores has no effect a reader can see, so admitting one buys
27
27
  // nothing and costs the body line it was indistinguishable from.
28
- const SLIDE_KEYS = new Set(["variant"]);
28
+ // `title` names the slide's title treatment when it should differ from the pack's role default.
29
+ const SLIDE_KEYS = new Set(["variant", "title"]);
29
30
 
30
31
  /**
31
32
  * Split the text after a `layout:` line into slide-local keys and body content.
@@ -79,15 +80,16 @@ function load(source) {
79
80
 
80
81
  const fm = parseSimpleYaml(fmRaw);
81
82
 
82
- if (!fm.template) {
83
- throw new Error("Frontmatter must declare a 'template' field");
83
+ if (!fm.template && !fm.tonality) {
84
+ throw new Error("Frontmatter must declare a 'template' or a 'tonality' field");
84
85
  }
85
86
  if (!fm.title) {
86
87
  throw new Error("Frontmatter must declare a 'title' field");
87
88
  }
88
89
 
89
90
  const deck = {
90
- template: fm.template,
91
+ // A tonality-only deck records its pack as the design it was written for.
92
+ template: fm.template || `tonality:${fm.tonality}`,
91
93
  title: fm.title,
92
94
  metadata: {},
93
95
  };