@litfamily/litmuse 0.1.0 → 0.1.2

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 (176) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +115 -26
  3. package/README_ko-KR.md +112 -23
  4. package/bin/litmuse.mjs +4 -0
  5. package/docs/assets/card/after.webp +0 -0
  6. package/docs/assets/card/idle.webp +0 -0
  7. package/docs/assets/card/working.webp +0 -0
  8. package/docs/assets/handoff/latest.webp +0 -0
  9. package/docs/assets/handoff/saved.webp +0 -0
  10. package/docs/assets/readme/demo.webp +0 -0
  11. package/lib/stage-card.mjs +180 -5
  12. package/package.json +7 -1
  13. package/plugin/.muse-plugin/plugin.json +26 -11
  14. package/plugin/commands/lit-status.md +2 -1
  15. package/plugin/commands/lit.md +1 -1
  16. package/plugin/hooks/lib/digest.mjs +1 -1
  17. package/plugin/hooks/lib/next-step.mjs +68 -0
  18. package/plugin/hooks/lib/routes.mjs +59 -0
  19. package/plugin/hooks/lib/workflow-stack.mjs +318 -0
  20. package/plugin/hooks/session-start.mjs +29 -2
  21. package/plugin/hooks/user-prompt-submit.mjs +138 -7
  22. package/plugin/lib/capped-fetch.mjs +25 -0
  23. package/plugin/lib/pinned-fetch.mjs +79 -0
  24. package/plugin/lib/plan-task-rows.mjs +32 -0
  25. package/plugin/lib/render_pages.py +29 -1
  26. package/plugin/lib/rewind/collect.mjs +679 -0
  27. package/plugin/lib/rewind/gauge.mjs +72 -0
  28. package/plugin/lib/rewind/next-step.mjs +91 -0
  29. package/plugin/lib/rewind/redact.mjs +83 -0
  30. package/plugin/lib/rewind/transcripts.mjs +227 -0
  31. package/plugin/skills/frontend-ui-ux/SKILL.md +24 -9
  32. package/plugin/skills/frontend-ui-ux/kit/components.css +414 -0
  33. package/plugin/skills/frontend-ui-ux/kit/fonts.json +22 -0
  34. package/plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  35. package/plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  36. package/plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  37. package/plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  38. package/plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  39. package/plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  40. package/plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  41. package/plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  42. package/plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
  43. package/plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -1
  44. package/plugin/skills/frontend-ui-ux/references/craft-floor.md +9 -4
  45. package/plugin/skills/frontend-ui-ux/references/default-editorial-pixel.json +59 -7
  46. package/plugin/skills/frontend-ui-ux/references/house-styles.md +234 -0
  47. package/plugin/skills/frontend-ui-ux/references/motion-guide.md +3 -3
  48. package/plugin/skills/frontend-ui-ux/references/slop-register.md +7 -7
  49. package/plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  50. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-lock.mjs +196 -0
  51. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-page.mjs +31 -10
  52. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-rules.mjs +19 -7
  53. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-static.mjs +4 -0
  54. package/plugin/skills/frontend-ui-ux/scripts/interface-probe.mjs +59 -7
  55. package/plugin/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
  56. package/plugin/skills/lit-adhd/SKILL.md +188 -0
  57. package/plugin/skills/lit-adhd/references/card-examples.md +121 -0
  58. package/plugin/skills/lit-adhd/references/status-taxonomy.md +43 -0
  59. package/plugin/skills/lit-adhd/scripts/rewind.mjs +11 -0
  60. package/plugin/skills/lit-diagram-drawer/SKILL.md +3 -3
  61. package/plugin/skills/lit-diagram-drawer/scripts/diagram-craft-rules.mjs +1 -2
  62. package/plugin/skills/lit-diagram-drawer/scripts/visual-quality.mjs +0 -1
  63. package/plugin/skills/lit-docx/SKILL.md +1 -1
  64. package/plugin/skills/lit-hijack/SKILL.md +589 -0
  65. package/plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
  66. package/plugin/skills/lit-hijack/references/clean-room-build.md +133 -0
  67. package/plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
  68. package/plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
  69. package/plugin/skills/lit-hijack/references/parity-and-report.md +133 -0
  70. package/plugin/skills/lit-hijack/scripts/hijack.mjs +879 -0
  71. package/plugin/skills/lit-pptx/SKILL.md +20 -9
  72. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  73. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  74. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  75. package/plugin/skills/lit-pptx/fonts/provenance.json +32 -1
  76. package/plugin/skills/lit-pptx/references/direction-step.md +19 -2
  77. package/plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
  78. package/plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
  79. package/plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
  80. package/plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
  81. package/plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
  82. package/plugin/skills/lit-pptx/references/tonalities/bauhaus.md +112 -0
  83. package/plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +118 -0
  84. package/plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +125 -0
  85. package/plugin/skills/lit-pptx/references/tonalities/newsroom.md +115 -0
  86. package/plugin/skills/lit-pptx/references/tonalities/riso.md +109 -0
  87. package/plugin/skills/lit-pptx/scripts/compile-deck.js +1 -1
  88. package/plugin/skills/lit-pptx/scripts/craft_extras.py +136 -92
  89. package/plugin/skills/lit-pptx/scripts/embed_fonts.py +13 -1
  90. package/plugin/skills/lit-pptx/scripts/lib/grid-resolver.js +105 -116
  91. package/plugin/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
  92. package/plugin/skills/lit-pptx/scripts/lib/render-pack.js +368 -561
  93. package/plugin/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
  94. package/plugin/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
  95. package/plugin/skills/lit-pptx/scripts/qa_deck.py +4 -4
  96. package/plugin/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
  97. package/plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
  98. package/plugin/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
  99. package/plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  100. package/plugin/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
  101. package/plugin/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
  102. package/plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  103. package/plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  104. package/plugin/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
  105. package/plugin/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
  106. package/plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  107. package/plugin/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
  108. package/plugin/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
  109. package/plugin/skills/lit-recap/SKILL.md +3 -0
  110. package/plugin/skills/lit-schematic/SKILL.md +241 -0
  111. package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +14 -0
  112. package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +8 -0
  113. package/plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +22 -0
  114. package/plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +73 -0
  115. package/plugin/skills/lit-schematic/references/cutaway-outputs.md +25 -0
  116. package/plugin/skills/lit-schematic/references/isometric-craft.md +24 -0
  117. package/plugin/skills/lit-schematic/references/journal-figures.md +19 -0
  118. package/plugin/skills/lit-schematic/references/kit-api.md +27 -0
  119. package/plugin/skills/lit-schematic/references/pfd-and-layout.md +20 -0
  120. package/plugin/skills/lit-schematic/references/pid-drafting.md +26 -0
  121. package/plugin/skills/lit-schematic/references/pid-symbols.md +27 -0
  122. package/plugin/skills/lit-schematic/references/verification.md +24 -0
  123. package/plugin/skills/lit-schematic/scripts/doctor.mjs +13 -0
  124. package/plugin/skills/lit-schematic/scripts/lib/figure.mjs +49 -0
  125. package/plugin/skills/lit-schematic/scripts/lib/isa.mjs +18 -0
  126. package/plugin/skills/lit-schematic/scripts/lib/iso.mjs +58 -0
  127. package/plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +10 -0
  128. package/plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +241 -0
  129. package/plugin/skills/lit-schematic/scripts/lib/sheet.mjs +446 -0
  130. package/plugin/skills/lit-schematic/scripts/lib/svg.mjs +39 -0
  131. package/plugin/skills/lit-schematic/scripts/lib/theme.mjs +25 -0
  132. package/plugin/skills/lit-schematic/scripts/motion.mjs +8 -0
  133. package/plugin/skills/lit-schematic/scripts/qa.mjs +21 -0
  134. package/plugin/skills/lit-schematic/scripts/render.mjs +38 -0
  135. package/plugin/skills/lit-schematic/scripts/scaffold.mjs +73 -0
  136. package/plugin/skills/lit-scientific-visualization/SKILL.md +2 -0
  137. package/plugin/skills/lit-typographic-motion/SKILL.md +12 -8
  138. package/plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +11 -0
  139. package/plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
  140. package/plugin/skills/lit-typographic-motion/engine/core/presets.mjs +1 -1
  141. package/plugin/skills/lit-typographic-motion/engine/node/assets.mjs +218 -0
  142. package/plugin/skills/lit-typographic-motion/engine/node/blender.mjs +144 -0
  143. package/plugin/skills/lit-typographic-motion/engine/node/board.mjs +136 -0
  144. package/plugin/skills/lit-typographic-motion/engine/node/done.mjs +42 -3
  145. package/plugin/skills/lit-typographic-motion/engine/node/gallery.mjs +120 -0
  146. package/plugin/skills/lit-typographic-motion/engine/node/house.mjs +44 -0
  147. package/plugin/skills/lit-typographic-motion/engine/node/pipeline.mjs +18 -7
  148. package/plugin/skills/lit-typographic-motion/engine/node/runtime.mjs +6 -3
  149. package/plugin/skills/lit-typographic-motion/engine/node/scaffold.mjs +210 -0
  150. package/plugin/skills/lit-typographic-motion/engine/node/score.mjs +639 -0
  151. package/plugin/skills/lit-typographic-motion/engine/node/sound-cli.mjs +21 -4
  152. package/plugin/skills/lit-typographic-motion/engine/node/sound.mjs +126 -27
  153. package/plugin/skills/lit-typographic-motion/engine/node/stage-serve.mjs +29 -5
  154. package/plugin/skills/lit-typographic-motion/engine/node/stage.mjs +82 -14
  155. package/plugin/skills/lit-typographic-motion/engine/node/styles.mjs +214 -0
  156. package/plugin/skills/lit-typographic-motion/engine/node/treatment.mjs +311 -10
  157. package/plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  158. package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  159. package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  160. package/plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  161. package/plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +69 -0
  162. package/plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
  163. package/plugin/skills/lit-typographic-motion/references/depth.md +122 -0
  164. package/plugin/skills/lit-typographic-motion/references/sound.md +74 -16
  165. package/plugin/skills/lit-typographic-motion/references/stage.md +29 -5
  166. package/plugin/skills/lit-typographic-motion/references/studio.md +245 -0
  167. package/plugin/skills/lit-typographic-motion/references/treatment.md +31 -8
  168. package/plugin/skills/lit-typographic-motion/scripts/motion.mjs +48 -7
  169. package/plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
  170. package/plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
  171. package/plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
  172. package/plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
  173. package/plugin/skills/readme-studio/SKILL.md +1 -1
  174. package/plugin/skills/readme-studio/templates/hyperframes-cover/package-lock.json +3 -3
  175. package/plugin/skills/readme-studio/templates/remotion-cover/package-lock.json +3 -3
  176. package/plugin/skills/start-work/SKILL.md +6 -0
@@ -4,20 +4,14 @@
4
4
  * grid-resolver.js — where things go on a slide, read from a tonality pack and two dials.
5
5
  *
6
6
  * A pack never reaches this file as a name: it arrives as values (palette roles, a ramp, the
7
- * treatments it allows, its density and variance). The density dial picks one of three grids and
8
- * the spacing tokens; a title treatment fixes where the title sits, what is drawn with it and where
9
- * the body starts; a layout family decides which treatments a slide may take. Geometry is worked
10
- * out in points on the 960 x 540 (16:9) or 720 x 540 (4:3) canvas; the renderer converts to inches.
7
+ * treatments it allows, its density and variance). The density dial picks the spacing tokens; a
8
+ * title treatment styles the title inside its fixed zone and decides what is drawn with it; a
9
+ * layout family decides which treatments a slide may take. Geometry is worked out in points on
10
+ * the 960 x 540 (16:9) or 720 x 540 (4:3) canvas; the renderer converts to inches.
11
11
  */
12
12
 
13
- const CANVAS = { "16:9": { W: 960, H: 540 }, "4:3": { W: 720, H: 540 } };
14
-
15
- // Side margin, column width and gutter of the airy, standard, dense and compact grids. Each sums
16
- // exactly to the canvas width over 12 columns and 11 gutters.
17
- const GRIDS = {
18
- "16:9": { airy: { margin: 60, col: 48, gutter: 24 }, standard: { margin: 48, col: 50, gutter: 24 }, dense: { margin: 36, col: 52, gutter: 24 }, compact: { margin: 24, col: 54, gutter: 24 } },
19
- "4:3": { airy: { margin: 54, col: 40, gutter: 12 }, standard: { margin: 42, col: 42, gutter: 12 }, dense: { margin: 30, col: 44, gutter: 12 }, compact: { margin: 24, col: 45, gutter: 12 } },
20
- };
13
+ const SG = require("./slide-grid");
14
+ const { CANVAS } = SG;
21
15
 
22
16
  const RAMPS = {
23
17
  presented: { source: 12, label: 14, body: 18, lead: 24, title: 32, display: 44, cover: 56, hero: 72 },
@@ -29,19 +23,22 @@ const RAMPS = {
29
23
  const RAMP_ORDER = ["source", "label", "body", "lead", "title", "display", "cover", "hero"];
30
24
  const SCALE = [6, 12, 18, 24, 36, 48, 72];
31
25
 
32
- // Vertical zones shared by every canvas (all are 540 pt tall).
33
- const ZONE = { top: 36, body: 120, floor: 486, footer: 498, footerH: 18 };
26
+ // The content zones of the shared slide grid, the same on both canvases.
27
+ const ZONE = (() => {
28
+ const z = SG.ZONES.content;
29
+ return { top: z.title.y, body: z.body.y, floor: z.body.y + z.body.h, footer: z.footer.y, footerH: z.footer.h };
30
+ })();
34
31
 
35
- // What the density dial sets: grid, ramp, gaps, padding, table rows and the empty band a content
36
- // slide may leave under its last line. From density 5 a deck reads on the reading ramp (body 14 pt);
37
- // density 10 is the compact step: 24 pt margins, body 13 pt, titles that sit close over the body.
32
+ // What the density dial sets: ramp, gaps, padding, table rows and the empty band a content slide
33
+ // may leave under its last line; the zones themselves never move. From density 5 a deck reads on
34
+ // the reading ramp (body 14 pt); density 10 is the compact step: body 13 pt and the tightest gaps.
38
35
  const DENSITY_BANDS = [
39
- { upTo: 2, grid: "airy", ramp: "presented", item: 18, group: 36, block: 48, pad: 24, rowMin: 36, maxLines: 6, cap: 0.28, stepUp: true },
40
- { upTo: 4, grid: "standard", ramp: "presented", item: 12, group: 24, block: 36, pad: 24, rowMin: 32, maxLines: 8, cap: 0.24, stepUp: true },
41
- { upTo: 6, grid: "standard", ramp: "reading", item: 6, group: 18, block: 24, pad: 18, rowMin: 24, maxLines: 12, cap: 0.18, stepUp: true },
42
- { upTo: 8, grid: "dense", ramp: "reading", item: 6, group: 12, block: 24, pad: 12, rowMin: 22, maxLines: 16, cap: 0.14, stepUp: true },
43
- { upTo: 9, grid: "dense", ramp: "reading", item: 6, group: 12, block: 24, pad: 12, rowMin: 20, maxLines: 18, cap: 0.12, stepUp: false },
44
- { upTo: 10, grid: "compact", ramp: "compact", item: 6, group: 12, block: 18, pad: 12, rowMin: 18, maxLines: 22, cap: 0.12, stepUp: false, tight: true },
36
+ { upTo: 2, ramp: "presented", item: 18, group: 36, block: 48, pad: 24, rowMin: 36, maxLines: 6, cap: 0.28, stepUp: true },
37
+ { upTo: 4, ramp: "presented", item: 12, group: 24, block: 36, pad: 24, rowMin: 32, maxLines: 8, cap: 0.24, stepUp: true },
38
+ { upTo: 6, ramp: "reading", item: 6, group: 18, block: 24, pad: 18, rowMin: 24, maxLines: 12, cap: 0.18, stepUp: true },
39
+ { upTo: 8, ramp: "reading", item: 6, group: 12, block: 24, pad: 12, rowMin: 22, maxLines: 16, cap: 0.14, stepUp: true },
40
+ { upTo: 9, ramp: "reading", item: 6, group: 12, block: 24, pad: 12, rowMin: 20, maxLines: 18, cap: 0.12, stepUp: false },
41
+ { upTo: 10, ramp: "compact", item: 6, group: 12, block: 18, pad: 12, rowMin: 18, maxLines: 22, cap: 0.12, stepUp: false, tight: true },
45
42
  ];
46
43
 
47
44
  const TREATMENTS = ["top-rule", "top-plain-large", "side-rail", "band", "statement", "overlay", "kicker-numeral", "bottom-anchor"];
@@ -95,10 +92,15 @@ function clampDial(value, fallback) {
95
92
  return Number.isFinite(n) ? Math.max(1, Math.min(10, n)) : fallback;
96
93
  }
97
94
 
95
+ /**
96
+ * The density band's tokens. A bold pack (`ramp: presented`, allowed only with `register: bold`)
97
+ * sets its type on the presented ramp at any density, so its titles and figures fill the same zones
98
+ * at presentation size; every other pack takes the band's ramp.
99
+ */
98
100
  function densityTokens(density, packRamp) {
99
101
  const d = clampDial(density, 5);
100
102
  const band = DENSITY_BANDS.find((b) => d <= b.upTo);
101
- const ramp = band.ramp || packRamp || "presented";
103
+ const ramp = packRamp === "presented" ? "presented" : band.ramp;
102
104
  return { density: d, ...band, ramp, sizes: RAMPS[ramp] };
103
105
  }
104
106
 
@@ -109,12 +111,13 @@ function varianceMax(variance, treatmentCount) {
109
111
  return Math.min(max, treatmentCount);
110
112
  }
111
113
 
112
- function makeGrid(canvas, name) {
114
+ /** The shared slide grid's columns on a canvas (the same for every pack and density). */
115
+ function makeGrid(canvas) {
113
116
  const { W, H } = CANVAS[canvas];
114
- const g = GRIDS[canvas][name];
117
+ const g = SG.COLUMNS[canvas];
115
118
  const pitch = g.col + g.gutter;
116
119
  return {
117
- canvas, name, W, H, ...g, pitch,
120
+ canvas, name: "compact", W, H, ...g, pitch,
118
121
  /** Columns a…b (1-based, inclusive) as x and width in points. */
119
122
  span(a, b) { return { x: g.margin + pitch * (a - 1), w: pitch * (b - a + 1) - g.gutter }; },
120
123
  colX(a) { return g.margin + pitch * (a - 1); },
@@ -279,7 +282,16 @@ function leading(text) {
279
282
  * How much wider than the estimate a face sets: heavier weights of the bundled faces run wider,
280
283
  * so a frame measured at the regular width would lose its last line.
281
284
  */
282
- function faceWidth(face) {
285
+ function faceWidth(face, text) {
286
+ // A monospaced Latin face sets every Latin character and space at 0.6 em, and its Hangul in the
287
+ // East Asian face; the factor is that advance over the estimate, or the Latin worst case unread.
288
+ if (face && face.ea) {
289
+ const est = textEms(text);
290
+ if (text == null || !est) return 1.26;
291
+ let mono = 0;
292
+ for (const ch of String(text)) mono += /[ᄀ-ᇿ㄰-㆏가-힣一-鿿]/u.test(ch) ? 0.94 * (face.bold ? 1.08 : 1.0) : 0.6;
293
+ return Math.max(1, mono / est);
294
+ }
283
295
  const font = (face && face.font) || "";
284
296
  return /9 Black/u.test(font) ? 1.14 : /7 Bold/u.test(font) ? 1.08 : /5 Medium/u.test(font) ? 1.05 : face && face.bold ? 1.04 : 1.0;
285
297
  }
@@ -375,86 +387,89 @@ function pickTreatment({ pack, family, role, override, slideNumber, used, max, a
375
387
  /**
376
388
  * Title frame, companion decoration and body zone of one treatment, in points.
377
389
  *
390
+ * Every treatment draws its title inside the shared title zone and leaves the body zone alone:
391
+ * a treatment picks the title's face, size and colour plus what is drawn with it; placement of
392
+ * title and body comes from the zone. A statement draws its sentence in the display title zone.
393
+ *
378
394
  * ctx: { grid, sizes, title, hero, decoration (Set), numeral, rail ("surface" or "field") }
379
- * Returns { title, decor[], numeral?, body: {a, b, y, bottom}, side?, lines } where body columns
380
- * a…b are on the deck grid. `fits` is false when the title does not fit the treatment's budget.
395
+ * Returns { title, decor[], numeral?, body: {a, b, y, bottom}, support?, lines } where body
396
+ * columns a…b sit on the deck grid. `fits` is false when the title overflows its zone.
381
397
  */
382
398
  function treatmentGeometry(id, ctx) {
383
399
  const { grid, sizes } = ctx;
384
- // Lines of the title at a size, in the face that sets it (ctx.wide: face role → width factor).
385
- const fit = (t, w, size, face = "title") => lineCount(t, w / ((ctx.wide && ctx.wide[face]) || 1), size);
400
+ // Lines of the title at a size, in the face that sets it (ctx.wide: face role → width factor or factor fn).
401
+ const fit = (t, w, size, face = "title") => {
402
+ const k = ctx.wide && ctx.wide[face];
403
+ return lineCount(t, w / ((typeof k === "function" ? k(t) : k) || 1), size);
404
+ };
386
405
  const text = ctx.title || "";
387
406
  const decor = [];
388
407
  const full = grid.span(1, 12);
389
408
  const out = { id, decor, fits: true };
390
- const box = (cols, y, size, lines, face, colour) => {
391
- const s = grid.span(cols[0], cols[1]);
392
- return { x: s.x, y, w: s.w, h: Math.ceil(lines * size * 1.15), size, face, colour, lines };
409
+ const T = SG.zone(grid.canvas, "content", "title");
410
+ const B = SG.zone(grid.canvas, "content", "body");
411
+ const body = { a: B.a, b: B.b, y: B.y, bottom: B.y + B.h };
412
+ // The title frame is the zone itself. Two lines at the title step take 1.15 leading, or 1.1 when
413
+ // that keeps them inside the zone; display type may set solid (1.0) to keep two of its lines in.
414
+ const frame = (size, face, colour) => {
415
+ const lines = fit(text, T.w, size, face);
416
+ const lineSpacing = [1.15, 1.1, ...(face === "display" ? [1.0] : [])].find((k) => lines * size * k <= T.h + 0.5) || 1.1;
417
+ return { x: T.x, y: T.y, w: T.w, h: T.h, size, face, colour, lines, lineSpacing, fits: lines * size * lineSpacing <= T.h + 0.5 };
393
418
  };
394
-
395
- // On the compact step (ctx.tight) a top title frame is as tall as its lines, the rule sits 6 pt
396
- // under it and the body 18 pt under the rule; a band keeps its height and the body follows it at 12.
397
- if (id === "top-rule" || id === "band") {
398
- const cols = id === "band" ? [1, 11] : [1, 12];
399
- const w = grid.span(cols[0], cols[1]).w;
400
- const lines = fit(text, w, sizes.title);
401
- out.fits = lines <= 1 || (lines <= 2 && sizes.title * 1.15 * 2 <= 60.5);
402
- const h = ctx.tight && id === "top-rule" ? Math.ceil(Math.max(1, lines) * sizes.title * 1.15) + 2 : 60;
403
- out.title = { ...box(cols, 36, sizes.title, lines, "title", id === "band" ? "on-field" : "ink"), h };
404
- if (id === "band") {
405
- decor.push({ x: 0, y: 0, w: grid.W, h: 108, role: "field", kind: "band" });
406
- out.body = { a: 1, b: 12, y: ctx.tight ? 120 : 132, bottom: ZONE.floor };
407
- } else {
408
- const ruleY = 36 + h + 6;
409
- if (ctx.decoration.has("accent-rule")) decor.push({ x: out.title.x, y: ruleY, w: grid.col, h: 4, role: "accent", kind: "rule" });
419
+ // Where the title's last line ends inside the frame (a rule below it follows the text, not the zone).
420
+ const textBottom = (f) => T.y + Math.ceil(Math.max(1, f.lines) * f.size * f.lineSpacing) + 2;
421
+
422
+ if (id === "top-rule" || id === "band" || id === "bottom-anchor" || id === "side-rail" || id === "kicker-numeral") {
423
+ const f = frame(sizes.title, "title", id === "band" ? "on-field" : "ink");
424
+ out.fits = f.fits && f.lines <= 2 && (id !== "kicker-numeral" || ctx.numeral != null);
425
+ out.title = f;
426
+ out.body = body;
427
+ if (id === "top-rule") {
428
+ const ruleY = textBottom(f) + 4;
429
+ if (ctx.decoration.has("accent-rule")) decor.push({ x: T.x, y: ruleY, w: grid.col, h: 4, role: "accent", kind: "rule" });
410
430
  else decor.push({ x: full.x, y: ruleY, w: full.w, h: 0.75, role: "line", kind: "rule" });
411
- out.body = { a: 1, b: 12, y: ctx.tight ? ruleY + 18 : ZONE.body, bottom: ZONE.floor };
431
+ } else if (id === "band") {
432
+ // The field runs from the page's top edge to the foot of the title zone.
433
+ decor.push({ x: 0, y: 0, w: grid.W, h: T.y + T.h, role: "field", kind: "band" });
434
+ } else if (id === "bottom-anchor") {
435
+ // The title zone closes on a heavy ink rule across the page's columns.
436
+ decor.push({ x: full.x, y: T.y + T.h + 2, w: full.w, h: 3, role: "ink", kind: "rule" });
437
+ } else if (id === "side-rail") {
438
+ // A bar in the left margin: full page height in the rail colour where the pack fills its
439
+ // rail, else as tall as the title zone in the accent.
440
+ const filled = ctx.decoration.has("rail-fill");
441
+ const w = Math.round(grid.margin / 2);
442
+ decor.push(filled ? { x: 0, y: 0, w, h: grid.H, role: ctx.rail === "field" ? "field" : "accent", kind: "rail" }
443
+ : { x: 0, y: T.y, w, h: T.h, role: "accent", kind: "rail" });
444
+ } else {
445
+ const A = SG.zone(grid.canvas, "content", "aside");
446
+ out.numeral = { x: A.x, y: A.y, w: A.w, h: A.h, size: sizes.cover, text: String(ctx.numeral || ""), align: "right" };
412
447
  }
413
448
  return out;
414
449
  }
415
450
 
416
451
  if (id === "top-plain-large") {
417
- const w = grid.span(1, 10).w;
418
- const lines = fit(text, w, sizes.display, "display");
419
- out.fits = lines <= 2;
420
- // The frame reaches 8 pt into the gap below it: heavy display faces hang their descenders lower.
421
- const h = lines <= 1 ? 54 : 102;
422
- out.title = { ...box([1, 10], 36, sizes.display, lines, "display", "ink"), h: h + 8 };
423
- out.body = { a: 1, b: 12, y: 36 + h + (ctx.tight ? 18 : 24), bottom: ZONE.floor };
424
- return out;
425
- }
426
-
427
- if (id === "side-rail") {
428
- const s = grid.span(1, 4);
429
- const lines = fit(text, s.w, sizes.title);
430
- out.fits = lines <= 4;
431
- const filled = ctx.decoration.has("rail-fill");
432
- const rail = filled && ctx.rail === "field" ? "field" : "surface";
433
- out.title = box([1, 4], 36, sizes.title, lines, "title", rail === "field" ? "on-field" : "ink");
434
- // In a rail a few words fill a line, so one word moving down can add a line the estimate does
435
- // not see; the frame keeps a spare line (the text is top-aligned, so nothing moves).
436
- out.title.h = Math.ceil((lines + 1) * sizes.title * 1.15);
437
- const railRight = s.x + s.w + grid.gutter / 2;
438
- if (filled) decor.push({ x: 0, y: 0, w: railRight, h: grid.H, role: rail, kind: "rail" });
439
- else if (ctx.decoration.has("hairline-rule")) decor.push({ x: railRight, y: 36, w: 0.75, h: 450, role: "line", kind: "rule" });
440
- out.body = { a: 5, b: 12, y: 36, bottom: ZONE.floor };
452
+ const f = frame(sizes.display, "display", "ink");
453
+ out.fits = f.fits && f.lines <= 2;
454
+ out.title = f;
455
+ out.body = body;
441
456
  return out;
442
457
  }
443
458
 
444
459
  if (id === "statement") {
445
- // A pack may set the sentence beside a rail (`statementCols`); a hero pack sets it at the hero
446
- // step while it breaks into at most three balanced lines.
447
- const cols = ctx.statementCols || [1, 10];
448
- const w = grid.span(cols[0], cols[1]).w;
449
- const heroLines = fit(text, w, sizes.hero, "display");
450
- const useHero = ctx.hero && heroLines <= 3;
460
+ // The sentence centres in the display title zone, at the hero step in a hero pack while it
461
+ // holds to three lines there, else at the display step.
462
+ const D = SG.zone(grid.canvas, "display", "title");
463
+ const tall = (size, lines) => lines * size * 1.15 <= D.h + 0.5;
464
+ const heroLines = fit(text, D.w, sizes.hero, "display");
465
+ const useHero = ctx.hero && heroLines <= 3 && tall(sizes.hero, heroLines);
451
466
  const size = useHero ? sizes.hero : sizes.display;
452
- const lines = useHero ? heroLines : fit(text, w, sizes.display, "display");
453
- out.fits = lines <= 3;
454
- const h = Math.ceil(lines * size * 1.15);
455
- const y = Math.round((303 - h / 2) / 6) * 6;
456
- out.title = { ...box(cols, y, size, lines, "display", "ink"), h };
457
- out.support = { ...grid.span(cols[0], Math.min(12, cols[0] + 7)), y: y + h + 24 };
467
+ const lines = useHero ? heroLines : fit(text, D.w, sizes.display, "display");
468
+ out.fits = lines <= 3 && tall(size, lines);
469
+ out.title = { x: D.x, y: D.y, w: D.w, h: D.h, size, face: "display", colour: "ink", lines, lineSpacing: 1.15, valign: "middle",
470
+ textH: Math.ceil(lines * size * 1.15) };
471
+ const P = SG.zone(grid.canvas, "display", "support");
472
+ out.support = { x: P.x, y: P.y, w: P.w, bottom: P.y + P.h };
458
473
  out.body = null;
459
474
  return out;
460
475
  }
@@ -466,37 +481,11 @@ function treatmentGeometry(id, ctx) {
466
481
  // The panel is as tall as the title and its caption line (ending 24 pt under the caption at 432), so its
467
482
  // lower part never stands empty; the sample tag in the footer zone sits on the picture, clear of its edge.
468
483
  decor.push({ x: 0, y: 324, w: s.x + s.w + 24, h: 146, role: "field", kind: "panel", alpha: 12 });
469
- out.title = { ...box([1, 7], 348, sizes.title, lines, "title", "on-field"), h: 72 };
484
+ out.title = { x: s.x, y: 348, w: s.w, h: 72, size: sizes.title, face: "title", colour: "on-field", lines };
470
485
  out.image = { x: 0, y: 0, w: grid.W, h: grid.H };
471
486
  out.body = null;
472
487
  return out;
473
488
  }
474
-
475
- if (id === "kicker-numeral") {
476
- const w = grid.span(3, 12).w;
477
- const lines = fit(text, w, sizes.title);
478
- out.fits = lines <= 1 && ctx.numeral != null;
479
- const n = grid.span(1, 2);
480
- out.numeral = { x: n.x, y: 36, w: n.w, h: 72, size: sizes.cover, text: String(ctx.numeral || "") };
481
- out.title = { ...box([3, 12], 48, sizes.title, lines, "title", "ink"), h: 60 };
482
- out.body = { a: 1, b: 12, y: ctx.tight ? 120 : 132, bottom: ZONE.floor };
483
- return out;
484
- }
485
-
486
- if (id === "bottom-anchor") {
487
- // The title's last line ends on the body floor, so a one-line title leaves no band under itself;
488
- // the hairline sits 12 pt above the title and the visual's zone ends 24 pt above it.
489
- const s = grid.span(1, 9);
490
- const lines = fit(text, s.w, sizes.title);
491
- out.fits = lines <= 2;
492
- const h = Math.ceil(Math.max(1, lines) * sizes.title * 1.15) + 2;
493
- const y = ZONE.floor - h;
494
- out.title = { ...box([1, 9], y, sizes.title, lines, "title", "ink"), h };
495
- if (ctx.decoration.has("hairline-rule")) decor.push({ x: full.x, y: y - 12, w: full.w, h: 0.75, role: "line", kind: "rule" });
496
- out.side = { ...grid.span(10, 12), y, bottom: ZONE.floor };
497
- out.body = { a: 1, b: 12, y: 36, bottom: y - 24 };
498
- return out;
499
- }
500
489
  throw new Error(`unknown title treatment "${id}"`);
501
490
  }
502
491
 
@@ -514,7 +503,7 @@ function treatmentApplies(id, facts, ctx) {
514
503
  }
515
504
 
516
505
  module.exports = {
517
- CANVAS, GRIDS, RAMPS, SCALE, ZONE, TREATMENTS, FAMILIES, VARIANTS, LEGACY_LAYOUTS,
506
+ CANVAS, RAMPS, SCALE, ZONE, TREATMENTS, FAMILIES, VARIANTS, LEGACY_LAYOUTS,
518
507
  densityTokens, varianceMax, makeGrid, stepGap, stepRole, clampDial,
519
508
  textEms, lineCount, leading, textHeight, isKorean, faceWidth, balanceLines, keepLines,
520
509
  familyIds, resolveFamily, familyAllows, pickTreatment, treatmentGeometry, treatmentApplies,
@@ -831,6 +831,7 @@ function comparisonRole(blocks) {
831
831
 
832
832
  /** The ordered content of a pack slide, captions bound to the visual before them. */
833
833
  // Families whose geometry is a chart: a table of numbers there is drawn as one.
834
+ const DISPLAY_FAMILIES = new Set(["statement", "quote", "closing-statement"]);
834
835
  const CHART_FAMILIES = new Set(["chart-insight", "full-chart", "kpi-over-chart", "dashboard-grid"]);
835
836
  const NUMBER_CELL = /^[+\-−±▲▼△▽]?\s*[\d.,]+\s*(?:%p?|배|x|pt|bp|[가-힣]{1,3}|[A-Za-z]{1,3})?$/u;
836
837
 
@@ -998,7 +999,7 @@ function resolvePack(ast, templateObj) {
998
999
  const numbering = { figure: 0, table: 0, FIGURE, TABLE };
999
1000
  const facts = deckFacts(ast);
1000
1001
  const families = pack["layout-families"] || [];
1001
- const wide = { title: grid.faceWidth(pack.faces.title), display: grid.faceWidth(pack.faces.display) };
1002
+ const wide = { title: (t) => grid.faceWidth(pack.faces.title, t), display: (t) => grid.faceWidth(pack.faces.display, t) };
1002
1003
  let part = 0;
1003
1004
 
1004
1005
  const slides = ast.slides.map((slide) => {
@@ -1046,23 +1047,13 @@ function resolvePack(ast, templateObj) {
1046
1047
  } else if (!families.includes(family)) {
1047
1048
  notes.push(`slide ${n}: ${family} is not a ${pack.id} layout family (${families.join(", ")}); drawn with its own geometry`);
1048
1049
  }
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
1050
  const items = packItems(slide.blocks, numbering, CHART_FAMILIES.has(family));
1059
1051
  const paras = slide.blocks.filter((b) => b.type === "body").flatMap((b) => b.items || []);
1060
1052
  // A closing is not part of a section, so it never carries the part number.
1061
1053
  const closing = grid.VARIANTS.closing.includes(family);
1062
1054
  // An agenda numbers its own rows: a count beside its title reads as a stray section number (spec-v2 Amendments 8).
1063
1055
  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) };
1056
+ const ctx = { grid: pack.grid, sizes, title: plain(title), hero: pack.hero, decoration, numeral, rail: pack.rail, wide, tight: Boolean((pack.tok || {}).tight) };
1066
1057
  const facts1 = slideFacts(slide.blocks);
1067
1058
  // A quote under a statement title says the quote; the source line becomes the support line.
1068
1059
  let statementText = null;
@@ -1080,8 +1071,11 @@ function resolvePack(ast, templateObj) {
1080
1071
  };
1081
1072
  let allow = grid.familyAllows(family);
1082
1073
  let treatment;
1083
- if (titleOnly) {
1084
- treatment = titleOnly;
1074
+ // A statement, a quote and a closing statement sit in the display zone in every pack (the shared
1075
+ // slide grid): the statement title is theirs whether or not the pack lists it among its treatments.
1076
+ const display = DISPLAY_FAMILIES.has(family) && !meta.title && applicable("statement") === true;
1077
+ if (display) {
1078
+ treatment = "statement";
1085
1079
  } else if (!families.includes(family) && !meta.title && !pack.treatments.some((t) => allow.includes(t) && applicable(t) === true)) {
1086
1080
  // A family from outside the pack that takes none of its titles gets the pack's title for its role.
1087
1081
  allow = [pack["role-defaults"][role] || pack.treatments[0], ...grid.familyAllows("text-column")];
@@ -1093,7 +1087,7 @@ function resolvePack(ast, templateObj) {
1093
1087
  applicable, onNote: (note) => notes.push(note),
1094
1088
  });
1095
1089
  }
1096
- const candidates = meta.title || titleOnly ? [treatment] : [treatment, ...pack.treatments.filter((t) =>
1090
+ const candidates = meta.title || display ? [treatment] : [treatment, ...pack.treatments.filter((t) =>
1097
1091
  t !== treatment && allow.includes(t) && !["statement", "overlay"].includes(t) && applicable(t) === true)];
1098
1092
  return {
1099
1093
  ...base,