@litfamily/litgrok 1.0.13 → 1.0.15

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 (114) hide show
  1. package/.grok/hooks/auto-handoff-gate.mjs +15 -2
  2. package/.grok/rules/00-litgrok.md +1 -1
  3. package/.grok/skills/frontend-ui-ux/THIRD-PARTY-NOTICE.txt +0 -5
  4. package/.grok/skills/frontend-ui-ux/references/slop-register.md +4 -4
  5. package/.grok/skills/frontend-ui-ux/scripts/import-design-intelligence.mjs +1 -1
  6. package/.grok/skills/lit-docx/SKILL.md +59 -11
  7. package/.grok/skills/lit-docx/examples/01-report-ko.md +137 -0
  8. package/.grok/skills/lit-docx/examples/02-brief-ko.md +82 -0
  9. package/.grok/skills/lit-docx/examples/03-manual-ko.md +118 -0
  10. package/.grok/skills/lit-docx/examples/04-proposal-ko.md +92 -0
  11. package/.grok/skills/lit-docx/examples/05-memo-en.md +42 -0
  12. package/.grok/skills/lit-docx/examples/06-journal-en.md +90 -0
  13. package/.grok/skills/lit-docx/examples/07-report-en.md +103 -0
  14. package/.grok/skills/lit-docx/examples/08-proposal-en.md +90 -0
  15. package/.grok/skills/lit-docx/examples/09-memo-ko.md +41 -0
  16. package/.grok/skills/lit-docx/references/components.md +151 -0
  17. package/.grok/skills/lit-docx/references/direction-step.md +129 -0
  18. package/.grok/skills/lit-docx/references/page-composition.md +129 -0
  19. package/.grok/skills/lit-docx/references/tonalities/brief.md +98 -0
  20. package/.grok/skills/lit-docx/references/tonalities/journal.md +100 -0
  21. package/.grok/skills/lit-docx/references/tonalities/manual.md +107 -0
  22. package/.grok/skills/lit-docx/references/tonalities/memo.md +95 -0
  23. package/.grok/skills/lit-docx/references/tonalities/proposal.md +105 -0
  24. package/.grok/skills/lit-docx/references/tonalities/report.md +114 -0
  25. package/.grok/skills/lit-docx/scripts/convert_md_to_docx.py +394 -51
  26. package/.grok/skills/lit-docx/scripts/docx_design.py +2446 -0
  27. package/.grok/skills/lit-docx/scripts/docx_gate.py +203 -0
  28. package/.grok/skills/lit-docx/scripts/docx_layout.py +959 -0
  29. package/.grok/skills/lit-docx/scripts/run.mjs +3 -2
  30. package/.grok/skills/lit-docx/scripts/slop_lint.py +63 -17
  31. package/.grok/skills/lit-docx/templates/tonalities/brief.yaml +29 -0
  32. package/.grok/skills/lit-docx/templates/tonalities/journal.yaml +26 -0
  33. package/.grok/skills/lit-docx/templates/tonalities/manual.yaml +27 -0
  34. package/.grok/skills/lit-docx/templates/tonalities/memo.yaml +27 -0
  35. package/.grok/skills/lit-docx/templates/tonalities/proposal.yaml +27 -0
  36. package/.grok/skills/lit-docx/templates/tonalities/report.yaml +27 -0
  37. package/.grok/skills/lit-pptx/NOTICE.md +1 -1
  38. package/.grok/skills/lit-pptx/SKILL.md +70 -14
  39. package/.grok/skills/lit-pptx/agents/analyst.md +1 -1
  40. package/.grok/skills/lit-pptx/agents/thesis.md +2 -2
  41. package/.grok/skills/lit-pptx/examples/01-pitch-signal-ko.md +175 -0
  42. package/.grok/skills/lit-pptx/examples/02-business-review-ledger-ko.md +199 -0
  43. package/.grok/skills/lit-pptx/examples/03-research-talk-paper-en.md +177 -0
  44. package/.grok/skills/lit-pptx/examples/04-lecture-chalk-ko.md +176 -0
  45. package/.grok/skills/lit-pptx/examples/05-data-review-night-en.md +221 -0
  46. package/.grok/skills/lit-pptx/examples/06-image-led-atlas-en.md +143 -0
  47. package/.grok/skills/lit-pptx/examples/07-briefing-gazette-ko.md +192 -0
  48. package/.grok/skills/lit-pptx/examples/08-status-update-ledger-en.md +168 -0
  49. package/.grok/skills/lit-pptx/examples/09-pitch-studio-en.md +157 -0
  50. package/.grok/skills/lit-pptx/examples/10-image-led-studio-ko.md +175 -0
  51. package/.grok/skills/lit-pptx/examples/11-business-review-night-en.md +181 -0
  52. package/.grok/skills/lit-pptx/examples/12-status-update-gazette-ko.md +199 -0
  53. package/.grok/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
  54. package/.grok/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
  55. package/.grok/skills/lit-pptx/references/density-and-fill.md +155 -0
  56. package/.grok/skills/lit-pptx/references/direction-step.md +211 -0
  57. package/.grok/skills/lit-pptx/references/layout-families.md +193 -0
  58. package/.grok/skills/lit-pptx/references/title-treatments.md +134 -0
  59. package/.grok/skills/lit-pptx/references/tonalities/atlas.md +124 -0
  60. package/.grok/skills/lit-pptx/references/tonalities/chalk.md +135 -0
  61. package/.grok/skills/lit-pptx/references/tonalities/gazette.md +127 -0
  62. package/.grok/skills/lit-pptx/references/tonalities/ledger.md +134 -0
  63. package/.grok/skills/lit-pptx/references/tonalities/night.md +132 -0
  64. package/.grok/skills/lit-pptx/references/tonalities/paper.md +127 -0
  65. package/.grok/skills/lit-pptx/references/tonalities/signal.md +118 -0
  66. package/.grok/skills/lit-pptx/references/tonalities/studio.md +123 -0
  67. package/.grok/skills/lit-pptx/scripts/compile-deck.js +65 -17
  68. package/.grok/skills/lit-pptx/scripts/craft_extras.py +82 -18
  69. package/.grok/skills/lit-pptx/scripts/deck_output.py +598 -0
  70. package/.grok/skills/lit-pptx/scripts/embed_fonts.py +1 -1
  71. package/.grok/skills/lit-pptx/scripts/inventory.py +6 -2
  72. package/.grok/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
  73. package/.grok/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  74. package/.grok/skills/lit-pptx/scripts/lib/layout-resolver.js +401 -16
  75. package/.grok/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  76. package/.grok/skills/lit-pptx/scripts/lib/render-adapter-html.js +2 -2
  77. package/.grok/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -126
  78. package/.grok/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
  79. package/.grok/skills/lit-pptx/scripts/lib/slide-ast.js +54 -6
  80. package/.grok/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
  81. package/.grok/skills/lit-pptx/scripts/qa_deck.py +43 -10
  82. package/.grok/skills/lit-pptx/scripts/run.mjs +3 -2
  83. package/.grok/skills/lit-pptx/scripts/validate_pptx.py +17 -5
  84. package/.grok/skills/lit-pptx/templates/boilerplate_professional.css +6 -6
  85. package/.grok/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  86. package/.grok/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
  87. package/.grok/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
  88. package/.grok/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
  89. package/.grok/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +99 -0
  90. package/.grok/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +104 -0
  91. package/.grok/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +99 -0
  92. package/.grok/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +100 -0
  93. package/.grok/skills/lit-pptx/templates/tonalities/night/pack.yaml +100 -0
  94. package/.grok/skills/lit-pptx/templates/tonalities/paper/pack.yaml +105 -0
  95. package/.grok/skills/lit-pptx/templates/tonalities/signal/pack.yaml +101 -0
  96. package/.grok/skills/lit-pptx/templates/tonalities/studio/pack.yaml +99 -0
  97. package/.grok/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
  98. package/.grok/skills/lit-typographic-motion/fonts/pins.json +6 -6
  99. package/.grok/skills/lit-typographic-motion/references/runtime.md +1 -1
  100. package/.grok/skills/lit-typographic-motion/references/style-bible.md +2 -2
  101. package/.grok/skills/lit-typographic-motion/references/type-craft.md +2 -2
  102. package/.grok/skills/lit-typographic-motion/scripts/lib/gate.mjs +1 -1
  103. package/.grok/skills/litgrok/SKILL.md +1 -1
  104. package/.grok-plugin/plugin.json +1 -1
  105. package/CHANGELOG.md +11 -0
  106. package/README.md +9 -9
  107. package/README_ko-KR.md +9 -9
  108. package/docs/assets/readme/badge-version.svg +1 -1
  109. package/docs/reference.md +1 -1
  110. package/docs/reference_ko-KR.md +1 -1
  111. package/package.json +1 -1
  112. package/plugin.json +1 -1
  113. package/.grok/skills/lit-pptx/pretendard-font/public/static/PretendardGOV-Bold.otf +0 -0
  114. package/.grok/skills/lit-pptx/pretendard-font/public/static/PretendardGOV-Regular.otf +0 -0
@@ -139,12 +139,16 @@ function buildBlocks(layout, content) {
139
139
  continue;
140
140
  }
141
141
 
142
- // Check for standalone image
143
- const img = parseImage(remaining);
144
- if (img && !remaining.split("\n").some((l) => l.trim() && !l.trim().startsWith("!["))) {
145
- blocks.push({ type: "image", src: img.src, caption: img.alt || "" });
146
- if (img.alt) {
147
- blocks.push({ type: "figure-caption", caption: img.alt });
142
+ // Check for standalone images: a run of image lines keeps every image, each with its caption.
143
+ const imageLines = remaining.split("\n").filter((l) => l.trim());
144
+ if (imageLines.length && imageLines.every((l) => l.trim().startsWith("![")) && imageLines.some((l) => parseImage(l))) {
145
+ for (const line of imageLines) {
146
+ const img = parseImage(line);
147
+ if (!img) continue;
148
+ blocks.push({ type: "image", src: img.src, caption: img.alt || "" });
149
+ if (img.alt) {
150
+ blocks.push({ type: "figure-caption", caption: img.alt });
151
+ }
148
152
  }
149
153
  continue;
150
154
  }
@@ -432,11 +436,55 @@ function buildDirectiveBlocks(seg, layout) {
432
436
  return [block];
433
437
  }
434
438
 
439
+ case "chart":
440
+ return buildChartBlocks(value, attrs, positional);
441
+
435
442
  default:
436
443
  return [buildDirectiveBlock(directive, value)];
437
444
  }
438
445
  }
439
446
 
447
+ const CHART_TYPES = new Set(["bar", "column", "line", "area", "pie", "doughnut", "stacked"]);
448
+
449
+ /**
450
+ * A native, editable chart. The body is a pipe table: the first column holds the
451
+ * categories, every further column is one series. It travels as a kpi-table block
452
+ * with a `chart` field, so any layout that accepts a table accepts a chart, and a
453
+ * renderer that cannot draw charts still shows the numbers as a table.
454
+ *
455
+ * ::: chart type=column unit="억 원"
456
+ * | 분기 | 매출 | 영업이익 |
457
+ * |---|---|---|
458
+ * | 1Q | 1,120 | 101 |
459
+ * > 분기별 매출과 영업이익 (예시 데이터)
460
+ * :::
461
+ */
462
+ function buildChartBlocks(value, attrs, positional) {
463
+ const table = parseTable(value);
464
+ if (!table || table.headers.length < 2 || table.rows.length === 0) {
465
+ throw new Error('"::: chart" needs a pipe table: categories in the first column, one column per series');
466
+ }
467
+ const type = String(attrs.type || positional[0] || "column").toLowerCase();
468
+ if (!CHART_TYPES.has(type)) {
469
+ throw new Error(`"::: chart" type must be one of ${[...CHART_TYPES].join(", ")}; got "${type}"`);
470
+ }
471
+ const captionLine = value.split("\n").map((l) => l.trim()).find((l) => l.startsWith("> "));
472
+ const caption = captionLine ? captionLine.replace(/^>\s*/, "") : null;
473
+ const chart = { type };
474
+ if (attrs.unit != null) chart.unit = String(attrs.unit);
475
+ if (attrs.title != null) chart.title = String(attrs.title);
476
+ if (attrs.labels != null) chart.labels = attrs.labels !== false && attrs.labels !== "false";
477
+ const blocks = [{
478
+ type: "kpi-table",
479
+ headers: table.headers,
480
+ rows: table.rows,
481
+ chart,
482
+ ...(caption ? { caption } : {}),
483
+ }];
484
+ if (caption) blocks.push({ type: "table-caption", caption, figure: true });
485
+ return blocks;
486
+ }
487
+
440
488
  /**
441
489
  * Build a directive block from name and content.
442
490
  */
@@ -12,6 +12,21 @@ const path = require("path");
12
12
 
13
13
  const ENROLLED_DIR = path.resolve(__dirname, "../../templates/enrolled");
14
14
 
15
+ /**
16
+ * Where templates are looked up, first match wins: every directory in
17
+ * LIT_PPTX_TEMPLATE_DIRS (path-delimiter separated), then .lit-pptx/templates
18
+ * under the working directory (learn_template.py writes there by default),
19
+ * then the templates bundled with the skill. The installed skill directory is
20
+ * read-only, so a learned template never has to be written into it.
21
+ */
22
+ function templateDirs() {
23
+ const extra = (process.env.LIT_PPTX_TEMPLATE_DIRS || "")
24
+ .split(path.delimiter)
25
+ .filter(Boolean)
26
+ .map((dir) => path.resolve(dir));
27
+ return [...extra, path.resolve(process.cwd(), ".lit-pptx", "templates"), ENROLLED_DIR];
28
+ }
29
+
15
30
  /**
16
31
  * Minimal YAML parser for the project's template files.
17
32
  * Handles nested mappings, lists, quoted strings, and numeric values.
@@ -276,10 +291,27 @@ function parseSequence(lines, startLine, minIndent) {
276
291
  return { value: result, nextLine: i };
277
292
  }
278
293
 
294
+ /**
295
+ * Drop a trailing YAML comment (whitespace, then `#`) from a scalar. A quoted
296
+ * value ends at its closing quote, so a `#` inside the quotes stays. Without
297
+ * this, `char_spacing: -0.5 # note` parsed as a string and every run of the
298
+ * template was written with spc="NaN".
299
+ */
300
+ function stripInlineComment(raw) {
301
+ const quote = raw[0];
302
+ if (quote === '"' || quote === "'") {
303
+ const close = raw.indexOf(quote, 1);
304
+ if (close > 0 && /^\s+#/.test(raw.slice(close + 1))) return raw.slice(0, close + 1);
305
+ return raw;
306
+ }
307
+ return raw.replace(/\s+#.*$/, "");
308
+ }
309
+
279
310
  /**
280
311
  * Parse a scalar YAML value (string, number, boolean, null).
281
312
  */
282
313
  function parseScalar(raw) {
314
+ raw = stripInlineComment(raw);
283
315
  if (raw === "null" || raw === "~") return null;
284
316
  if (raw === "true") return true;
285
317
  if (raw === "false") return false;
@@ -312,23 +344,30 @@ function parseScalar(raw) {
312
344
  * @returns {string[]}
313
345
  */
314
346
  function listTemplates() {
315
- if (!fs.existsSync(ENROLLED_DIR)) return [];
316
- return fs
317
- .readdirSync(ENROLLED_DIR)
318
- .filter((name) =>
319
- fs.statSync(path.join(ENROLLED_DIR, name)).isDirectory()
320
- );
347
+ const names = new Set();
348
+ for (const dir of templateDirs()) {
349
+ if (!fs.existsSync(dir)) continue;
350
+ for (const name of fs.readdirSync(dir)) {
351
+ if (fs.statSync(path.join(dir, name)).isDirectory()) names.add(name);
352
+ }
353
+ }
354
+ return [...names].sort();
321
355
  }
322
356
 
323
357
  /**
324
358
  * Load an enrolled template by name.
325
- * @param {string} name — Template directory name (e.g. "AZURE-PRO")
359
+ * @param {string} name — Template directory name (e.g. "MY-BRAND")
326
360
  * @returns {{ name: string, version: string, template: object, mapping: object, capabilities: object }}
327
361
  */
328
362
  function loadTemplate(name) {
329
- const dir = path.join(ENROLLED_DIR, name);
330
- if (!fs.existsSync(dir)) {
331
- throw new Error(`Unknown template: "${name}" not found in enrolled templates`);
363
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(String(name))) {
364
+ throw new Error(`Invalid template name: "${name}"`);
365
+ }
366
+ const dir = templateDirs()
367
+ .map((base) => path.join(base, name))
368
+ .find((candidate) => fs.existsSync(path.join(candidate, "template.yaml")));
369
+ if (!dir) {
370
+ throw new Error(`Unknown template: "${name}" not found in ${templateDirs().join(", ")}`);
332
371
  }
333
372
 
334
373
  const readFile = (basename) => {
@@ -349,7 +388,292 @@ function loadTemplate(name) {
349
388
  template,
350
389
  mapping,
351
390
  capabilities,
391
+ pack: legacyPack(template.name || name, template),
392
+ };
393
+ }
394
+
395
+ // ── Tonality packs ──────────────────────────────────────────────────────────
396
+
397
+ const grid = require("./grid-resolver");
398
+
399
+ const TONALITY_DIR = path.resolve(__dirname, "../../templates/tonalities");
400
+
401
+ /** Pack lookup, first match wins, in the same order as templates. */
402
+ function tonalityDirs() {
403
+ const extra = (process.env.LIT_PPTX_TONALITY_DIRS || "")
404
+ .split(path.delimiter)
405
+ .filter(Boolean)
406
+ .map((dir) => path.resolve(dir));
407
+ return [...extra, path.resolve(process.cwd(), ".lit-pptx", "tonalities"), TONALITY_DIR];
408
+ }
409
+
410
+ function listTonalities() {
411
+ const names = new Set();
412
+ for (const dir of tonalityDirs()) {
413
+ if (!fs.existsSync(dir)) continue;
414
+ for (const name of fs.readdirSync(dir)) {
415
+ if (fs.existsSync(path.join(dir, name, "pack.yaml"))) names.add(name);
416
+ }
417
+ }
418
+ return [...names].sort();
419
+ }
420
+
421
+ const PALETTE_ROLES = ["ground", "surface", "ink", "ink-muted", "line", "accent", "accent-deep", "accent-tint", "field", "on-field", "positive", "negative"];
422
+ const FACE_KEYS = ["display", "title", "body", "label", "numeral"];
423
+ const FACES = {
424
+ "Pretendard Regular": { font: "Pretendard", bold: false },
425
+ "Pretendard Bold": { font: "Pretendard", bold: true },
426
+ "A2Z Light": { font: "에이투지체 3 Light", bold: false },
427
+ "A2Z Regular": { font: "에이투지체 4 Regular", bold: false },
428
+ "A2Z Medium": { font: "에이투지체 5 Medium", bold: false },
429
+ "A2Z Bold": { font: "에이투지체 7 Bold", bold: false },
430
+ "A2Z Black": { font: "에이투지체 9 Black", bold: false },
431
+ };
432
+ const DECORATIONS = ["accent-rule", "hairline-rule", "header-band", "rail-fill", "colour-field", "caption-band", "column-hairlines", "box-outline", "mark-underline", "numeral"];
433
+ const SLIDE_ROLES = ["content", "data", "data-takeaway", "sequence", "image", "statement", "reference", "definition"];
434
+ const FILL_POLICIES = ["distribute", "step-up", "anchor-visual", "change-family"];
435
+ // How a pack carries its display slides: the device on a type-led cover, around a statement,
436
+ // behind a big number and under a closing's next step (`display.index` adds the part index to
437
+ // section slides).
438
+ const DISPLAY_DEVICES = {
439
+ cover: ["plain", "drench", "plate", "rail", "band", "figures", "rules", "numeral"],
440
+ statement: ["open", "drench", "rules", "plate", "offset"],
441
+ number: ["field", "tint", "outline"],
442
+ closing: ["band", "box", "rules"],
443
+ };
444
+ // Contrast pairs every pack must meet (text pairs 4.5:1, large marks 3:1).
445
+ const CONTRAST_PAIRS = [["ink", "ground", 4.5], ["ink", "surface", 4.5], ["ink-muted", "ground", 4.5], ["ink-muted", "surface", 4.5],
446
+ ["accent", "ground", 3.0], ["accent-deep", "accent-tint", 4.5], ["ink", "accent-tint", 4.5], ["on-field", "field", 4.5],
447
+ ["positive", "ground", 4.5], ["negative", "ground", 4.5]];
448
+
449
+ function luminance(hexColour) {
450
+ const v = String(hexColour).replace("#", "");
451
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(v.slice(i, i + 2), 16) / 255)
452
+ .map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
453
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
454
+ }
455
+
456
+ function contrast(a, b) {
457
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
458
+ return (hi + 0.05) / (lo + 0.05);
459
+ }
460
+
461
+ /** Check a parsed pack against the pack schema; throws one message naming every problem. */
462
+ function validatePack(pack, file) {
463
+ const problems = [];
464
+ const need = (cond, msg) => { if (!cond) problems.push(msg); };
465
+ const isList = (v) => Array.isArray(v);
466
+ const hex = (v) => typeof v === "string" && /^#[0-9A-Fa-f]{6}$/u.test(v);
467
+ need(typeof pack.id === "string" && /^[a-z][a-z0-9-]{2,23}$/u.test(pack.id), "id must be 3-24 lowercase letters, digits or hyphens");
468
+ need(typeof pack.name === "string" && pack.name.length >= 1 && pack.name.length <= 24, "name must be 1-24 characters");
469
+ need(typeof pack.version === "string" && /^\d+\.\d+\.\d+$/u.test(pack.version), "version must be semver");
470
+ need(typeof pack.intent === "string" && pack.intent.length > 0, "intent is missing");
471
+ need(isList(pack.canvas) && pack.canvas.length > 0 && pack.canvas.every((c) => grid.CANVAS[c]), "canvas must list 16:9 and/or 4:3");
472
+ // `faces` is the deck's default and is Pretendard only; `faces-a2z` applies when the source asks.
473
+ for (const key of ["faces", "faces-pretendard-only", "faces-a2z"]) {
474
+ if (key === "faces-a2z" && pack[key] == null) continue;
475
+ const faces = pack[key] || {};
476
+ for (const role of FACE_KEYS) {
477
+ need(FACES[faces[role]] && (key === "faces-a2z" || /^Pretendard /u.test(faces[role])), `${key}.${role} must name a bundled face${key === "faces-a2z" ? "" : " (Pretendard Regular or Bold)"}`);
478
+ }
479
+ const distinct = new Set(FACE_KEYS.map((r) => faces[r]));
480
+ need(distinct.size <= 3, `${key} uses ${distinct.size} faces; at most 3`);
481
+ need(new Set([...distinct].map((f) => String(f).split(" ")[0])).size <= 2, `${key} mixes more than two families`);
482
+ }
483
+ const palette = pack.palette || {};
484
+ for (const role of PALETTE_ROLES) need(hex(palette[role]), `palette.${role} is missing or not #RRGGBB`);
485
+ need(isList(palette.series) && palette.series.length >= 2 && palette.series.length <= 4 && palette.series.every(hex),
486
+ "palette.series must list 2-4 #RRGGBB colours");
487
+ if (PALETTE_ROLES.every((r) => hex(palette[r]))) {
488
+ for (const [a, b, min] of CONTRAST_PAIRS) {
489
+ const got = contrast(palette[a], palette[b]);
490
+ need(got >= min, `${a} on ${b} is ${got.toFixed(2)}:1, below ${min}:1`);
491
+ }
492
+ need(Math.abs(luminance(palette.line) - luminance(palette.ground)) >= 0.08, "line is not visible on ground (luminance difference below 0.08)");
493
+ if (isList(palette.series) && palette.series.every(hex)) {
494
+ need(palette.series[0].toUpperCase() === palette.accent.toUpperCase(), "palette.series must start with the accent");
495
+ for (const s of palette.series) need(contrast(s, palette.ground) >= 3, `series colour ${s} is below 3:1 on ground`);
496
+ }
497
+ }
498
+ need(["presented", "reading"].includes(pack.ramp), "ramp must be presented or reading");
499
+ need(typeof pack.hero === "boolean", "hero must be true or false");
500
+ need(Number.isInteger(pack.density) && pack.density >= 1 && pack.density <= 10, "density must be an integer 1-10");
501
+ need(Number.isInteger(pack.variance) && pack.variance >= 4 && pack.variance <= 10, "variance must be an integer 4-10");
502
+ need(pack.radius === 0 || pack.radius === 6, "radius must be 0 or 6");
503
+ need(["border", "fill", "fill+border"].includes(pack.edge), "edge must be border, fill or fill+border");
504
+ const treatments = isList(pack.treatments) ? pack.treatments : [];
505
+ need(treatments.length >= 3 && treatments.length <= 5, "treatments must list 3-5 title treatments");
506
+ for (const t of treatments) need(grid.TREATMENTS.includes(t), `unknown treatment "${t}" (known: ${grid.TREATMENTS.join(", ")})`);
507
+ const roles = pack["role-defaults"] || {};
508
+ for (const role of SLIDE_ROLES) need(treatments.includes(roles[role]), `role-defaults.${role} must be one of the pack's treatments`);
509
+ // A family's own title in this pack, ahead of its role default: the structure the pack draws it with.
510
+ const structure = pack.structure || {};
511
+ need(typeof structure === "object" && !Array.isArray(structure), "structure must be a map of family to treatment");
512
+ for (const [f, t] of Object.entries(structure)) {
513
+ need(Boolean(grid.FAMILIES[f]), `structure names an unknown family "${f}"`);
514
+ need(treatments.includes(t) && grid.FAMILIES[f] && grid.FAMILIES[f].allow.includes(t), `structure.${f} must be a treatment of the pack that the family allows`);
515
+ }
516
+ for (const d of pack.decoration || []) need(DECORATIONS.includes(d), `unknown decoration "${d}"`);
517
+ need(pack.rail == null || ["surface", "field"].includes(pack.rail), "rail must be surface or field");
518
+ const display = pack.display || {};
519
+ need(typeof display === "object" && !Array.isArray(display), "display must be a map");
520
+ for (const [key, devices] of Object.entries(DISPLAY_DEVICES)) {
521
+ need(display[key] == null || devices.includes(display[key]), `display.${key} must be one of ${devices.join(", ")}`);
522
+ }
523
+ need(display.index == null || typeof display.index === "boolean", "display.index must be true or false");
524
+ for (const key of Object.keys(display)) need(key === "index" || DISPLAY_DEVICES[key], `unknown display key "${key}"`);
525
+ const families = pack["layout-families"] || [];
526
+ need(families.length >= 6, "layout-families must list at least 6 families");
527
+ for (const f of families) {
528
+ need(Boolean(grid.FAMILIES[f]), `unknown layout family "${f}"`);
529
+ if (grid.FAMILIES[f]) need(grid.FAMILIES[f].allow.some((t) => treatments.includes(t)), `the ${f} family allows none of the pack's treatments`);
530
+ }
531
+ for (const [key, kind, min] of [["covers", "cover", 3], ["sections", "section", 2], ["closings", "closing", 2]]) {
532
+ const list = pack[key] || [];
533
+ need(list.length >= min, `${key} must list at least ${min} variants`);
534
+ for (const v of list) need(grid.VARIANTS[kind].includes(v), `unknown ${kind} variant "${v}"`);
535
+ }
536
+ for (const key of ["table", "chart", "image"]) need(pack[key] && typeof pack[key] === "object", `${key} settings are missing`);
537
+ const order = pack["fill-order"] || [];
538
+ need(order.length === FILL_POLICIES.length && FILL_POLICIES.every((p) => order.includes(p)), `fill-order must order ${FILL_POLICIES.join(", ")}`);
539
+ if (problems.length) throw new Error(`Tonality pack ${file}: ${problems.join("; ")}`);
540
+ return pack;
541
+ }
542
+
543
+ /** Load and check one tonality pack by id. */
544
+ function loadPack(name) {
545
+ // Pack ids are lowercase; a display name such as "Ledger" names the same pack.
546
+ const id = String(name).toLowerCase();
547
+ const known = listTonalities();
548
+ const dir = tonalityDirs()
549
+ .map((base) => path.join(base, String(id)))
550
+ .find((candidate) => /^[a-z][a-z0-9-]*$/u.test(String(id)) && fs.existsSync(path.join(candidate, "pack.yaml")));
551
+ if (!dir) {
552
+ throw new Error(`Unknown tonality "${name}". Tonalities: ${known.join(", ") || "(none)"}; legacy templates: ${listTemplates().join(", ")}`);
553
+ }
554
+ const file = path.join(dir, "pack.yaml");
555
+ const pack = validatePack(parseYaml(fs.readFileSync(file, "utf-8")), file);
556
+ if (pack.id !== path.basename(dir)) throw new Error(`Tonality pack ${file}: id "${pack.id}" must match its folder name`);
557
+ return pack;
558
+ }
559
+
560
+ // Today's hand-tuned geometry, now a token set. A legacy template draws with these values unless
561
+ // its template.yaml overrides one under `pack: tokens:`; a tonality pack derives its own from the dials.
562
+ const LEGACY_TOKENS = {
563
+ card: { pad: 0.30, chip: 0.52, minH: 1.6, gap: 0.28, radius: 0.16 },
564
+ kpi: { minCardWidth: 1.9, blockHeight: 1.75, gap: 0.26 },
565
+ bullet: { size: 16, x: 0.59, w: 8.8, h: 0.38, ySpacing: 0.42, lineSpacing: 1.25 },
566
+ secHeader: { size: 18, x: 0.39, w: 9.0, h: 0.5 },
567
+ summary: { headerW: 4.3, bulletW: 4.8, bulletSize: 14, bulletH: 0.34, ySpacing: 0.38, lineSpacing: 1.05 },
568
+ toc: { size: 18, x: 0.39, w: 7.5, h: 0.55, yStart: 0.957, ySpacing: 0.65 },
569
+ notice: { x: 0.45, h: 0.3, bottom: 0.14, size: 10.5 },
570
+ };
571
+ // Rich card, KPI and table rendering plus the accent, font and background switches, for a copy of
572
+ // an older template that still says `render_style: azure` instead of listing its capabilities.
573
+ const RICH_LEGACY_CAPABILITIES = ["rich-blocks", "recolor", "font-swap", "mesh-background"];
574
+
575
+ function mergeTokens(base, extra) {
576
+ const out = {};
577
+ for (const [group, values] of Object.entries(base)) out[group] = { ...values, ...((extra && extra[group]) || {}) };
578
+ return out;
579
+ }
580
+
581
+ /**
582
+ * A legacy template read as a pack: one title treatment, its own palette mapped onto the pack
583
+ * roles, its capabilities, and today's geometry as tokens. It renders through the legacy path, so
584
+ * a deck that names the template keeps its look.
585
+ */
586
+ function legacyPack(name, template) {
587
+ const pal = template.palette || {};
588
+ const own = template.pack || {};
589
+ const capabilities = Array.isArray(own.capabilities)
590
+ ? own.capabilities
591
+ : template.render_style === "azure" ? RICH_LEGACY_CAPABILITIES : [];
592
+ const accent = pal.primary || `#${String((template.global_typography || {}).bullet_color || "3C3836").replace("#", "")}`;
593
+ const palette = {
594
+ ground: pal.ground || pal.paper || "#FFFFFF",
595
+ surface: pal.tint || pal.paper_warm || pal.header_fill || "#F6F6F6",
596
+ ink: pal.ink || "#000000",
597
+ "ink-muted": pal.ink_muted || pal.muted || pal.ink || "#555555",
598
+ line: pal.line || "#CCCCCC",
599
+ accent,
600
+ "accent-deep": pal.primary_deep || pal.ink || accent,
601
+ "accent-tint": pal.tint || pal.paper_warm || "#F6F6F6",
602
+ field: pal.primary_deep || pal.ink || accent,
603
+ "on-field": "#FFFFFF",
604
+ };
605
+ const report = CONTRAST_PAIRS
606
+ .filter(([a, b]) => palette[a] && palette[b])
607
+ .map(([a, b, min]) => ({ pair: `${a} on ${b}`, ratio: +contrast(palette[a], palette[b]).toFixed(2), min }))
608
+ .filter((r) => r.ratio < r.min);
609
+ return {
610
+ id: name,
611
+ name,
612
+ legacy: true,
613
+ treatments: ["top-rule"],
614
+ capabilities,
615
+ palette,
616
+ tokens: mergeTokens(LEGACY_TOKENS, own.tokens),
617
+ contrast: report,
618
+ };
619
+ }
620
+
621
+ function hasCapability(templateObj, capability) {
622
+ return Boolean(templateObj && templateObj.pack && (templateObj.pack.capabilities || []).includes(capability));
623
+ }
624
+
625
+ /**
626
+ * Load a tonality pack as the template object the resolver and renderer read. The dials come from
627
+ * the deck (frontmatter or flags) or the pack defaults; the renderer never sees the pack's name.
628
+ */
629
+ function loadTonality(id, options = {}) {
630
+ const pack = loadPack(id);
631
+ const density = grid.clampDial(options.density != null ? options.density : pack.density, pack.density);
632
+ const variance = grid.clampDial(options.variance != null ? options.variance : pack.variance, pack.variance);
633
+ const canvas = options.canvas || pack.canvas[0];
634
+ if (!pack.canvas.includes(canvas)) {
635
+ throw new Error(`Tonality "${pack.id}" supports ${pack.canvas.join(", ")}, not canvas ${canvas}`);
636
+ }
637
+ const tok = grid.densityTokens(density, pack.ramp);
638
+ const g = grid.makeGrid(canvas, tok.grid);
639
+ // Pretendard by default; the A2Z map only when the source asks for it (`faces: a2z`).
640
+ const wantsA2z = String(options.faces || "").toLowerCase() === "a2z" && pack["faces-a2z"];
641
+ const faceMap = wantsA2z ? pack["faces-a2z"] : pack.faces;
642
+ const faces = Object.fromEntries(FACE_KEYS.map((role) => [role, FACES[faceMap[role]]]));
643
+ const P = pack.palette;
644
+ const template = {
645
+ name: pack.name,
646
+ label: `${pack.name} tonality`,
647
+ version: pack.version,
648
+ fonts: { title: faces.title.font, body: faces.body.font, light: faces.body.font },
649
+ dimensions: { width: g.W / 72, height: g.H / 72, unit: "inches", format: canvas },
650
+ palette: {
651
+ ink: P.ink, ink_muted: P["ink-muted"], primary: P.accent, primary_deep: P["accent-deep"],
652
+ azure: P.series[1] || P.accent, azure_soft: P["accent-tint"], tint: P.surface, line: P.line, ground: P.ground, paper: P.ground,
653
+ },
654
+ global_typography: { char_spacing: 0, bullet_color: P.accent.replace("#", ""), section_accent: P.accent.replace("#", "") },
655
+ };
656
+ const pt = (v) => v / 72;
657
+ return {
658
+ name: pack.id,
659
+ version: pack.version,
660
+ template,
661
+ mapping: { layouts: {} },
662
+ capabilities: { font_roles: {} },
663
+ pack: {
664
+ ...pack,
665
+ legacy: false,
666
+ capabilities: [],
667
+ faces,
668
+ dials: { density, variance, varianceMax: grid.varianceMax(variance, pack.treatments.length), canvas },
669
+ tok,
670
+ grid: g,
671
+ tokens: mergeTokens(LEGACY_TOKENS, {
672
+ card: { pad: pt(tok.pad), chip: 0, minH: 0, gap: pt(g.gutter), radius: pt(pack.radius) },
673
+ kpi: { minCardWidth: pt(g.span(1, 2).w), blockHeight: pt(tok.pad * 2 + tok.sizes.display * 1.15 + 6 + tok.sizes.label * 1.3), gap: pt(g.gutter) },
674
+ }),
675
+ },
352
676
  };
353
677
  }
354
678
 
355
- module.exports = { loadTemplate, listTemplates };
679
+ module.exports = { loadTemplate, listTemplates, loadPack, listTonalities, loadTonality, hasCapability, validatePack, legacyPack, LEGACY_TOKENS };
@@ -11,6 +11,9 @@ exits non-zero if ANY real defect is found:
11
11
  No LibreOffice / rendering engine required: everything is read from the OOXML
12
12
  via python-pptx. (A renderer is only ever needed for *visual* preview, which is
13
13
  optional and lives in thumbnail.py.)
14
+
15
+ `--sibling other.pptx` names the same source compiled under another tonality; the deck output checks
16
+ then fail the pair when they differ on fewer than two content slides (one in a deck of under four) (OF-116).
14
17
  """
15
18
 
16
19
  from __future__ import annotations
@@ -28,7 +31,8 @@ from pptx import Presentation
28
31
  from pptx.dml.color import RGBColor
29
32
  from pptx.enum.shapes import MSO_SHAPE_TYPE
30
33
  from pptx.opc.constants import RELATIONSHIP_TYPE as RT
31
- from craft_extras import assess as assess_craft
34
+ from craft_extras import assess as assess_craft, display_slide
35
+ from deck_output import assess as assess_output, compare_skeletons
32
36
 
33
37
  ROOT = Path(__file__).resolve().parents[1]
34
38
  VALIDATE = ROOT / "scripts" / "validate_pptx.py"
@@ -94,10 +98,22 @@ def _bbox(shape):
94
98
  return None
95
99
 
96
100
 
97
- def _resolve_bg(shape, idx, filled):
101
+ def _slide_ground(slide) -> tuple[int, int, int]:
102
+ """The slide's own solid background colour; white when it sets none."""
103
+ try:
104
+ fill = slide.background.fill
105
+ if fill.type == 1: # MSO_FILL.SOLID
106
+ return _hex_to_rgb(fill.fore_color.rgb) or (255, 255, 255)
107
+ except (AttributeError, TypeError, ValueError):
108
+ pass
109
+ return (255, 255, 255)
110
+
111
+
112
+ def _resolve_bg(shape, idx, filled, ground=(255, 255, 255)):
98
113
  """Effective background for a text shape: its own fill, else the topmost
99
114
  filled shape drawn *behind* it (lower z-order) that contains its centre,
100
- else white. Prevents false positives like white text over a charcoal band."""
115
+ else the slide's ground. Prevents false positives like white text over a
116
+ charcoal band or light type on a dark slide."""
101
117
  own = _shape_fill_rgb(shape)
102
118
  if own is not None:
103
119
  return own
@@ -112,7 +128,7 @@ def _resolve_bg(shape, idx, filled):
112
128
  best = f_rgb # later (higher idx) wins -> topmost behind text
113
129
  if best is not None:
114
130
  return best
115
- return (255, 255, 255)
131
+ return ground
116
132
 
117
133
 
118
134
  def check_contrast(path: Path) -> dict[str, Any]:
@@ -123,10 +139,11 @@ def check_contrast(path: Path) -> dict[str, Any]:
123
139
  for s_idx, slide in enumerate(prs.slides, start=1):
124
140
  shapes = list(slide.shapes)
125
141
  filled = [(i, rgb, _bbox(sh)) for i, sh in enumerate(shapes) if (rgb := _shape_fill_rgb(sh)) is not None]
142
+ ground = _slide_ground(slide)
126
143
  for idx, shape in enumerate(shapes):
127
144
  if not getattr(shape, "has_text_frame", False):
128
145
  continue
129
- bg = _resolve_bg(shape, idx, filled)
146
+ bg = _resolve_bg(shape, idx, filled, ground)
130
147
  for para in shape.text_frame.paragraphs:
131
148
  for run in para.runs:
132
149
  if not run.text.strip():
@@ -448,7 +465,11 @@ def check_evidence_binding(path: Path) -> dict[str, Any]:
448
465
  checked += 1
449
466
  figure_box = _bbox_in(shape)
450
467
  nearby: list[str] = []
451
- if figure_box is not None:
468
+ page_area = (prs.slide_width / 914400.0) * (prs.slide_height / 914400.0)
469
+ if figure_box is not None and _is_full_bleed(figure_box, page_area):
470
+ # Nothing can sit under a full-bleed picture: its caption and source ride on the panel over it.
471
+ nearby = [text for other in shapes if other is not shape and (text := _shape_text(other))]
472
+ elif figure_box is not None:
452
473
  figure_width = figure_box[2] - figure_box[0]
453
474
  for other in shapes:
454
475
  text = _shape_text(other)
@@ -621,8 +642,8 @@ def check_visual_substance(path: Path) -> dict[str, Any]:
621
642
  if kind or shape.shape_type == MSO_SHAPE_TYPE.CHART:
622
643
  boxes.append((max(0, x1), max(height * 0.18, y1),
623
644
  min(width, x2), min(height * 0.91, y2)))
624
- if slide_no == 1 or not boxes:
625
- continue
645
+ if slide_no == 1 or not boxes or display_slide(slide):
646
+ continue # a cover, section, statement or quote holds a statement, not a body
626
647
  body_slides += 1
627
648
  if has_table and not any(sh.shape_type == MSO_SHAPE_TYPE.CHART for sh in slide.shapes):
628
649
  table_slides += 1
@@ -695,7 +716,7 @@ def run_validate(pptx: Path) -> dict[str, Any]:
695
716
 
696
717
 
697
718
  # ── gate ───────────────────────────────────────────────────────────────────
698
- def qa(pptx: Path) -> dict[str, Any]:
719
+ def qa(pptx: Path, sibling: Path | None = None) -> dict[str, Any]:
699
720
  validate_report = run_validate(pptx)
700
721
  inv_summary = summarize_inventory(load_inventory_issues(pptx))
701
722
  contrast = check_contrast(pptx)
@@ -704,6 +725,11 @@ def qa(pptx: Path) -> dict[str, Any]:
704
725
  objective = check_objective_quality(pptx)
705
726
  substance = check_visual_substance(pptx)
706
727
  craft = assess_craft(Presentation(str(pptx)))
728
+ output = assess_output(Presentation(str(pptx)))
729
+ # The same source under another tonality, when given: the two decks differ on at least two content slides (OF-116).
730
+ if sibling is not None:
731
+ output["findings"] += compare_skeletons(Presentation(str(pptx)), Presentation(str(sibling)), sibling.name)
732
+ output["pass"] = not any(f["severity"] == "HIGH" for f in output["findings"])
707
733
 
708
734
  reasons: list[str] = []
709
735
  if not validate_report.get("pass", False):
@@ -743,6 +769,11 @@ def qa(pptx: Path) -> dict[str, Any]:
743
769
  reasons.append(f"visual substance: {len(substance['findings'])} finding(s); see fix hints")
744
770
  if not craft["pass"]:
745
771
  reasons.append(f"office craft: {sum(f['severity'] == 'HIGH' for f in craft['findings'])} high finding(s)")
772
+ if not output["pass"]:
773
+ high = sorted({f["rule"] for f in output["findings"] if f["severity"] == "HIGH"})
774
+ reasons.append(f"deck output: {', '.join(high)} — vary title treatments and layout families by slide role, "
775
+ "fill the body and every region a title leaves, keep each treatment's frame, write titles as "
776
+ "topic labels, give a dark ground dark figures, and give each tonality its own structure")
746
777
 
747
778
  return {
748
779
  "file": str(pptx),
@@ -760,14 +791,16 @@ def qa(pptx: Path) -> dict[str, Any]:
760
791
  "objective": objective,
761
792
  "visual_substance": substance,
762
793
  "office_craft": craft,
794
+ "deck_output": output,
763
795
  }
764
796
 
765
797
 
766
798
  def main(argv: list[str] | None = None) -> int:
767
799
  parser = argparse.ArgumentParser(description="Honest renderer-agnostic QA gate for PPTX decks.")
768
800
  parser.add_argument("pptx", type=Path)
801
+ parser.add_argument("--sibling", type=Path, help="the same source compiled under another tonality (OF-116)")
769
802
  args = parser.parse_args(argv)
770
- report = qa(args.pptx.resolve())
803
+ report = qa(args.pptx.resolve(), args.sibling.resolve() if args.sibling else None)
771
804
  print(json.dumps(report, ensure_ascii=False, indent=2))
772
805
  return 0 if report["pass"] else 1
773
806
 
@@ -8,7 +8,7 @@ const require = createRequire(import.meta.url);
8
8
  const deps = require('./deps.cjs');
9
9
  const directory = dirname(fileURLToPath(import.meta.url));
10
10
  const [name, ...args] = process.argv.slice(2);
11
- const allowed = new Set(['qa_deck.py', 'validate_pptx.py', 'inventory.py', 'ooxml_integrity.py', 'embed_fonts.py', 'learn_template.py']);
11
+ const allowed = new Set(['qa_deck.py', 'validate_pptx.py', 'inventory.py', 'ooxml_integrity.py', 'embed_fonts.py', 'learn_template.py', 'deck_output.py', 'craft_extras.py']);
12
12
 
13
13
  if (name === 'doctor') {
14
14
  console.log(`node: ${deps.supportsSlideNode() ? (deps.ready('node') ? 'ready' : 'first-use install needed') : 'Node.js 20.9+ required for slides'}`);
@@ -18,7 +18,8 @@ if (name === 'doctor') {
18
18
  console.log(`${executable}: ${result.status === 0 ? result.stdout.trim() : 'unavailable (optional)'}`);
19
19
  }
20
20
  } else if (allowed.has(name) && existsSync(join(directory, name))) {
21
- const result = spawnSync(deps.python(), [join(directory, name), ...args], { stdio: 'inherit', env: process.env });
21
+ // The installed skill folder stays free of bytecode caches.
22
+ const result = spawnSync(deps.python(), [join(directory, name), ...args], { stdio: 'inherit', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
22
23
  process.exit(result.status ?? 1);
23
24
  } else {
24
25
  console.error('Usage: node run.mjs doctor | <packaged Python script> [arguments]');