@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
@@ -46,6 +46,7 @@ EMU = 914400.0
46
46
  FILL_MIN = 0.45
47
47
  DISPLAY_PT = 30.0
48
48
  CAPTION_PT = 10.5
49
+ QUOTE_MARKS = {"“", "”", "‘", "’", "\"", "«", "»", "「", "」", "『", "』"}
49
50
  EDGE_TOL = 0.03
50
51
  FULL_BLEED = 0.92
51
52
  # A bracket with a digit in it is a citation marker ([1], [S3]); a blank has none.
@@ -144,6 +145,15 @@ def _outlined(shape):
144
145
  return len(line) > 0 or line.get("w") is not None
145
146
 
146
147
 
148
+ def _bleeds(box, W, H, tol=EDGE_TOL):
149
+ """A fill that touches two or more canvas edges (a band, a rail, a colour field) is page
150
+ decoration that may hold a title; it is not a card around content."""
151
+ if box is None:
152
+ return False
153
+ x, y, w, h = box
154
+ return sum([x <= tol, y <= tol, x + w >= W - tol, y + h >= H - tol]) >= 2
155
+
156
+
147
157
  def _contains(outer, inner, tol=0.05):
148
158
  return (inner[0] >= outer[0] - tol and inner[1] >= outer[1] - tol
149
159
  and inner[0] + inner[2] <= outer[0] + outer[2] + tol and inner[1] + inner[3] <= outer[1] + outer[3] + tol)
@@ -152,6 +162,8 @@ def _contains(outer, inner, tol=0.05):
152
162
  def slide_layout(shapes, W, H, page_area):
153
163
  """The slide's display flag, title, content area and content boxes, shared by the craft
154
164
  checks here and in craft_extras.py so both judge the same region."""
165
+ # A frame holding only a quotation mark is the quote's ornament, not a text block.
166
+ shapes = [s for s in shapes if _text(s) not in QUOTE_MARKS]
155
167
  texts = [(s, _text(s)) for s in shapes]
156
168
  # Cover, section and closing slides: display-size type, no data visual, and at
157
169
  # most two headline-sized lines. A KPI slide has several large numbers and is content.
@@ -159,12 +171,41 @@ def slide_layout(shapes, W, H, page_area):
159
171
  for s in shapes)
160
172
  large = [s for s, t in texts if t and _max_pt(s) >= 18]
161
173
  display = (any(_max_pt(s) >= DISPLAY_PT for s, t in texts if t) and not has_visual and len(large) <= 2)
162
-
163
- # Title: the largest text near the top.
174
+ # A slide the tonality engine names with a content family (`family@<id>`) is a content slide,
175
+ # however little it holds; covers, sections, statements, quotes and full-bleed pictures are the
176
+ # display slides.
177
+ family = next((s.name.split("@", 1)[1] for s in shapes if s.name.startswith("family@")), "")
178
+ if family:
179
+ display = bool(re.match(r"(cover|section)(-|$)|(statement|quote|closing-statement|image-full)$", family))
180
+
181
+ # Title: the frame the engine names `title@…`, else the largest text near the top.
164
182
  tops = [(s, _max_pt(s)) for s, t in texts if t and _box(s) and _box(s)[1] < H * 0.25]
165
- title = max(tops, key=lambda item: item[1])[0] if tops else None
183
+ named = next((s for s, t in texts if t and s.name.startswith("title@") and _box(s)), None)
184
+ title = named if named is not None else max(tops, key=lambda item: item[1])[0] if tops else None
166
185
  title_bottom = (_box(title)[1] + _box(title)[3]) if title is not None else H * 0.2
167
186
  area = (0.5, title_bottom + 0.1, W - 1.0, max(0.1, H - 0.55 - title_bottom - 0.1))
187
+ # A title on the body floor (bottom-anchor) closes the page: the content area is above it.
188
+ if title is not None and _box(title)[1] > H * 0.5:
189
+ area = (0.5, 0.5, W - 1.0, max(0.1, _box(title)[1] - 0.1 - 0.5))
190
+ # A title in a side rail: a narrow headline at the left edge, the body to its right. What stands in
191
+ # the rail under the title (its takeaways, criteria labels, the source) is the rail's, so the content
192
+ # area is the body beside the rail, from the top, and the rail's own blocks are not counted in it.
193
+ in_rail = []
194
+ rail_tops = [(named, _max_pt(named))] if named is not None and _box(named)[1] < H * 0.25 else tops
195
+ for shape, _ in rail_tops:
196
+ rail = _box(shape)
197
+ if _max_pt(shape) < 24 or rail[0] > W * 0.1 or rail[2] > W * 0.35:
198
+ continue
199
+ right = rail[0] + rail[2]
200
+ others = [_box(s) for s, t in texts if s is not shape and _box(s) and (t or _is_table(s) or _is_chart(s)) and _box(s)[1] < H - 0.55]
201
+ others += [_box(s) for s in shapes if s.shape_type == MSO_SHAPE_TYPE.PICTURE and _box(s) and not _decorative_picture(s, page_area)]
202
+ under = [b for b in others if b[0] + b[2] <= right + 0.05 and b[1] >= rail[1] + rail[3] - 0.05]
203
+ body = [b for b in others if b[0] >= right - 0.05]
204
+ if body and len(body) + len(under) == len(others):
205
+ title = shape
206
+ in_rail = under
207
+ area = (right + 0.1, rail[1], max(0.1, W - 0.5 - right - 0.1), max(0.1, H - 0.55 - rail[1]))
208
+ break
168
209
 
169
210
  visuals = []
170
211
  boxes = []
@@ -172,7 +213,7 @@ def slide_layout(shapes, W, H, page_area):
172
213
  if shape is title:
173
214
  continue
174
215
  box = _box(shape)
175
- if box is None:
216
+ if box is None or box in in_rail:
176
217
  continue
177
218
  if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
178
219
  if _decorative_picture(shape, page_area):
@@ -186,7 +227,7 @@ def slide_layout(shapes, W, H, page_area):
186
227
  if _max_pt(shape) <= CAPTION_PT:
187
228
  continue
188
229
  elif shape.shape_type in (MSO_SHAPE_TYPE.AUTO_SHAPE, MSO_SHAPE_TYPE.GROUP) and box[2] * box[3] >= 0.5 and (
189
- shape.shape_type == MSO_SHAPE_TYPE.GROUP or _filled(shape)):
230
+ shape.shape_type == MSO_SHAPE_TYPE.GROUP or _filled(shape)) and not _bleeds(box, W, H):
190
231
  if box[2] * box[3] < FULL_BLEED * page_area:
191
232
  visuals.append("card")
192
233
  else:
@@ -245,7 +286,8 @@ def analyse(path: Path) -> dict:
245
286
  rectangular = False
246
287
  if not rectangular or _filled(shape) or not _outlined(shape):
247
288
  continue
248
- if any(_contains(box, inner) for inner in content_boxes if inner != box):
289
+ # A hairline frame drawn exactly on a picture's edge is that picture's frame.
290
+ if any(_contains(box, inner) for inner in content_boxes):
249
291
  continue
250
292
  findings.append({"check": "stray_box", "slide": number, "shape": shape.name,
251
293
  "detail": [round(v, 2) for v in box],
@@ -0,0 +1,521 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * grid-resolver.js — where things go on a slide, read from a tonality pack and two dials.
5
+ *
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.
11
+ */
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
+ };
21
+
22
+ const RAMPS = {
23
+ presented: { source: 12, label: 14, body: 18, lead: 24, title: 32, display: 44, cover: 56, hero: 72 },
24
+ reading: { source: 10, label: 12, body: 14, lead: 18, title: 26, display: 36, cover: 44, hero: 60 },
25
+ // The reading ramp with its three small steps one point down: the readable floor of a page read
26
+ // closely on screen (body 13, table cells and captions 11, sources 9).
27
+ compact: { source: 9, label: 11, body: 13, lead: 18, title: 26, display: 36, cover: 44, hero: 60 },
28
+ };
29
+ const RAMP_ORDER = ["source", "label", "body", "lead", "title", "display", "cover", "hero"];
30
+ const SCALE = [6, 12, 18, 24, 36, 48, 72];
31
+
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 };
34
+
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.
38
+ 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 },
45
+ ];
46
+
47
+ const TREATMENTS = ["top-rule", "top-plain-large", "side-rail", "band", "statement", "overlay", "kicker-numeral", "bottom-anchor"];
48
+
49
+ // Content layout families: the slide role that picks a treatment from the pack, and the
50
+ // treatments the family's geometry allows.
51
+ const FAMILIES = {
52
+ "text-column": { role: "content", allow: ["top-rule", "band", "kicker-numeral", "side-rail", "top-plain-large"] },
53
+ "text-two-column": { role: "content", allow: ["top-rule", "band", "kicker-numeral", "top-plain-large"] },
54
+ "summary-box-list": { role: "content", allow: ["band", "top-rule", "kicker-numeral"] },
55
+ "sidebar-note": { role: "content", allow: ["top-rule", "kicker-numeral", "band", "side-rail"] },
56
+ agenda: { role: "content", allow: ["top-rule", "kicker-numeral", "band", "top-plain-large", "side-rail"] },
57
+ statement: { role: "statement", allow: ["statement"] },
58
+ quote: { role: "statement", allow: ["statement", "top-rule"] },
59
+ "kpi-row": { role: "data", allow: ["top-rule", "band", "top-plain-large", "side-rail", "bottom-anchor"] },
60
+ "table-insight": { role: "data", allow: ["top-rule", "band", "side-rail", "bottom-anchor"] },
61
+ "ledger-table": { role: "data", allow: ["top-rule", "band", "kicker-numeral"] },
62
+ "dashboard-grid": { role: "data", allow: ["top-rule", "band", "side-rail"] },
63
+ "kpi-over-chart": { role: "data", allow: ["top-rule", "band", "top-plain-large"] },
64
+ "matrix-2x2": { role: "data", allow: ["top-rule", "band", "side-rail"] },
65
+ comparison: { role: "data", allow: ["top-rule", "band", "top-plain-large", "side-rail", "bottom-anchor"] },
66
+ "chart-insight": { role: "data-takeaway", allow: ["top-rule", "band", "side-rail", "bottom-anchor", "top-plain-large"] },
67
+ "full-chart": { role: "data-takeaway", allow: ["bottom-anchor", "top-plain-large", "top-rule"] },
68
+ "big-number": { role: "data-takeaway", allow: ["top-rule", "side-rail", "bottom-anchor", "top-plain-large"] },
69
+ process: { role: "sequence", allow: ["top-rule", "band", "top-plain-large", "side-rail"] },
70
+ timeline: { role: "sequence", allow: ["top-rule", "band", "top-plain-large", "bottom-anchor"] },
71
+ "step-diagram": { role: "sequence", allow: ["top-rule", "top-plain-large", "band"] },
72
+ "image-full": { role: "image", allow: ["overlay"] },
73
+ "image-split": { role: "image", allow: ["top-plain-large", "side-rail", "bottom-anchor", "top-rule"] },
74
+ "photo-grid": { role: "image", allow: ["top-plain-large", "bottom-anchor", "top-rule"] },
75
+ "figure-pair": { role: "image", allow: ["top-rule", "bottom-anchor", "side-rail"] },
76
+ "figure-academic": { role: "image", allow: ["top-rule", "bottom-anchor", "side-rail"] },
77
+ "asymmetric-feature": { role: "image", allow: ["top-plain-large", "bottom-anchor", "top-rule"] },
78
+ method: { role: "definition", allow: ["top-rule", "side-rail", "band"] },
79
+ "references-appendix": { role: "reference", allow: ["top-rule", "band"] },
80
+ };
81
+
82
+ // Cover, section and closing variants. A closing other than the statement is a content slide whose
83
+ // title states the ask, so it takes the content role.
84
+ const VARIANTS = {
85
+ cover: ["cover-typographic", "cover-split-field", "cover-split-image", "cover-full-image", "cover-band", "cover-numeral", "cover-index", "cover-figures", "cover-rail"],
86
+ section: ["section-field", "section-numeral", "section-rail", "section-image", "section-rule", "section-band"],
87
+ closing: ["closing-ask", "closing-decision-box", "closing-statement", "closing-summary-list", "closing-contact-split"],
88
+ };
89
+ // The six layouts every existing deck uses, read as families; v1 archetype names that were renamed.
90
+ const LEGACY_LAYOUTS = { content: "text-column", main: "text-column", summary: "comparison", cover: "cover", section: "section", closing: "closing-statement", free: "free" };
91
+ const RENAMED = { "cover-split": "cover-split-field" };
92
+
93
+ function clampDial(value, fallback) {
94
+ const n = Math.round(Number(value));
95
+ return Number.isFinite(n) ? Math.max(1, Math.min(10, n)) : fallback;
96
+ }
97
+
98
+ function densityTokens(density, packRamp) {
99
+ const d = clampDial(density, 5);
100
+ const band = DENSITY_BANDS.find((b) => d <= b.upTo);
101
+ const ramp = band.ramp || packRamp || "presented";
102
+ return { density: d, ...band, ramp, sizes: RAMPS[ramp] };
103
+ }
104
+
105
+ /** Most distinct title treatments a deck may mix at this variance, never more than the pack allows. */
106
+ function varianceMax(variance, treatmentCount) {
107
+ const v = clampDial(variance, 5);
108
+ const max = v <= 3 ? 2 : v === 4 ? 3 : v <= 6 ? 4 : 5;
109
+ return Math.min(max, treatmentCount);
110
+ }
111
+
112
+ function makeGrid(canvas, name) {
113
+ const { W, H } = CANVAS[canvas];
114
+ const g = GRIDS[canvas][name];
115
+ const pitch = g.col + g.gutter;
116
+ return {
117
+ canvas, name, W, H, ...g, pitch,
118
+ /** Columns a…b (1-based, inclusive) as x and width in points. */
119
+ span(a, b) { return { x: g.margin + pitch * (a - 1), w: pitch * (b - a + 1) - g.gutter }; },
120
+ colX(a) { return g.margin + pitch * (a - 1); },
121
+ };
122
+ }
123
+
124
+ /** Next step of the spacing scale, never past `max`. */
125
+ function stepGap(value, max) {
126
+ const next = SCALE.find((s) => s > value);
127
+ return next != null && next <= max ? next : value;
128
+ }
129
+
130
+ /** Next ramp role above `role` (body → lead), or the same role at the top of the ramp. */
131
+ function stepRole(role) {
132
+ const i = RAMP_ORDER.indexOf(role);
133
+ return i >= 0 && i < RAMP_ORDER.length - 1 ? RAMP_ORDER[i + 1] : role;
134
+ }
135
+
136
+ // ── Text measure ─────────────────────────────────────────────────────────────
137
+
138
+ /** Advance of a string in ems: Hangul about one em, Latin about half, spaces less. */
139
+ function textEms(text) {
140
+ let ems = 0;
141
+ for (const ch of String(text || "")) {
142
+ if (/\s/u.test(ch)) ems += 0.28;
143
+ else if (/[ᄀ-ᇿ㄰-㆏가-힣一-鿿]/u.test(ch)) ems += 0.94;
144
+ else if (/[A-Z0-9%]/u.test(ch)) ems += 0.62;
145
+ else ems += 0.52;
146
+ }
147
+ return ems;
148
+ }
149
+
150
+ /** Lines a paragraph wraps to in a frame `widthPt` wide; a little slack covers word breaks. */
151
+ function lineCount(text, widthPt, sizePt) {
152
+ const perLine = Math.max(1, widthPt / sizePt);
153
+ return String(text || "").split("\n").reduce((sum, part) => sum + Math.max(1, Math.ceil((textEms(part) * 1.06) / perLine)), 0);
154
+ }
155
+
156
+ /**
157
+ * Display text broken into `lines` lines of near-equal width at word boundaries, so a two-line
158
+ * statement never ends on one orphaned word. Returns the text unchanged when it has no spaces,
159
+ * already holds a break, or no split keeps every line inside `perLineEms`.
160
+ */
161
+ function balanceLines(text, lines, perLineEms) {
162
+ const s = String(text || "");
163
+ if (lines < 2 || s.includes("\n")) return s;
164
+ const words = breakUnits(s);
165
+ if (words.length < lines) return s;
166
+ const width = (i, j) => textEms(words.slice(i, j).join(" ")) * 1.06;
167
+ // best[k][j]: the smallest widest line setting words 0..j-1 on k lines; the last line holds at
168
+ // least `tail` words.
169
+ const solve = (tail) => {
170
+ const best = Array.from({ length: lines + 1 }, () => new Array(words.length + 1).fill(Infinity));
171
+ const cut = Array.from({ length: lines + 1 }, () => new Array(words.length + 1).fill(-1));
172
+ best[0][0] = 0;
173
+ for (let k = 1; k <= lines; k++) {
174
+ for (let j = k; j <= words.length; j++) {
175
+ const last = k === lines ? Math.min(j - tail, j - 1) : j - 1;
176
+ for (let i = k - 1; i <= last; i++) {
177
+ const v = Math.max(best[k - 1][i], width(i, j));
178
+ if (v < best[k][j]) { best[k][j] = v; cut[k][j] = i; }
179
+ }
180
+ }
181
+ }
182
+ if (best[lines][words.length] > perLineEms) return null;
183
+ const out = [];
184
+ for (let k = lines, j = words.length; k > 0; k--) {
185
+ const i = cut[k][j];
186
+ out.unshift(words.slice(i, j).join(" "));
187
+ j = i;
188
+ }
189
+ return out;
190
+ };
191
+ // Widow control: a last line of one word only when no split keeps two there.
192
+ const set = (words.length >= lines + 1 && solve(2)) || solve(1);
193
+ return set ? set.join("\n") : s;
194
+ }
195
+
196
+ // Native Korean number words that take a counter after them ("다섯 분기", "한 곳"), and prefixes
197
+ // that belong to the figure after them ("연 6억 원", "약 40%").
198
+ const NUMBER_WORD = /(?:^|\s)(?:한|두|세|네|다섯|여섯|일곱|여덟|아홉|열|열한|열두|스무|몇|첫|연|월|주|약|총|최대|최소)$/u;
199
+ // A separator stays at the end of the line before it, never at the start of the next.
200
+ const SEPARATOR = /^[·・|/–—]$/u;
201
+
202
+ /** A short parenthetical ("(▲ +3.9%)", up to 16 characters) reads as one unit and never breaks inside. */
203
+ const SHORT_PAREN = 16;
204
+
205
+ function closesShort(open, w) {
206
+ const at = open.lastIndexOf("(");
207
+ return at >= 0 && !open.slice(at).includes(")") && `${open.slice(at)} ${w}`.length <= SHORT_PAREN;
208
+ }
209
+
210
+ /**
211
+ * Words a line may break between; a number stays with the word after it ("30억 원을", "4 분기", "다섯 분기"),
212
+ * and a short parenthetical stays whole.
213
+ */
214
+ function breakUnits(s) {
215
+ const words = String(s).split(/ +/u).filter(Boolean);
216
+ const joined = [];
217
+ for (let i = 0; i < words.length; i++) {
218
+ let unit = words[i];
219
+ // Join up to the word that closes the parenthesis, only when it closes within the short length.
220
+ let j = i;
221
+ let probe = unit;
222
+ while (j + 1 < words.length && closesShort(probe, words[j + 1])) {
223
+ probe += ` ${words[++j]}`;
224
+ if (probe.slice(probe.lastIndexOf("(")).includes(")")) { unit = probe; i = j; break; }
225
+ }
226
+ joined.push(unit);
227
+ }
228
+ return joined.reduce((out, w) => {
229
+ if (out.length && SEPARATOR.test(w)) out[out.length - 1] += ` ${w}`;
230
+ else if (out.length && (/[\d억만조천]$/u.test(out[out.length - 1]) || NUMBER_WORD.test(out[out.length - 1]))) out[out.length - 1] += ` ${w}`;
231
+ else out.push(w);
232
+ return out;
233
+ }, []);
234
+ }
235
+
236
+ /**
237
+ * Body text broken at spaces only, filling each line in turn, for a frame that holds `maxLines`.
238
+ * A renderer's own line breaking may split a number from the Hangul after it ("51 / 점이라는",
239
+ * "(10 / 월"), so text that wraps gets its breaks here, where a number keeps its unit and a word
240
+ * stays whole; a one-word last line takes a word from the line above when it fits. The text is
241
+ * returned unchanged when it fits one line, already holds a break, has a word wider than a line,
242
+ * or needs more lines than the frame holds.
243
+ */
244
+ function keepLines(text, perLineEms, maxLines, slack = 1.06) {
245
+ const s = String(text || "");
246
+ const width = (t) => textEms(t.replace(/\*\*/gu, "")) * slack;
247
+ if (s.includes("\n") || width(s) <= perLineEms) return s;
248
+ const words = breakUnits(s);
249
+ if (words.some((w) => width(w) > perLineEms)) return s;
250
+ // Lines are lists of break units, so the widow pull below never splits a number from its word.
251
+ const lines = [];
252
+ for (const w of words) {
253
+ const last = lines[lines.length - 1];
254
+ if (last != null && width([...last, w].join(" ")) <= perLineEms) last.push(w);
255
+ else lines.push([w]);
256
+ }
257
+ const n = lines.length;
258
+ if (n >= 2 && lines[n - 1].length === 1 && !/ /u.test(lines[n - 1][0])) {
259
+ const prev = lines[n - 2];
260
+ const moved = [prev[prev.length - 1], ...lines[n - 1]];
261
+ if (prev.length >= 2 && width(moved.join(" ")) <= perLineEms) { lines[n - 2] = prev.slice(0, -1); lines[n - 1] = moved; }
262
+ }
263
+ // The estimate runs wide of the faces, so a set that needs a line more than the frame holds is
264
+ // tried once more at the bare estimate before the renderer is left to break the text.
265
+ if (n > maxLines) return slack > 1 ? keepLines(s, perLineEms, maxLines, 1) : s;
266
+ return lines.map((l) => l.join(" ")).join("\n");
267
+ }
268
+
269
+ function isKorean(text) {
270
+ return /[가-힣]/u.test(String(text || ""));
271
+ }
272
+
273
+ /** Leading for body text: Hangul needs more line space than Latin at the same size. */
274
+ function leading(text) {
275
+ return isKorean(text) ? 1.4 : 1.25;
276
+ }
277
+
278
+ /**
279
+ * How much wider than the estimate a face sets: heavier weights of the bundled faces run wider,
280
+ * so a frame measured at the regular width would lose its last line.
281
+ */
282
+ function faceWidth(face) {
283
+ const font = (face && face.font) || "";
284
+ 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
+ }
286
+
287
+ function textHeight(text, widthPt, sizePt, lineSpacing) {
288
+ return lineCount(text, widthPt, sizePt) * sizePt * (lineSpacing || leading(text));
289
+ }
290
+
291
+ // ── Families and treatments ─────────────────────────────────────────────────
292
+
293
+ function familyIds() {
294
+ return [...Object.keys(FAMILIES), ...VARIANTS.cover, ...VARIANTS.section, ...VARIANTS.closing];
295
+ }
296
+
297
+ /**
298
+ * The family a slide's `layout:` names, its kind and the role that picks its title treatment.
299
+ * Legacy layout names keep working; an unknown name is an error that lists what exists.
300
+ */
301
+ function resolveFamily(layout, slideNumber) {
302
+ const name = RENAMED[layout] || layout;
303
+ const legacy = LEGACY_LAYOUTS[name];
304
+ if (legacy === "cover") return { family: null, kind: "cover", legacyLayout: name };
305
+ if (legacy === "section") return { family: null, kind: "section", legacyLayout: name };
306
+ if (legacy === "free") return { family: "free", kind: "free", legacyLayout: name };
307
+ const family = legacy || name;
308
+ if (FAMILIES[family]) return { family, kind: "content", role: FAMILIES[family].role, ...(legacy ? { legacyLayout: name } : {}) };
309
+ if (VARIANTS.cover.includes(family)) return { family, kind: "cover" };
310
+ if (VARIANTS.section.includes(family)) return { family, kind: "section" };
311
+ if (family === "closing-statement") return { family, kind: "content", role: "statement", ...(legacy ? { legacyLayout: name } : {}) };
312
+ if (VARIANTS.closing.includes(family)) return { family, kind: "content", role: "content" };
313
+ throw new Error(
314
+ `Slide ${slideNumber}: layout "${layout}" is neither a layout family nor a legacy layout. ` +
315
+ `Families: ${familyIds().join(", ")}. Legacy layouts: ${Object.keys(LEGACY_LAYOUTS).join(", ")}`
316
+ );
317
+ }
318
+
319
+ /**
320
+ * Treatments a family may take. A closing that is a content slide states the ask as a top title
321
+ * (S8.1 #20), so it takes only the top treatments.
322
+ */
323
+ function familyAllows(family) {
324
+ if (FAMILIES[family]) return FAMILIES[family].allow;
325
+ if (family === "closing-statement") return ["statement"];
326
+ if (VARIANTS.closing.includes(family)) return ["top-rule", "band", "top-plain-large"];
327
+ return FAMILIES["text-column"].allow;
328
+ }
329
+
330
+ /**
331
+ * Choose the slide's title treatment: the per-slide `title:` key when given (it must be a known
332
+ * treatment, one of the pack's, allowed by the family and drawable on this slide), else the pack's
333
+ * default for the slide role, else the next pack treatment the family allows. The variance dial
334
+ * stops a deck from adding treatments past its maximum.
335
+ */
336
+ function pickTreatment({ pack, family, role, override, slideNumber, used, max, applicable, onNote }) {
337
+ const allow = familyAllows(family);
338
+ if (override) {
339
+ if (!TREATMENTS.includes(override)) {
340
+ throw new Error(`Slide ${slideNumber}: unknown title treatment "${override}". Treatments: ${TREATMENTS.join(", ")}`);
341
+ }
342
+ if (!pack.treatments.includes(override)) {
343
+ throw new Error(`Slide ${slideNumber}: title "${override}" is not one of the ${pack.id} treatments (${pack.treatments.join(", ")})`);
344
+ }
345
+ if (!allow.includes(override)) {
346
+ throw new Error(`Slide ${slideNumber}: the ${family} family does not take "${override}"; it allows ${allow.join(", ")}`);
347
+ }
348
+ const why = applicable(override);
349
+ if (why !== true) throw new Error(`Slide ${slideNumber}: title "${override}" cannot be drawn here: ${why}`);
350
+ return override;
351
+ }
352
+ const preferred = (pack.structure || {})[family] || pack["role-defaults"][role];
353
+ const order = [preferred, ...pack.treatments.filter((t) => t !== preferred)].filter(Boolean);
354
+ let candidates = order.filter((t) => pack.treatments.includes(t) && allow.includes(t) && applicable(t) === true);
355
+ if (!candidates.length) {
356
+ // A title longer than every budget still gets the first treatment it would otherwise take.
357
+ candidates = order.filter((t) => pack.treatments.includes(t) && allow.includes(t) && applicable(t) === BUDGET);
358
+ if (candidates.length && onNote) onNote(`slide ${slideNumber}: the title is longer than the ${candidates[0]} budget; shorten it`);
359
+ }
360
+ if (used.size >= max) {
361
+ const reuse = candidates.filter((t) => used.has(t));
362
+ if (reuse.length) candidates = reuse;
363
+ }
364
+ if (!candidates.length) {
365
+ throw new Error(
366
+ `Slide ${slideNumber}: no ${pack.id} treatment (${pack.treatments.join(", ")}) fits the ${family} family ` +
367
+ `(${allow.join(", ")}) on this slide; choose another layout family or tonality`
368
+ );
369
+ }
370
+ return candidates[0];
371
+ }
372
+
373
+ // ── Treatment geometry ──────────────────────────────────────────────────────
374
+
375
+ /**
376
+ * Title frame, companion decoration and body zone of one treatment, in points.
377
+ *
378
+ * 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.
381
+ */
382
+ function treatmentGeometry(id, ctx) {
383
+ 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);
386
+ const text = ctx.title || "";
387
+ const decor = [];
388
+ const full = grid.span(1, 12);
389
+ 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 };
393
+ };
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" });
410
+ 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 };
412
+ }
413
+ return out;
414
+ }
415
+
416
+ 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 };
441
+ return out;
442
+ }
443
+
444
+ 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;
451
+ 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 };
458
+ out.body = null;
459
+ return out;
460
+ }
461
+
462
+ if (id === "overlay") {
463
+ const s = grid.span(1, 7);
464
+ const lines = fit(text, s.w, sizes.title);
465
+ out.fits = lines <= 2;
466
+ // The panel is as tall as the title and its caption line (ending 24 pt under the caption at 432), so its
467
+ // lower part never stands empty; the sample tag in the footer zone sits on the picture, clear of its edge.
468
+ 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 };
470
+ out.image = { x: 0, y: 0, w: grid.W, h: grid.H };
471
+ out.body = null;
472
+ return out;
473
+ }
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
+ throw new Error(`unknown title treatment "${id}"`);
501
+ }
502
+
503
+ const BUDGET = "the title does not fit its budget";
504
+
505
+ /** Can this treatment be drawn on a slide with these facts? true, or the reason it cannot. */
506
+ function treatmentApplies(id, facts, ctx) {
507
+ if (id === "kicker-numeral" && ctx.numeral == null) return "no real number to set beside the title";
508
+ const g = treatmentGeometry(id, ctx);
509
+ if (!g.fits) return BUDGET;
510
+ if (id === "overlay" && !facts.image) return "it needs a full-bleed image";
511
+ if (id === "statement" && (facts.visual || facts.textBlocks > 1)) return "a statement carries one sentence and at most one support line";
512
+ if (id === "bottom-anchor" && !facts.visual && facts.textBlocks > 1) return "the title closes a page whose upper part is a visual";
513
+ return true;
514
+ }
515
+
516
+ module.exports = {
517
+ CANVAS, GRIDS, RAMPS, SCALE, ZONE, TREATMENTS, FAMILIES, VARIANTS, LEGACY_LAYOUTS,
518
+ densityTokens, varianceMax, makeGrid, stepGap, stepRole, clampDial,
519
+ textEms, lineCount, leading, textHeight, isKorean, faceWidth, balanceLines, keepLines,
520
+ familyIds, resolveFamily, familyAllows, pickTreatment, treatmentGeometry, treatmentApplies,
521
+ };