@litfamily/lithermes 1.0.17 → 1.0.19

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 (49) hide show
  1. package/README.md +21 -67
  2. package/README_Ko-KR.md +20 -65
  3. package/assets/lithermes-plugin/auto_update.py +4 -1
  4. package/assets/lithermes-plugin/core_routing.py +7 -2
  5. package/assets/lithermes-plugin/payload-version.json +42 -38
  6. package/assets/lithermes-plugin/plugin.yaml +1 -1
  7. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -2
  8. package/assets/lithermes-plugin/skills/lit-docx/references/page-composition.md +1 -3
  9. package/assets/lithermes-plugin/skills/lit-docx/scripts/convert_md_to_docx.py +9 -9
  10. package/assets/lithermes-plugin/skills/lit-docx/scripts/docx_design.py +9 -9
  11. package/assets/lithermes-plugin/skills/lit-docx/scripts/docx_layout.py +6 -6
  12. package/assets/lithermes-plugin/skills/lit-docx/templates/tonalities/brief.yaml +0 -1
  13. package/assets/lithermes-plugin/skills/lit-docx/templates/tonalities/journal.yaml +0 -1
  14. package/assets/lithermes-plugin/skills/lit-docx/templates/tonalities/manual.yaml +0 -1
  15. package/assets/lithermes-plugin/skills/lit-docx/templates/tonalities/memo.yaml +0 -1
  16. package/assets/lithermes-plugin/skills/lit-docx/templates/tonalities/proposal.yaml +0 -1
  17. package/assets/lithermes-plugin/skills/lit-docx/templates/tonalities/report.yaml +0 -1
  18. package/assets/lithermes-plugin/skills/lit-pptx/FORBIDDEN_TERMS.json +55 -0
  19. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/atlas.md +2 -2
  20. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/chalk.md +2 -3
  21. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/gazette.md +2 -2
  22. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/ledger.md +5 -5
  23. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/night.md +3 -3
  24. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/paper.md +2 -2
  25. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/signal.md +3 -3
  26. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/studio.md +2 -3
  27. package/assets/lithermes-plugin/skills/lit-pptx/scripts/deck_output.py +8 -8
  28. package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/render-pack.js +96 -96
  29. package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/template-registry.js +1 -1
  30. package/assets/lithermes-plugin/skills/lit-pptx/scripts/qa_deck.py +7 -7
  31. package/assets/lithermes-plugin/skills/lit-pptx/scripts/validate_pptx.py +8 -3
  32. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +0 -13
  33. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +0 -13
  34. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +0 -13
  35. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +2 -15
  36. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/night/pack.yaml +0 -13
  37. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/paper/pack.yaml +0 -13
  38. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/signal/pack.yaml +0 -13
  39. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/studio/pack.yaml +0 -13
  40. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +2 -2
  41. package/package.json +19 -3
  42. package/readme-assets/badge-version.svg +1 -1
  43. package/src/cli.js +164 -34
  44. package/src/lib/check.js +13 -1
  45. package/src/lib/config.js +94 -36
  46. package/src/lib/hud.js +33 -2
  47. package/src/lib/install.js +39 -1
  48. package/src/lib/skins.js +29 -9
  49. package/readme-assets/ascii-readme.svg +0 -5
@@ -55,7 +55,7 @@ function titleRuns(text, S, base) {
55
55
  });
56
56
  }
57
57
 
58
- /** Display text broken at word boundaries into lines of near-equal width, at the line count the frame was */
58
+ /** Breaks display text at word boundaries into lines of near-equal width, at the frame's measured line count. */
59
59
  function balanced(S, text, w, size, role, room = Infinity) {
60
60
  const s = String(text || "");
61
61
  if (/\*\*/u.test(s)) return s;
@@ -205,7 +205,7 @@ function bodySize(S, state, role = "body") {
205
205
  return state.stepped ? S.sizes[G.stepRole(role)] : S.sizes[role];
206
206
  }
207
207
 
208
- /** A point that opens with a bold run-in label reads "label */
208
+ /** A point that opens with a bold run-in label reads "label: rest"; a bold line alone is a heading. */
209
209
  function runIn(text) {
210
210
  const m = /^\*\*(.+?)\*\*\s+(\S[\s\S]*)$/u.exec(String(text == null ? "" : text));
211
211
  if (!m || /[::.)\]?!–—-]$/u.test(m[1].trim())) return text;
@@ -245,7 +245,7 @@ function measureTable(item, w, S, state) {
245
245
  const widths = headers.length ? shared().proportionalWidths(headers, rows, IN(w)).map((v) => v * 72) : [w];
246
246
  const cell = 6;
247
247
  const minRow = S.tok.rowMin * (state.anchor ? 1.25 : 1);
248
- // Rows are measured at the face's natural line (about 1.45 of the size with its gap), which is what office
248
+ // Rows are measured at the face's natural line (about 1.45 of the size with its gap), as office renderers set cells.
249
249
  const rowH = (cells, size) => Math.max(minRow, ...cells.map((t, i) => G.textHeight(plainText(t), (widths[i] || w) - cell * 2, size, 1.45) + cell * 2));
250
250
  const heights = [rowH(headers, header), ...rows.map((r) => rowH(r, data))];
251
251
  const tableH = heights.reduce((a, b) => a + b, 0);
@@ -370,7 +370,7 @@ function shiftGroup(p, dy) {
370
370
  }
371
371
  }
372
372
 
373
- /** Pitch for n rows that share a region */
373
+ /** Pitch for n rows that share a region; the last row ends on the region floor. */
374
374
  function rowPitch(n, rowH, R, gap, max) {
375
375
  if (n <= 1) return rowH;
376
376
  return Math.max(rowH + gap, Math.min(max, (R.bottom - R.y - rowH) / (n - 1)));
@@ -382,21 +382,21 @@ function textAt(paras, R, S, state, o = {}) {
382
382
  return { ...m, x: R.x, y: R.y, limit: R.bottom };
383
383
  }
384
384
 
385
- // ── Layout families ───────────────────────────────────────────────────────── Each plan gets ctx = { spec, S
385
+ // ── Layout families ───────────────────────────────────────────────────────── Each plan gets ctx and returns placements.
386
386
 
387
387
  /** A visual with its takeaway: beside it (share of the columns), or under it on a narrow body. */
388
388
  function visualWithText(ctx, visuals, texts, share, o = {}) {
389
389
  const { S, state, Z } = ctx;
390
390
  const out = [];
391
- // A visual alone (its takeaways in the rail, or none)
391
+ // A visual alone (its takeaways in the rail, or none) takes the body: a table opens its rows.
392
392
  if (!texts.length) return ((S.tok.tight && !ctx.railValues && valuesUnder(visuals, Z, S, state)) || openRows(stack(visuals, Z, S, state).placed, Z, S));
393
393
  if (!visuals.length) return stack(texts, Z, S, state).placed;
394
- // Takeaways too short for their column give the visual columns
394
+ // Takeaways too short for their column give the visual columns.
395
395
  if (!o.fixed && Z.b - Z.a + 1 >= 6 && visuals.some((v) => v.type === "image" || (v.content || {}).chart)) {
396
396
  const n = Z.b - Z.a + 1;
397
397
  const base = visualWithText(ctx, visuals, texts, share, { ...o, fixed: true });
398
398
  let pick = base;
399
- // A picture drawn short of its column (a wide one in a narrow column) takes columns while either side leaves
399
+ // A picture drawn short of its column takes columns while either side leaves over a fifth of the body empty.
400
400
  const target = visuals.some((v) => v.type === "image") ? 0.2 : RAIL_EMPTY;
401
401
  for (let step = 1; shortColumn(pick, Z.bottom, S).share > target; step++) {
402
402
  const k = o.textFirst ? share - step / n : share + step / n;
@@ -407,7 +407,7 @@ function visualWithText(ctx, visuals, texts, share, o = {}) {
407
407
  const size = (plan) => Math.max(0, ...plan.filter((q) => q.kind === "text" && !q.strip && !q.muted).map((q) => q.size || 0));
408
408
  if (!overflows(next) && size(next) >= size(base) && shortColumn(next, Z.bottom, S).share < shortColumn(pick, Z.bottom, S).share) pick = next;
409
409
  }
410
- // Takeaways too few for even a three-column column beside a chart run under it instead, side by side, and the
410
+ // Takeaways too few for a three-column column beside a chart run under it; the chart takes the full width.
411
411
  if (shortColumn(pick, Z.bottom, S).share > RAIL_EMPTY && visuals.some((v) => (v.content || {}).chart)) {
412
412
  const paras = texts.flatMap(itemParas);
413
413
  const half = Math.ceil(paras.length / 2);
@@ -431,7 +431,7 @@ function visualWithText(ctx, visuals, texts, share, o = {}) {
431
431
  let v = stack(visuals, vR, S, state);
432
432
  const col = takeawayColumn(ctx, texts, visuals, full, v.bottom);
433
433
  const t = col.t;
434
- // When the chart's values found no room beside it, they stand under the chart, which gives up the height the
434
+ // When the chart's values find no room beside it, they stand under the chart as a compact table.
435
435
  if (S.tok.tight && !col.values) {
436
436
  const under = valuesUnder(visuals, vR, S, state, true);
437
437
  if (under) v = under;
@@ -449,7 +449,7 @@ function visualWithText(ctx, visuals, texts, share, o = {}) {
449
449
  : stack(texts, below, S, state).placed;
450
450
  const end = Math.max(...notes.map((q) => q.y + q.h));
451
451
  if (end <= Z.bottom) {
452
- // Still short of the density band
452
+ // Still short of the density band, the table's rows open up first.
453
453
  let room = Z.bottom - end;
454
454
  let grow = 0;
455
455
  const table = top.placed.find((q) => q.kind === "table");
@@ -466,7 +466,7 @@ function visualWithText(ctx, visuals, texts, share, o = {}) {
466
466
  return [...top.placed, ...notes.map((q) => ({ ...q, y: q.y + dy }))];
467
467
  }
468
468
  }
469
- // A picture drawn shorter than its box (a wide one in a tall column) stands on the box floor when the takeaways
469
+ // A picture shorter than its box stands on the box floor when the takeaways beside it run past its top.
470
470
  for (const q of v.placed) if (q.kind === "image") q.beside = t.bottom;
471
471
  out.push(...v.placed, ...t.placed);
472
472
  if (S.deco.has("column-hairlines")) out.push(columnHairline(S, o.textFirst ? right : right, Z));
@@ -475,7 +475,7 @@ function visualWithText(ctx, visuals, texts, share, o = {}) {
475
475
  return stack([...visuals, ...texts], Z, S, state).placed;
476
476
  }
477
477
 
478
- /** A chart over its values as a compact table in region R */
478
+ /** A chart over its values as a compact table in region R; returns the placements or null. */
479
479
  function valuesUnder(visuals, R, S, state, asStack = false) {
480
480
  // Across the body the values run across (two or three rows under a wide chart); in a column, as columns.
481
481
  const order = R.w >= S.grid.span(1, 8).w ? [chartTable(visuals, true), chartTable(visuals)] : [chartTable(visuals), chartTable(visuals, true)];
@@ -510,7 +510,7 @@ function takeawayColumn(ctx, texts, visuals, full, visualBottom) {
510
510
  return { ...pick, strip, R };
511
511
  }
512
512
 
513
- /** A table standing alone in its region (its takeaways in a side title's rail) opens its rows, at most one */
513
+ /** A table alone in its region opens its rows while the region leaves more than the density band empty. */
514
514
  function openRows(placed, Z, S) {
515
515
  const tables = placed.filter((q) => q.kind === "table");
516
516
  if (tables.length !== 1 || placed.some((q) => q.flex)) return placed;
@@ -562,9 +562,9 @@ const PLANS = {
562
562
  const { visuals, texts } = sortItems(items);
563
563
  if (visuals.length && texts.length) return visualWithText(ctx, visuals, texts, 7 / 12);
564
564
  const placed = stack(items, R, S, state).placed;
565
- // Two to five points that each open with a bold head, and that leave the body more than the density band empty
565
+ // Two to five bold-headed points that leave the body more than the density band empty read as rows.
566
566
  const ents = !visuals.length && ctx.cls !== "side" ? entries(texts.flatMap(itemParas)) : [];
567
- // Groups of sub-points under bold heads, held to one reading measure narrower than the body, run in two columns
567
+ // Groups of sub-points under bold heads, held to one reading measure, run in two columns instead.
568
568
  const groups = ents.length >= 2 && ents.every((e) => e.head && !e.rest && e.children.length);
569
569
  if (groups && R.w < Z.w - S.grid.pitch) {
570
570
  const two = PLANS["text-two-column"](ctx);
@@ -602,7 +602,7 @@ const PLANS = {
602
602
  acc += heights[i];
603
603
  }
604
604
  let out = [textAt(paras.slice(0, cut), l, S, state), textAt(paras.slice(cut), r, S, state)];
605
- // Two sections of unequal length leave one column half empty
605
+ // Two sections of unequal length leave one column half empty, so the break moves to even them.
606
606
  if (shortColumn(out, Z.bottom, S).share > 0.35) {
607
607
  const evenest = (ok) => {
608
608
  let best = null;
@@ -676,7 +676,7 @@ const PLANS = {
676
676
  const m = measureText(itemParas(note), nR.w - pad * 2, S, state);
677
677
  out.push({ kind: "box", x: nR.x, y: nR.y, w: nR.w, h: m.h + pad * 2, surface: true, limit: Z.bottom },
678
678
  { ...m, x: nR.x + pad, y: nR.y + pad, limit: Z.bottom });
679
- // A short note beside a long main column leaves its column mostly empty
679
+ // A short note beside a long main column stands across the top; the points run in two columns.
680
680
  if (main.length && shortColumn(out, Z.bottom, S).share > RAIL_EMPTY && Z.b - Z.a + 1 >= 12) {
681
681
  // First a narrower note column at the body size (the note never set larger than the main points), the main
682
682
  const [mR3, nR3] = splitRegion(S, Z, 9 / 12);
@@ -693,7 +693,7 @@ const PLANS = {
693
693
  const boxH = lead.h + pad * 2;
694
694
  const below = { ...Z, y: Z.y + boxH + state.blockGap };
695
695
  const fits = (plan) => !plan.some((q) => !q.decor && q.y + q.h > Z.bottom + 0.5);
696
- // One column at the reading measure when it holds the points and reaches the density band
696
+ // The points take one column at the reading measure when it holds them and reaches the density band.
697
697
  for (const z of [S.sizes.lead, bodySize(S, state)]) {
698
698
  const m = measureText(itemParas(note).map((p) => ({ ...p, kind: "text" })), Math.min(Z.w - pad * 2, (korean ? 30 : 35) * z), S, state, { size: z });
699
699
  const top = Z.y + m.h + pad * 2 + state.blockGap;
@@ -754,7 +754,7 @@ const PLANS = {
754
754
  const { S, state, Z } = ctx;
755
755
  const paras = sortItems(ctx.items).texts.flatMap(itemParas).map((p) => ({ ...p, kind: "text" }));
756
756
  let size = S.tok.ramp === "presented" ? (state.stepped ? S.sizes.body : S.sizes.label) : bodySize(S, state);
757
- // Entries are scanned for a name or a year
757
+ // Entries are scanned for a name or a year, one per row.
758
758
  const fits = (z, w, n) => n * Math.max(...paras.slice(0, n).map((p) => G.textHeight(plainText(p.text), w, z, 1.3))) + 12 * (n - 1) <= Z.bottom - Z.y;
759
759
  let cols = [Z];
760
760
  if (!fits(size, Z.w, paras.length)) {
@@ -782,7 +782,7 @@ const PLANS = {
782
782
  if (!kpi) return PLANS["table-insight"](ctx);
783
783
  const others = visuals.filter((v) => v !== kpi);
784
784
  if (ctx.cls === "side" || Z.b - Z.a + 1 < 10 || (state.anchor && ctx.cls === "top")) {
785
- // Under a side title, or when the values take the room, they run down the body as rows
785
+ // Under a side title, or when the values take the room, they run down the body as rows.
786
786
  const cards = shared().kpiCards(kpi.content).slice(0, 6);
787
787
  const out = [];
788
788
  const restTexts = texts;
@@ -807,7 +807,7 @@ const PLANS = {
807
807
  const out = [rows, ...notes.placed];
808
808
  if (kpi.caption) out.push({ kind: "caption", item: { content: kpi.caption }, x: fR.x, y: fR.bottom - capH + 6, w: fR.w, h: capH - 6, limit: Z.bottom });
809
809
  if (shortColumn(out, Z.bottom, S).share <= 0.35) return out;
810
- // Takeaways too few to fill the column beside the rows
810
+ // Takeaways too few to fill the column beside the rows stand under them, side by side.
811
811
  const paras = texts.flatMap(itemParas);
812
812
  const half = Math.ceil(paras.length / 2);
813
813
  // Up to three points read down one column at the reading measure; four or more stand side by side.
@@ -868,7 +868,7 @@ const PLANS = {
868
868
  const R = tR ? region(S, tR.a + 1, tR.b, Z.y, Z.bottom)
869
869
  : ctx.cls === "bottom" ? { ...Z, bottom: y - state.blockGap } : { ...Z, y: Z.y + blockH + state.blockGap };
870
870
  const evidence = stack(texts, R, S, { ...state, stepped: true });
871
- // Beside a number on the floor the evidence ends level with the number block, so it reads with the number
871
+ // Beside a number on the floor the evidence ends level with the number block, not at the page top.
872
872
  const lift = tR && ctx.cls === "bottom" ? Math.max(0, Math.min(y + blockH, Z.bottom) - evidence.bottom) : 0;
873
873
  out.push(...evidence.placed.map((q) => ({ ...q, y: q.y + lift })));
874
874
  }
@@ -947,7 +947,7 @@ const PLANS = {
947
947
  return { head: headIdx >= 0 ? paras[headIdx].text : null,
948
948
  items: paras.filter((_, j) => j !== headIdx).map((p) => String(p.text).replace(/^\(\d+\)\s*/u, "")) };
949
949
  });
950
- // Points that open with the same label on both sides
950
+ // Points that open with the same label on both sides ("비용: …") read as criteria rows.
951
951
  const LABEL = /^([^::]{1,24})[::]\s*(.+)$/u;
952
952
  const isLabel = (t) => { const m = LABEL.exec(plainText(t)); return Boolean(m) && m[1].trim().split(/\s+/u).length <= 3; };
953
953
  let criteria = null;
@@ -962,13 +962,13 @@ const PLANS = {
962
962
  for (const sd of sides) sd.items = sd.items.map((t) => LABEL.exec(plainText(t))[2]);
963
963
  }
964
964
  }
965
- // Under a side title the criteria stand in the rail, level with their rows, and the two sides take the body
965
+ // Under a side title the criteria stand in the rail, level with their rows, and the two sides take the body.
966
966
  const inRail = Boolean(criteria && ctx.rail);
967
967
  const floor = inRail ? Math.min(Z.bottom, ctx.rail.bottom) : Z.bottom;
968
968
  const critR = inRail ? { ...ctx.rail, bottom: floor } : criteria ? region(S, Z.a, Z.a + 1, Z.y, Z.bottom) : null;
969
969
  const parts = criteria && !inRail ? splitRegion(S, region(S, Z.a + 2, Z.b, Z.y, Z.bottom), 0.5) : splitRegion(S, Z, 0.5);
970
970
  const headSize = bodySize(S, state, "lead");
971
- // Up to three rows of short points are set a step or two larger when that still keeps a dozen glyphs a line and
971
+ // Up to three rows of short points are set a step or two larger while a line keeps a dozen glyphs.
972
972
  const rowsN = Math.max(...sides.map((sd) => sd.items.length));
973
973
  const fitsAt = (z) => {
974
974
  if (parts[0].w / z < 12) return false;
@@ -1025,7 +1025,7 @@ const PLANS = {
1025
1025
  process(ctx) {
1026
1026
  const { S, state } = ctx;
1027
1027
  const all = entries(sortItems(ctx.items).texts.flatMap(itemParas));
1028
- // Points without a bold head after the steps are the slide's takeaways
1028
+ // Points without a bold head after the steps are the slide's takeaways, set under the steps.
1029
1029
  const headed = all.filter((e) => e.head);
1030
1030
  const loose = headed.length >= 2 && headed.length < all.length ? all.filter((e) => !e.head) : [];
1031
1031
  const ents = loose.length ? headed : all;
@@ -1109,7 +1109,7 @@ const PLANS = {
1109
1109
  const slots = equalParts(S, Z, events.length);
1110
1110
  const dateSize = bodySize(S, state, "lead");
1111
1111
  const labelSize = bodySize(S, state);
1112
- // The axis runs under the tallest date at the top of the body
1112
+ // The axis runs under the tallest date at the top of the body.
1113
1113
  const dateH = Math.max(...events.map((e, i) => G.textHeight(e.date, slots[i].w, dateSize, 1.25)));
1114
1114
  const axisY = Math.round(Z.y + dateH + 18);
1115
1115
  const items = events.map((e, i) => {
@@ -1127,10 +1127,10 @@ const PLANS = {
1127
1127
  const top = Math.max(...out.map((q) => q.y + q.h)) + state.blockGap;
1128
1128
  out.push(...stack(texts, { ...Z, y: top }, S, state).placed);
1129
1129
  }
1130
- // Room past the density band is shared evenly
1130
+ // Room past the density band is shared evenly around the axis.
1131
1131
  const room = Z.bottom - contentBottom(out, Z.y);
1132
1132
  if (room / (Z.bottom - Z.y) > S.tok.cap) {
1133
- // A single takeaway line stays with the axis it reads (a band over one line set it adrift), so the pair moves
1133
+ // A single takeaway line stays with the axis it reads, so the pair moves together.
1134
1134
  const single = texts.flatMap(itemParas).length === 1;
1135
1135
  const blocks = out.length > (cap ? 2 : 1) && !single ? 3 : 2;
1136
1136
  const step = Math.floor(room / blocks);
@@ -1151,7 +1151,7 @@ const PLANS = {
1151
1151
  const c = (table && table.content) || {};
1152
1152
  if (!table || (c.headers || []).length !== 3 || (c.rows || []).length !== 2) return PLANS["table-insight"](ctx);
1153
1153
  const [yName, xName] = String(plainText(c.headers[0])).split(/\s*[\\/]\s*/u);
1154
- // The takeaways stand beside the quadrants in the last four columns (under the matrix on a narrow body), so the
1154
+ // The takeaways stand beside the quadrants in the last four columns, so the matrix never drops them.
1155
1155
  const wide = texts.length && Z.b - Z.a + 1 >= 10;
1156
1156
  const M = wide ? region(S, Z.a, Z.b - 4, Z.y, Z.bottom) : Z;
1157
1157
  const notesH = texts.length && !wide ? stack(texts, Z, S, state).bottom - Z.y + state.blockGap : 0;
@@ -1166,7 +1166,7 @@ const PLANS = {
1166
1166
  const qh = (grid.bottom - capH - top - gap) / 2;
1167
1167
  const qw = (grid.w - gap) / 2;
1168
1168
  const pad = 18;
1169
- // A cell is its item over its details
1169
+ // A cell is its item over its details, one detail a line under the item.
1170
1170
  const head = bodySize(S, state, "lead");
1171
1171
  const detail = bodySize(S, state);
1172
1172
  const cells = [0, 1].flatMap((ri) => [0, 1].map((ci) => {
@@ -1195,7 +1195,7 @@ const PLANS = {
1195
1195
  const size = S.sizes.title;
1196
1196
  const h = G.textHeight(plainText(said), R.w, size, 1.3);
1197
1197
  const blockH = h + (by ? 18 + S.sizes.label * 1.4 : 0);
1198
- // Beside a side title the quote is the slide's display element
1198
+ // Beside a side title the quote is the slide's display element, set in the middle of the body.
1199
1199
  const y = ctx.cls === "side" ? Math.max(R.y, Math.round(Z.y + (Z.bottom - Z.y - blockH) / 2)) : R.y;
1200
1200
  return [{ kind: "quote", text: said, by: by ? plainText(by.text) : "", size, x: R.x, w: R.w, y, h: blockH, textH: h, markX: Z.x, markW, limit: Z.bottom }];
1201
1201
  },
@@ -1230,7 +1230,7 @@ const PLANS = {
1230
1230
  if (!defsItem.length) { place(Z); return out; }
1231
1231
  const defsAt = (R) => textAt(defs, R, S, { ...state, itemGap: Math.max(state.itemGap, 12) });
1232
1232
  if (!visuals.length && Z.b - Z.a + 1 >= 8) {
1233
- // The formula runs across the top and its terms in columns under it, instead of one line over an empty column
1233
+ // The formula runs across the top and its terms in columns under it, never beside an empty column.
1234
1234
  place(Z);
1235
1235
  const below = { ...Z, y: Math.max(...out.map((q) => q.y + q.h)) + state.blockGap };
1236
1236
  // Two columns of terms, else three (a formula beside its terms left its own column four-fifths empty).
@@ -1240,7 +1240,7 @@ const PLANS = {
1240
1240
  }
1241
1241
  out.length = 0;
1242
1242
  }
1243
- // A wide figure (twice as wide as tall or more) set in half the body is drawn at a third of its column's height
1243
+ // A wide figure set in half the body would draw at a third of its height, so it runs across the top.
1244
1244
  const size = visuals.length === 1 && visuals[0].type === "image" && visuals[0].imageSize;
1245
1245
  if (S.tok.tight && size && size.width / size.height >= 2 && Z.b - Z.a + 1 >= 8) {
1246
1246
  const cap = captionHeight(visuals[0].caption, Z.w, S);
@@ -1261,7 +1261,7 @@ const PLANS = {
1261
1261
  }
1262
1262
  const [vR, full] = splitRegion(S, Z, 7 / 12);
1263
1263
  place(vR);
1264
- // Beside a figure the terms' column closes with the source strip when it ends above the figure, and terms that
1264
+ // Beside a figure the terms' column closes with the source strip, and sparse terms are set a step up.
1265
1265
  const strip = ctx.strip && visuals.length ? stripPlacements(ctx.strip, full, S) : null;
1266
1266
  const dR = strip ? { ...full, bottom: full.bottom - strip.h - S.tok.block } : full;
1267
1267
  let beside = defsAt(dR);
@@ -1271,7 +1271,7 @@ const PLANS = {
1271
1271
  }
1272
1272
  if (strip && beside.y + beside.h <= dR.bottom + 0.5) out.push(...strip.placed.map((q) => ({ ...q, strip: true })));
1273
1273
  if (S.tok.tight && visuals.length && beside.y + beside.h > Z.bottom) {
1274
- // Terms too long for five columns beside the figure
1274
+ // Terms too long for five columns beside the figure take one column, then two.
1275
1275
  const [hR, tR6] = splitRegion(S, Z, 6 / 12);
1276
1276
  const strip6 = ctx.strip ? stripPlacements(ctx.strip, tR6, S) : null;
1277
1277
  const one = textAt(defs, strip6 ? { ...tR6, bottom: tR6.bottom - strip6.h - S.tok.block } : tR6, S, { ...state, itemGap: Math.max(state.itemGap, 12) });
@@ -1280,14 +1280,14 @@ const PLANS = {
1280
1280
  place(hR);
1281
1281
  return [...out, { ...one, limit: Z.bottom - one.h * 0.1 }, ...(strip6 ? strip6.placed.map((q) => ({ ...q, strip: true })) : [])];
1282
1282
  }
1283
- // The text estimate runs short of what a renderer sets for Latin, so the columns keep a tenth of their height
1283
+ // The text estimate runs short of what a renderer sets for Latin, so the columns keep a tenth spare.
1284
1284
  for (const share of [5 / 12, 4 / 12]) {
1285
1285
  out.length = 0;
1286
1286
  const [fR, tR] = splitRegion(S, Z, share);
1287
1287
  const cols = defsInColumns(defs, tR);
1288
1288
  if (share < 5 / 12 || cols.every((q) => q.y + q.h * 1.1 <= Z.bottom)) {
1289
1289
  place(fR);
1290
- // The spare tenth is part of each column's limit, so a slide that cannot keep it reads as overflowing and takes
1290
+ // The spare tenth is part of each column's limit, so a slide that cannot keep it reads as overflowing.
1291
1291
  return [...out, ...cols.map((q) => ({ ...q, limit: Z.bottom - q.h * 0.1 }))];
1292
1292
  }
1293
1293
  }
@@ -1295,7 +1295,7 @@ const PLANS = {
1295
1295
  out.push(beside);
1296
1296
  return out;
1297
1297
 
1298
- // The terms split between two columns at the entry that brings the first nearest half the height
1298
+ // The terms split between two columns at the entry that brings the first nearest half the height.
1299
1299
  function defsInColumns(list, R, n = 2) {
1300
1300
  const ents = entries(list);
1301
1301
  const flat = (part) => part.flatMap((e) => [{ kind: e.kind === "section" ? "section" : "bullet", text: e.head ? `**${e.head}** ${e.rest}`.trim() : e.rest },
@@ -1419,7 +1419,7 @@ const PLANS = {
1419
1419
  const { S, state, Z } = ctx;
1420
1420
  const { visuals, texts } = sortItems(ctx.items);
1421
1421
  const acts = visuals.length === 1 && visuals[0].type === "kpi-table" && !visuals[0].content.chart ? actionRows(visuals[0].content) : null;
1422
- // The field starts where the rows' text ends (at most seven of twelve columns, at least five), so each key
1422
+ // The field starts where the rows' text ends (five to seven of twelve columns), so each key stands near its row.
1423
1423
  const n = Z.b - Z.a + 1;
1424
1424
  if (acts && !ctx.splitCols) {
1425
1425
  const at = (k) => PLANS["closing-contact-split"]({ ...ctx, splitCols: k });
@@ -1441,7 +1441,7 @@ const PLANS = {
1441
1441
  const F = { ...fR, x: fieldX + inset, w: fR.x + fR.w - fieldX - inset, y: Z.y + inset, bottom: Z.bottom };
1442
1442
  const field = { decor: true, kind: "rect", x: fieldX, y: Z.y, w: S.grid.W - fieldX, h: S.grid.H - Z.y, fill: "field", bleed: true };
1443
1443
  if (acts) {
1444
- // The field is the ask
1444
+ // The field is the ask, each row's key in line with its row.
1445
1445
  const total = actionTotal(acts) || actionSpan(acts);
1446
1446
  // The band stops at the field's edge.
1447
1447
  const stepW = total ? fieldX - lR.x : F.x + F.w - lR.x;
@@ -1474,7 +1474,7 @@ const PLANS = {
1474
1474
  PLANS["step-diagram"] = PLANS.process;
1475
1475
  PLANS.statement = PLANS["text-column"];
1476
1476
 
1477
- /** Headed points as rows */
1477
+ /** Headed points run as rows, the head bold in the first three columns. */
1478
1478
  function headedRows(ctx, ents) {
1479
1479
  const { S, state, Z } = ctx;
1480
1480
  const headR = region(S, Z.a, Z.a + 2, Z.y, Z.bottom);
@@ -1514,7 +1514,7 @@ function figureRows(cards, Z, S, state, spread = null) {
1514
1514
  return { kind: "figrows", rows, x: Z.x, w: Z.w, valueW, labelX, labelW, value, labelSize, noteSize, pitch, y: Z.y, h: (n - 1) * pitch + heights[n - 1], limit: Z.bottom };
1515
1515
  }
1516
1516
 
1517
- /** A big-number slide as a data panel */
1517
+ /** A big-number slide is a data panel, one row per figure. */
1518
1518
  function numberPanel(ctx, table, texts) {
1519
1519
  const { S, state, Z } = ctx;
1520
1520
  const c = table.content || {};
@@ -1572,7 +1572,7 @@ function actionRows(content) {
1572
1572
  const rows = (content.rows || []).map((r) => r.map((cell) => plainText(cell).trim()))
1573
1573
  .filter((r, i, all) => !(all.length > 1 && i === all.length - 1 && TOTALS.test(r[0] || "")));
1574
1574
  if (rows.length < 1 || rows.length > 5 || headers.length < 2) return null;
1575
- // A bare number under a head that names its unit ("Budget (k USD)") carries that unit, as a key and in a row's
1575
+ // A bare number under a head that names its unit ("Budget (k USD)") carries that unit wherever it stands.
1576
1576
  const units = headers.map((h) => (/\(([^()]{1,12})\)\s*$/u.exec(h) || [])[1] || null);
1577
1577
  const withUnit = (cell, j) => (units[j] && /^\d[\d,.]*$/u.test(cell) ? joinUnit(cell, units[j]) : cell);
1578
1578
  let key = -1;
@@ -1629,7 +1629,7 @@ function actionSpan(acts) {
1629
1629
  return first && last && first !== last ? `${count} · ${first} – ${last}` : count;
1630
1630
  }
1631
1631
 
1632
- /** Action rows down a region */
1632
+ /** Action rows run down a region, the key large on the left. */
1633
1633
  function placeActions(ctx, acts, R, o = {}) {
1634
1634
  const { S, state } = ctx;
1635
1635
  const n = acts.rows.length;
@@ -1639,7 +1639,7 @@ function placeActions(ctx, acts, R, o = {}) {
1639
1639
  const sizes = [S.sizes.title, S.sizes.lead, S.sizes.body];
1640
1640
  const total = acts.kind === "amount" ? acts.rows.reduce((a, r) => a + r.value, 0) : 0;
1641
1641
  const gaps = o.last ? n : Math.max(1, n - 1);
1642
- // At lead size first
1642
+ // Rows start at lead size and step down until they fit.
1643
1643
  const fit = (step) => {
1644
1644
  const fitting = sizes.filter((v) => v && longest * v * 1.04 <= keyR.w);
1645
1645
  const keySize = fitting[Math.min(step, fitting.length - 1)] || S.sizes.body;
@@ -1662,7 +1662,7 @@ function placeActions(ctx, acts, R, o = {}) {
1662
1662
  if (chosen.room < 0) return null;
1663
1663
  const { keySize, head, meta, rows } = chosen;
1664
1664
  const sum = rows.reduce((a, r) => a + r.h, 0);
1665
- // Rows sit at most 96 pt apart
1665
+ // Rows sit at most 96 pt apart.
1666
1666
  const gap = Math.max(state.itemGap, Math.min(96, (R.bottom - R.y - sum) / gaps));
1667
1667
  const spare = Math.max(0, R.bottom - R.y - sum - gap * gaps);
1668
1668
  let y = R.y + Math.round(spare / 2);
@@ -1672,20 +1672,20 @@ function placeActions(ctx, acts, R, o = {}) {
1672
1672
  head, meta, total, gap, x: R.x, y: R.y, w: (o.keys ? keyR.x + keyR.w : R.x + R.w) - R.x, h, limit: R.bottom }];
1673
1673
  }
1674
1674
 
1675
- /** The next step of a closing, set at lead size on the body floor in the pack's closing device */
1675
+ /** The next step of a closing, set at lead size on the body floor in the pack's closing device. */
1676
1676
  function nextStep(ctx, texts, R, device, o = {}) {
1677
1677
  const { S, state } = ctx;
1678
1678
  const lead = bodySize(S, { ...state, stepped: false }, "lead");
1679
1679
  const ruled = device === "rules" || device === "field";
1680
1680
  const pad = ruled ? 0 : Math.max(18, S.tok.pad);
1681
- // Each point keeps its own line
1681
+ // Each point keeps its own line; a source line is a note at label size.
1682
1682
  const paras = texts.flatMap(itemParas).map((p) => plainText(p.text));
1683
1683
  const notes = paras.filter((t) => /^(출처|자료|sources?)\b/iu.test(t));
1684
1684
  const points = paras.filter((t) => !notes.includes(t)).map((t) => t.replace(/^([^::]{1,16})([::])\s*/u, "**$1$2** "));
1685
1685
  const size = points.length > 2 || o.small ? S.sizes.body : lead;
1686
1686
  const box = R.w - pad * 2;
1687
1687
  const inner = Math.min(box, (G.isKorean(paras.join("")) ? 34 : 70) * size * (G.isKorean(paras.join("")) ? 1 : 0.5));
1688
- // A line that fits the box on one line takes the box's width
1688
+ // A line that fits the box on one line takes the box's width.
1689
1689
  const widthOf = (t, z) => {
1690
1690
  if (G.lineCount(plainText(t), box, z) <= 1) return box;
1691
1691
  const n = G.lineCount(plainText(t), inner, z);
@@ -1749,7 +1749,7 @@ function drawPlaced(slide, S, p, ctx) {
1749
1749
  const soft = p.onField ? S.colour("on-field") : S.colour("ink-muted");
1750
1750
  for (const para of p.paras) {
1751
1751
  y += para.gap;
1752
- // The marker is a small dot centred on the first line
1752
+ // The marker is a small dot centred on the first line, drawn as a shape so no font swaps it.
1753
1753
  if (para.kind === "bullet") {
1754
1754
  const d = Math.max(4, Math.round(p.size * 0.28));
1755
1755
  const lineH = p.size * G.leading(para.text);
@@ -1910,7 +1910,7 @@ function drawPlaced(slide, S, p, ctx) {
1910
1910
  if (p.kind === "caption") drawCaption(slide, S, p.item.content, p.x, p.y, p.w, p.role === "on-field" ? "on-field" : "ink-muted");
1911
1911
  }
1912
1912
 
1913
- /** A figure on a dark ground is drawn from its dark variant when the source folder has one beside it */
1913
+ /** A figure on a dark ground is drawn from its dark variant when the source folder has one beside it. */
1914
1914
  function forGround(file, S) {
1915
1915
  const c = (i) => parseInt(String(S.P.ground).replace("#", "").slice(i, i + 2), 16) / 255;
1916
1916
  if (0.2126 * c(0) + 0.7152 * c(2) + 0.0722 * c(4) >= 0.25) return file;
@@ -1945,7 +1945,7 @@ function periodOf(name) {
1945
1945
  return y * 100 + (within || 0);
1946
1946
  }
1947
1947
 
1948
- /** The series a chart highlights */
1948
+ /** The series a chart highlights is the latest period, else the measured one. */
1949
1949
  function primarySeries(names) {
1950
1950
  const periods = names.map(periodOf);
1951
1951
  if (periods.every((v) => v != null) && new Set(periods).size === periods.length) return periods.indexOf(Math.max(...periods));
@@ -1959,7 +1959,7 @@ function seriesKey(name) {
1959
1959
  return plainText(name).replace(/\s*[((][^))]*[))]\s*$/u, "").trim().toLowerCase();
1960
1960
  }
1961
1961
 
1962
- /** A native chart in the pack's chart style */
1962
+ /** Draws a native chart in the pack's chart style. */
1963
1963
  function drawChart(slide, S, c, p) {
1964
1964
  const P = S.P;
1965
1965
  const chart = S.pack.chart || {};
@@ -1967,7 +1967,7 @@ function drawChart(slide, S, c, p) {
1967
1967
  const muted = mix(P["ink-muted"], P.ground, 0.45);
1968
1968
  let colours;
1969
1969
  if (series <= 1) {
1970
- // One series is one hue (a per-point highlight would make the chart read as categories)
1970
+ // One series is one hue; a per-point highlight would make the chart read as categories.
1971
1971
  const other = S.seriesRole && S.seriesRole.get(seriesKey((c.headers || [])[1])) === "other";
1972
1972
  const receding = chart.highlight === "accent-on-muted" ? muted : P.series.find((h) => String(h).toUpperCase() !== String(P.accent).toUpperCase()) || muted;
1973
1973
  colours = [other ? receding : P.accent];
@@ -2068,7 +2068,7 @@ function overflows(placed) {
2068
2068
  return placed.some((p) => !p.decor && p.limit != null && p.y + p.h > p.limit + 0.5);
2069
2069
  }
2070
2070
 
2071
- /** The zones of one treatment frame */
2071
+ /** Returns the zones of one treatment frame. */
2072
2072
  function zonesOf(frame, S) {
2073
2073
  const t = frame.title;
2074
2074
  const cls = frame.id === "side-rail" ? "side" : frame.id === "bottom-anchor" ? "bottom" : "top";
@@ -2077,11 +2077,11 @@ function zonesOf(frame, S) {
2077
2077
  return { cls, Z, bandTop: cls === "bottom" ? Z.y : Math.min(t.y + t.h, Z.y) };
2078
2078
  }
2079
2079
 
2080
- /** Lay a content slide out under one frame and, when the body leaves more than the density band empty, try the */
2080
+ /** Lay a content slide out under one frame, then try the pack's fill policies while the body stays under-filled. */
2081
2081
  // A line that names the slide's source or a note on its basis ("출처:", "주:", "Source:", "Note:").
2082
2082
  const STRIP = /^(?:출처|자료|주|Sources?|Notes?)\s*[::]/iu;
2083
2083
 
2084
- /** The source and note lines of a content slide's text, taken out of its takeaways */
2084
+ /** The source and note lines of a content slide's text, taken out of its takeaways into a foot strip. */
2085
2085
  function takeStrip(spec, items) {
2086
2086
  // A closing with a next step keeps its source there; a closing summary has no such line, so it takes the strip.
2087
2087
  if (G.VARIANTS.closing.includes(spec.family) && spec.family !== "closing-summary-list") return { items, lines: [] };
@@ -2103,20 +2103,20 @@ function stripPlacements(lines, Z, S) {
2103
2103
  paras[0].gap = 0;
2104
2104
  const h = paras.reduce((a, q) => a + q.gap + q.h, 0);
2105
2105
  const y = Z.bottom - h;
2106
- // A hairline sets the strip off where the pack draws hairlines
2106
+ // A hairline sets the strip off where the pack draws hairlines.
2107
2107
  return { h: h + 10, placed: [
2108
2108
  ...(S.deco.has("hairline-rule") ? [{ decor: true, kind: "rect", x: Z.x, y: y - 6, w: Z.w, h: 0.5, fill: "line" }] : []),
2109
2109
  { kind: "text", paras, size, x: Z.x, y, w: Z.w, h, muted: true, limit: Z.bottom },
2110
2110
  ] };
2111
2111
  }
2112
2112
 
2113
- // Families that pair a visual with its takeaways
2113
+ // Families that pair a visual with its takeaways may use the rail.
2114
2114
  const RAIL_TAKEAWAYS = new Set(["chart-insight", "full-chart", "table-insight", "figure-academic", "image-split",
2115
2115
  "kpi-row", "big-number", "kpi-over-chart", "dashboard-grid", "photo-grid", "figure-pair", "asymmetric-feature"]);
2116
2116
  // A rail more empty than this, from the title down, costs the slide the side rail.
2117
2117
  const RAIL_EMPTY = 0.4;
2118
2118
 
2119
- /** The rail under a side title */
2119
+ /** The rail under a side title holds the slide's supporting content. */
2120
2120
  function railOf(frame, S) {
2121
2121
  const t = frame.title;
2122
2122
  const s = S.grid.span(1, 4);
@@ -2143,7 +2143,7 @@ function planSlide(spec, frame, S, items) {
2143
2143
  const rail = z.cls === "side" ? railOf(frame, S) : null;
2144
2144
  const railPlaced = [];
2145
2145
  const taken = takeStrip(spec, items);
2146
- // The source strip
2146
+ // The source strip stands at the rail's foot or the body's.
2147
2147
  let lines = taken.lines;
2148
2148
  items = taken.items;
2149
2149
  if (lines.length && rail) {
@@ -2152,11 +2152,11 @@ function planSlide(spec, frame, S, items) {
2152
2152
  rail.bottom -= strip.h + S.tok.block;
2153
2153
  lines = [];
2154
2154
  }
2155
- // A visual's takeaways stand in the rail when they fit there, a chart's values table under them
2155
+ // A visual's takeaways stand in the rail when they fit there, a chart's values table under them.
2156
2156
  if (rail && RAIL_TAKEAWAYS.has(spec.family) && !spec.stacked) {
2157
2157
  const { texts, visuals } = sortItems(items);
2158
2158
  const paras = texts.filter((t) => t.type !== "main-box").flatMap(itemParas);
2159
- // A table keeps its own height
2159
+ // A table keeps its own height.
2160
2160
  const fills = () => {
2161
2161
  // Figure rows and number panels spread to the floor; so do charts and pictures.
2162
2162
  if (["kpi-row", "big-number"].includes(spec.family) || visuals.some((v) => v.type !== "kpi-table" || (v.content || {}).chart)) return true;
@@ -2174,7 +2174,7 @@ function planSlide(spec, frame, S, items) {
2174
2174
  // Body size when it fills the rail; else whichever size leaves the rail least empty.
2175
2175
  const pick = sets.find((x) => x.band <= RAIL_EMPTY) || sets.reduce((a, b) => (b.band < a.band ? b : a), sets[0]);
2176
2176
  if (pick) {
2177
- // Takeaways that still leave a quarter of the rail empty share that room between them, at most a sixth of the
2177
+ // Takeaways that still leave a quarter of the rail empty share that room as gaps, a sixth of the rail at most.
2178
2178
  const [m] = pick.set;
2179
2179
  const breaks = m.paras.filter((para, i) => i > 0 && para.kind !== "child");
2180
2180
  const room = rail.bottom - S.tok.block - (pick.set[pick.set.length - 1].y + pick.set[pick.set.length - 1].h);
@@ -2188,7 +2188,7 @@ function planSlide(spec, frame, S, items) {
2188
2188
  }
2189
2189
  }
2190
2190
  }
2191
- // A sidebar note is the slide's supporting content
2191
+ // A sidebar note stands in the rail under a side title.
2192
2192
  if (rail && spec.family === "sidebar-note") {
2193
2193
  const texts = sortItems(items).texts;
2194
2194
  const note = texts.find((t) => t.type === "main-box") || (texts.length > 1 ? texts[texts.length - 1] : null);
@@ -2257,7 +2257,7 @@ function planSlide(spec, frame, S, items) {
2257
2257
  }
2258
2258
  }
2259
2259
  z.Z = zone();
2260
- // A text column that stops short of the column beside it is filled by layout
2260
+ // A text column that stops short of the column beside it is filled by layout.
2261
2261
  const floor = full.bottom;
2262
2262
  let short = shortColumn([...out.placed, ...(across && !offer ? across.placed : [])], floor, S);
2263
2263
  // The fill starts a little under the line (0.35), so a column the gate reads a point or two emptier than the
@@ -2301,7 +2301,7 @@ function chooseTreatment(spec, S, deck) {
2301
2301
  const cost = (x) => x.plan.band + (x.plan.band > S.tok.cap ? 0.15 : 0) + 0.07 * (deck.count[x.t] || 0) +
2302
2302
  (x.t === deck.prev ? 0.08 : 0) - (x.t === options[0] ? 0.03 : 0) + ((deck.count[x.t] || 0) >= deck.limit ? 1 : 0);
2303
2303
  const best = pool.reduce((b, x) => (cost(x) < cost(b) - 1e-9 ? x : b));
2304
- // The role's own title is the pack's structure
2304
+ // The role's own title is the pack's structure and usually stays.
2305
2305
  const ownT = options[0] === "bottom-anchor" && !drawn(spec) ? options.find((t) => t !== "bottom-anchor") : options[0];
2306
2306
  const own = pool.find((x) => x.t === ownT);
2307
2307
  const margin = best.plan.band > S.tok.cap ? 0.15 : 0.1;
@@ -2334,7 +2334,7 @@ function spreadTreatments(choices, S, pack, slideCount) {
2334
2334
  choices.set(best.spec, { t: best.t, plan: best.plan });
2335
2335
  lines.push(`variety: slide ${best.spec.index + 1} drawn under ${best.t} so the deck carries ${used().size} title treatments`);
2336
2336
  }
2337
- // No title zone on more than 40 % of the content slides
2337
+ // No title zone may hold over 40 % of the content slides.
2338
2338
  const content = [...choices.keys()].filter((spec) => spec.kind === "content");
2339
2339
  const movable = (spec) => spec.role === "data" || spec.role === "data-takeaway";
2340
2340
  const cap = 0.4 * content.length;
@@ -2370,7 +2370,7 @@ function spreadTreatments(choices, S, pack, slideCount) {
2370
2370
  if (!crowded || n <= cap) break;
2371
2371
  let best = moveOut(crowded, zones, new Set());
2372
2372
  if (!best) {
2373
- // Every zone a crowded slide could take is full
2373
+ // When every zone is full, a data slide moves on to a third zone first.
2374
2374
  for (const [full, m] of Object.entries(zones)) {
2375
2375
  if (full === crowded || m + 1 <= cap) continue;
2376
2376
  const first = moveOut(full, zones, new Set([crowded]));
@@ -2390,7 +2390,7 @@ function spreadTreatments(choices, S, pack, slideCount) {
2390
2390
  const counted = content.filter((spec) => !G.VARIANTS.closing.includes(spec.family));
2391
2391
  const layoutsNeeded = Math.min(5, Math.ceil(0.6 * counted.length));
2392
2392
  const layoutOf = (t, plan) => `${ZONE_OF[t]}/${partitionOf(plan, S)}`;
2393
- // The check also fails a deck where one layout covers more than 40 % of those slides, so while one does, a
2393
+ // The check also fails one layout on more than 40 % of those slides, so slides move off that layout.
2394
2394
  const shareCap = 0.4 * counted.length;
2395
2395
  for (let guard = 0; guard < counted.length; guard++) {
2396
2396
  const keys = {};
@@ -2418,7 +2418,7 @@ function spreadTreatments(choices, S, pack, slideCount) {
2418
2418
  }
2419
2419
  }
2420
2420
  if (!best) {
2421
- // No title gives a new layout
2421
+ // No title gives a new layout, so the takeaways may stack under the visual.
2422
2422
  for (const spec of counted) {
2423
2423
  const current = choices.get(spec);
2424
2424
  const from = layoutOf(current.t, current.plan);
@@ -2444,7 +2444,7 @@ function spreadTreatments(choices, S, pack, slideCount) {
2444
2444
  return lines;
2445
2445
  }
2446
2446
 
2447
- /** The boxes a placement draws as the composition check reads them from the page */
2447
+ /** The boxes a placement draws, as the composition check reads them from the page. */
2448
2448
  function drawnBoxes(p, S) {
2449
2449
  const B = (x, y, w, h, card = false) => ({ x, y, w, h, card });
2450
2450
  const filled = S.pack.edge !== "border";
@@ -2478,7 +2478,7 @@ function drawnBoxes(p, S) {
2478
2478
  // A chart's caption is its own frame under the plot, as the check reads it.
2479
2479
  case "chart": return p.cap ? [B(p.x, p.y, p.w, p.h - p.cap), B(p.x, p.y + p.h - p.cap + 6, p.w, p.cap - 6)] : [B(p.x, p.y, p.w, p.h)];
2480
2480
  case "image": {
2481
- // A single picture is drawn contained in its box (on the box floor beside a taller takeaway column), its
2481
+ // A single picture is drawn contained in its box, its caption under it, as the check reads them.
2482
2482
  const size = p.item && p.item.type === "image" && p.item.imageSize;
2483
2483
  if (!size) return [B(p.x, p.y, p.w, p.h)];
2484
2484
  const fit = shared().containBox(p.x, p.y, p.w, p.h - p.cap, size);
@@ -2491,7 +2491,7 @@ function drawnBoxes(p, S) {
2491
2491
  }
2492
2492
  }
2493
2493
 
2494
- /** The column partition a plan draws, read the way the composition check reads the page */
2494
+ /** The column partition a plan draws, read the way the composition check reads the page. */
2495
2495
  function partitionOf(plan, S) {
2496
2496
  const Z = plan.z && plan.z.Z;
2497
2497
  // What stands in a side title's rail is part of the title zone, as the check reads it.
@@ -2532,7 +2532,7 @@ function partitionOf(plan, S) {
2532
2532
  return "one";
2533
2533
  }
2534
2534
 
2535
- /** The emptiest text column of a two-column body, read as the gate reads it (OF-115) */
2535
+ /** The emptiest text column of a two-column body, read as the gate reads it (OF-115). */
2536
2536
  const TEXT_KINDS = new Set(["text", "caption", "callout", "box", "panel"]);
2537
2537
  // A picture drawn smaller than its box leaves its own column empty above it as a short text column does.
2538
2538
  const SHORT_KINDS = new Set([...TEXT_KINDS, "image"]);
@@ -2552,7 +2552,7 @@ function shortColumn(placed, floor, S) {
2552
2552
  for (const cut of [...new Set(boxes.map((b) => b.x + b.w))].sort((m, n) => m - n)) {
2553
2553
  const a = boxes.filter((b) => b.x + b.w <= cut + 2);
2554
2554
  const c = boxes.filter((b) => b.x >= cut - 2);
2555
- // A block across most of the body (a note under both columns) belongs to neither
2555
+ // A block across most of the body (a note under both columns) belongs to neither column.
2556
2556
  const across = boxes.filter((b) => !a.includes(b) && !c.includes(b));
2557
2557
  if (!a.length || !c.length || across.some((b) => b.w < 0.7 * (right - left))) continue;
2558
2558
  const top = Math.min(...[...a, ...c].map((b) => b.y));
@@ -2576,7 +2576,7 @@ function shortColumn(placed, floor, S) {
2576
2576
  return worst;
2577
2577
  }
2578
2578
 
2579
- /** Fill a short text column by layout */
2579
+ /** Fills a short text column by layout; type never shrinks. */
2580
2580
  function fillColumn(short, floor, S, state) {
2581
2581
  const texts = short.owners.filter((q) => q.kind === "text" && !q.muted && !q.strip && q.paras);
2582
2582
  if (!texts.length) return;
@@ -2625,7 +2625,7 @@ function drawDecor(slide, S, list) {
2625
2625
  for (const d of list || []) packRect(slide, S, d, { fill: d.role, alpha: d.alpha });
2626
2626
  }
2627
2627
 
2628
- /** A content slide */
2628
+ /** Draws a content slide, the title frame last. */
2629
2629
  function renderContent(slide, spec, S, ctx, chosen) {
2630
2630
  const f = frameFor(spec, chosen.t);
2631
2631
  const t = f.title;
@@ -2671,7 +2671,7 @@ function renderContent(slide, spec, S, ctx, chosen) {
2671
2671
  });
2672
2672
  }
2673
2673
 
2674
- /** What carries a statement, from the pack's display keys */
2674
+ /** What carries a statement, from the pack's display keys; returns the colour the sentence takes. */
2675
2675
  function statementDevice(slide, S, f, supportH) {
2676
2676
  const g = S.grid;
2677
2677
  const t = f.title;
@@ -2681,7 +2681,7 @@ function statementDevice(slide, S, f, supportH) {
2681
2681
  return "on-field";
2682
2682
  }
2683
2683
  if (device === "plate") {
2684
- // The sentence moves down onto a plate that bleeds off three edges and covers less than half of the page, so
2684
+ // The sentence moves down onto a plate that bleeds off three edges and covers under half the page.
2685
2685
  const top = Math.round((376 - t.h / 2) / 6) * 6;
2686
2686
  if (top - 24 < 282 || top + t.h + 24 + supportH > G.ZONE.floor) return t.colour;
2687
2687
  f.support.y += top - t.y;
@@ -2697,7 +2697,7 @@ function statementDevice(slide, S, f, supportH) {
2697
2697
  return t.colour;
2698
2698
  }
2699
2699
  if (device === "offset") {
2700
- // The rail stops a column short of the sentence, so the gap between them is part of the grid, and at the body
2700
+ // The rail stops a column short of the sentence and at the body floor, so both gaps belong to the grid.
2701
2701
  packRect(slide, S, { x: 0, y: 0, w: t.x - g.gutter / 2 - g.pitch, h: G.ZONE.floor }, { fill: S.pack.rail === "field" ? "field" : "surface" });
2702
2702
  return t.colour;
2703
2703
  }
@@ -2721,14 +2721,14 @@ function coverImage(slide, box, src, sourceDir) {
2721
2721
  slide.addImage({ path: file, ...croppedTo(file, box), altText: src.caption || path.basename(file) });
2722
2722
  }
2723
2723
 
2724
- /** A picture that fills a box by cropping, never by stretching */
2724
+ /** A picture that fills a box by cropping, never by stretching. */
2725
2725
  function croppedTo(file, box) {
2726
2726
  const size = shared().readImageSize(file);
2727
2727
  const ratio = size && size.width && size.height ? size.height / size.width : box.h / box.w;
2728
2728
  return { x: IN(box.x), y: IN(box.y), w: IN(box.w), h: IN(box.w * ratio), sizing: { type: "cover", w: IN(box.w), h: IN(box.h) } };
2729
2729
  }
2730
2730
 
2731
- /** A type-led cover drawn with the pack's display device */
2731
+ /** A type-led cover drawn with the pack's display device. */
2732
2732
  const TYPE_COVERS = {
2733
2733
  drench(slide, spec, S, meta) {
2734
2734
  const g = S.grid;
@@ -2830,7 +2830,7 @@ const COVERS = {
2830
2830
  const g = S.grid;
2831
2831
  coverImage(slide, { x: 0, y: 0, w: g.W, h: g.H }, spec.cover.image, spec.sourceDir);
2832
2832
  const span = g.span(1, 8);
2833
- // The panel is as tall as the title and its meta line and stands on the page foot
2833
+ // The panel is as tall as the title and its meta line and stands on the page foot.
2834
2834
  const { size, h } = coverTitle(S, spec.title, span.w, 2, S.sizes.display);
2835
2835
  const line = [meta.subtitle, metaLine(meta)].filter(Boolean).join(" · ");
2836
2836
  const lineH = line ? G.textHeight(line, span.w, S.sizes.label, 1.3) : 0;
@@ -2847,7 +2847,7 @@ const COVERS = {
2847
2847
  packText(slide, S, spec.title, { ...span, y: Math.max(48, 180 - h), h }, { face: "display", size, colour: S.colour("on-field"), lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
2848
2848
  const subH = meta.subtitle ? G.textHeight(meta.subtitle, g.span(1, 8).w, S.sizes.lead) : 0;
2849
2849
  if (meta.subtitle) packText(slide, S, meta.subtitle, { ...g.span(1, 8), y: 252, h: subH }, { name: "subtitle@cover", size: S.sizes.lead, colour: S.colour("ink-muted") });
2850
- // Under the band, the deck's parts (else its headline figures) run across the page as the issue's contents
2850
+ // Under the band, the deck's parts (else its headline figures) run across the page as its contents.
2851
2851
  const parts = (spec.cover.index || []).length >= 2 ? spec.cover.index.map((name, i) => [String(i + 1).padStart(2, "0"), name])
2852
2852
  : (spec.cover.figures || []).length >= 2 ? spec.cover.figures.map((f) => [f.value, f.label]) : [];
2853
2853
  if (parts.length) {
@@ -2896,7 +2896,7 @@ const COVERS = {
2896
2896
  const g = S.grid;
2897
2897
  const span = g.span(1, 9);
2898
2898
  const { size, h } = coverTitle(S, spec.title, span.w, 2, S.sizes.display);
2899
- // The figures sit between the title and the meta line, with a hairline above them
2899
+ // The figures sit between the title and the meta line, with a hairline above them.
2900
2900
  const figs = spec.cover.figures.slice(0, 4);
2901
2901
  // The row stands in the middle of the room between the title and the meta line.
2902
2902
  const rowH = S.sizes.title * 1.3 + 6 + S.sizes.body * 1.4;
@@ -2959,7 +2959,7 @@ const SECTIONS = {
2959
2959
  } });
2960
2960
  },
2961
2961
  "section-band"(slide, spec, S) {
2962
- // The header band deepened to y 216
2962
+ // The header band deepened to y 216.
2963
2963
  const g = S.grid;
2964
2964
  packRect(slide, S, { x: 0, y: 0, w: g.W, h: 216 }, { fill: "field" });
2965
2965
  const index = showsIndex(spec, S);
@@ -2978,7 +2978,7 @@ const SECTIONS = {
2978
2978
  const numeral = S.sizes.title;
2979
2979
  if (spec.parts) partNumeral(slide, S, spec, { ...g.span(1, 3), y: 216, h: numeral * 1.15 }, numeral, "on-field");
2980
2980
  else if ((spec.contents || []).length) {
2981
- // No part count
2981
+ // Without a part count the rail lists what the section opens with.
2982
2982
  const w = g.span(1, 3).w;
2983
2983
  let y = 216;
2984
2984
  for (const entry of spec.contents) {
@@ -3028,7 +3028,7 @@ function sectionTitle(slide, spec, S, o = {}) {
3028
3028
  return box;
3029
3029
  }
3030
3030
 
3031
- /** A section the deck gives no part count (one section, no agenda) */
3031
+ /** A section the deck gives no part count (one section, no agenda), set as a statement with a preview. */
3032
3032
  function sectionOpener(slide, spec, S, o = {}) {
3033
3033
  const g = S.grid;
3034
3034
  const span = g.span(1, 10);
@@ -3049,7 +3049,7 @@ function sectionOpener(slide, spec, S, o = {}) {
3049
3049
  return box;
3050
3050
  }
3051
3051
 
3052
- /** A section with no part count whose slides carry body lines */
3052
+ /** A section with no part count whose slides carry body lines previews them under a hairline. */
3053
3053
  function sectionDigestPage(slide, spec, S, o, d) {
3054
3054
  const g = S.grid;
3055
3055
  const head = S.sizes.lead;
@@ -3062,7 +3062,7 @@ function sectionDigestPage(slide, spec, S, o, d) {
3062
3062
  const right = g.span(1, k);
3063
3063
  const ink = S.colour(d.onField ? "on-field" : "ink");
3064
3064
  const soft = S.colour(d.onField ? "on-field" : "ink-muted");
3065
- // Each opened slide shows up to two of its lines (a preview, not a copy)
3065
+ // Each opened slide shows up to two of its lines (a preview, not a copy).
3066
3066
  const gap = 24;
3067
3067
  const room = G.ZONE.floor - Math.max(o.top || 0, G.ZONE.body) - d.h - 54;
3068
3068
  const build = (entries, per) => entries.map((e) => {
@@ -3081,7 +3081,7 @@ function sectionDigestPage(slide, spec, S, o, d) {
3081
3081
  }
3082
3082
  if (!rows) rows = build(d.digest.slice(0, 1), 1);
3083
3083
  const sum = rows.reduce((a, r) => a + r.h, 0);
3084
- // Title, hairline and preview form one block centred in the body (under `top` when a band sits above), like a
3084
+ // Title, hairline and preview form one block centred in the body, like a statement of what the part holds.
3085
3085
  const total = d.h + 54 + sum + gap * (rows.length - 1);
3086
3086
  const y = Math.max(o.top || 0, G.ZONE.body, Math.round(((o.centre || 303) - total / 2) / 6) * 6);
3087
3087
  const listTop = y + d.h + 54;
@@ -3097,7 +3097,7 @@ function sectionDigestPage(slide, spec, S, o, d) {
3097
3097
  return box;
3098
3098
  }
3099
3099
 
3100
- /** A section in a deck with an agenda, set as its index page */
3100
+ /** A section in a deck with an agenda, set as its index page. */
3101
3101
  function sectionIndex(slide, spec, S) {
3102
3102
  const g = S.grid;
3103
3103
  const left = g.span(1, 6);
@@ -3210,7 +3210,7 @@ async function renderPack(resolved, templateObj, outputPath) {
3210
3210
  const deck = { used: new Set(), max: pack.dials.varianceMax, count: {}, prev: null, limit: Math.max(3, Math.floor(contentSlides * 0.34)) };
3211
3211
  const chosenBy = {};
3212
3212
  const choices = new Map();
3213
- // One colour per series name across the deck
3213
+ // One colour per series name across the deck.
3214
3214
  S.seriesRole = new Map();
3215
3215
  for (const spec of resolved.slides || []) {
3216
3216
  for (const item of spec.items || []) {