@ai-matrx/print 0.4.0 → 0.5.1

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.
@@ -27,6 +27,7 @@ __export(education_exports, {
27
27
  module.exports = __toCommonJS(education_exports);
28
28
 
29
29
  // src/core.ts
30
+ var import_html_escape = require("@ai-matrx/kit/html-escape");
30
31
  var PRINT_STYLES = `
31
32
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
32
33
 
@@ -97,16 +98,13 @@ var PRINT_STYLES = `
97
98
  th { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
98
99
  }
99
100
  `;
100
- function escapeHtml(str) {
101
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
102
- }
103
101
  function buildPrintDocument(bodyHtml, title = "AI Response", extraStyles = "") {
104
102
  return `<!DOCTYPE html>
105
103
  <html lang="en">
106
104
  <head>
107
105
  <meta charset="UTF-8">
108
106
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
109
- <title>${escapeHtml(title)}</title>
107
+ <title>${(0, import_html_escape.escapeHtml)(title)}</title>
110
108
  <style>
111
109
  ${PRINT_STYLES}
112
110
  ${extraStyles}
@@ -144,6 +142,7 @@ function openPrintWindow(htmlDocument, filename = "print", windowFeatures = "wid
144
142
  }
145
143
 
146
144
  // src/education.ts
145
+ var import_html_escape2 = require("@ai-matrx/kit/html-escape");
147
146
  function isRecord(value) {
148
147
  return typeof value === "object" && value !== null && !Array.isArray(value);
149
148
  }
@@ -222,7 +221,7 @@ function resolveRange(raw, fromKey, toKey, total) {
222
221
  }
223
222
  function printEmptyDocument(message, title, styles, filename) {
224
223
  return openPrintWindow(
225
- buildPrintDocument(`<p>${escapeHtml(message)}</p>`, title, styles),
224
+ buildPrintDocument(`<p>${(0, import_html_escape2.escapeHtml)(message)}</p>`, title, styles),
226
225
  filename
227
226
  );
228
227
  }
@@ -388,16 +387,16 @@ function renderCheatSheetItem(item) {
388
387
  const definition = (item.definition ?? "").trim();
389
388
  const formula = (item.formula ?? "").trim();
390
389
  const note = (item.note ?? "").trim();
391
- if (term) parts.push(`<span class="cs-term">${escapeHtml(term)}</span>`);
390
+ if (term) parts.push(`<span class="cs-term">${(0, import_html_escape2.escapeHtml)(term)}</span>`);
392
391
  if (definition) {
393
392
  parts.push(
394
- `${term ? " " : ""}<span class="cs-def">${escapeHtml(definition)}</span>`
393
+ `${term ? " " : ""}<span class="cs-def">${(0, import_html_escape2.escapeHtml)(definition)}</span>`
395
394
  );
396
395
  }
397
396
  if (formula) {
398
- parts.push(`<code class="cs-formula">${escapeHtml(formula)}</code>`);
397
+ parts.push(`<code class="cs-formula">${(0, import_html_escape2.escapeHtml)(formula)}</code>`);
399
398
  }
400
- if (note) parts.push(`<div class="cs-note">${escapeHtml(note)}</div>`);
399
+ if (note) parts.push(`<div class="cs-note">${(0, import_html_escape2.escapeHtml)(note)}</div>`);
401
400
  return `<div class="cs-item">${parts.join("")}</div>`;
402
401
  }
403
402
  function renderCheatSheet(data, variant, opts) {
@@ -408,7 +407,7 @@ function renderCheatSheet(data, variant, opts) {
408
407
  const subtitle = (data.subtitle ?? "").trim();
409
408
  const sections = data.sections.map((section) => {
410
409
  const heading = section.heading.trim();
411
- const bar = heading ? `<h2 class="cs-section-bar">${escapeHtml(heading)}</h2>` : "";
410
+ const bar = heading ? `<h2 class="cs-section-bar">${(0, import_html_escape2.escapeHtml)(heading)}</h2>` : "";
412
411
  const rule = opts.showSectionRules ? `<div class="cs-section-rule"></div>` : "";
413
412
  const items = section.items.map(renderCheatSheetItem).join("\n");
414
413
  return `<section class="cs-section">
@@ -420,8 +419,8 @@ ${items}
420
419
  const columnClass = CHEAT_SHEET_COLUMN_CLASS[variant];
421
420
  const compactClass = opts.compact ? " cs-compact" : "";
422
421
  return `<div class="edu-header">
423
- <h1 class="edu-title">${escapeHtml(data.title)}</h1>
424
- ${subtitle ? `<p class="edu-subtitle">${escapeHtml(subtitle)}</p>` : ""}
422
+ <h1 class="edu-title">${(0, import_html_escape2.escapeHtml)(data.title)}</h1>
423
+ ${subtitle ? `<p class="edu-subtitle">${(0, import_html_escape2.escapeHtml)(subtitle)}</p>` : ""}
425
424
  <div class="edu-meta">${data.sections.length} section${data.sections.length !== 1 ? "s" : ""} &nbsp;\xB7&nbsp; ${itemCount} item${itemCount !== 1 ? "s" : ""}</div>
426
425
  </div>
427
426
  ${hint}
@@ -586,8 +585,8 @@ function renderGlossaryTable(entries, opts) {
586
585
  const index = opts.showIndex ? `<span class="gl-index">${opts.startIndex + i + 1}.</span>` : "";
587
586
  return ` <tr class="gl-row">
588
587
  ${check ? ` ${check}
589
- ` : ""} <td class="gl-term-cell">${index}${escapeHtml(entry.term)}</td>
590
- <td class="gl-def">${escapeHtml(entry.definition)}</td>
588
+ ` : ""} <td class="gl-term-cell">${index}${(0, import_html_escape2.escapeHtml)(entry.term)}</td>
589
+ <td class="gl-def">${(0, import_html_escape2.escapeHtml)(entry.definition)}</td>
591
590
  </tr>`;
592
591
  }).join("\n");
593
592
  const checkHead = opts.masteredCheckbox ? `<th class="gl-th-check">\u2713</th>` : "";
@@ -607,7 +606,7 @@ function renderGlossaryList(entries, opts) {
607
606
  const items = entries.map((entry, i) => {
608
607
  const check = opts.masteredCheckbox ? CHECKBOX_HTML : "";
609
608
  const index = opts.showIndex ? `<span class="gl-index">${opts.startIndex + i + 1}.</span>` : "";
610
- return ` <li class="gl-entry">${check}${index}<span class="gl-term">${escapeHtml(entry.term)}</span> \u2014 <span class="gl-def">${escapeHtml(entry.definition)}</span></li>`;
609
+ return ` <li class="gl-entry">${check}${index}<span class="gl-term">${(0, import_html_escape2.escapeHtml)(entry.term)}</span> \u2014 <span class="gl-def">${(0, import_html_escape2.escapeHtml)(entry.definition)}</span></li>`;
611
610
  }).join("\n");
612
611
  return `<ul class="gl-list">
613
612
  ${items}
@@ -693,7 +692,7 @@ var glossaryPrinter = {
693
692
  const variant = variantId === "compact-list" ? "compact-list" : "table";
694
693
  const body = variant === "compact-list" ? renderGlossaryList(entries, opts) : renderGlossaryTable(entries, opts);
695
694
  const header = `<div class="edu-header">
696
- <h1 class="edu-title">${escapeHtml(title)}</h1>
695
+ <h1 class="edu-title">${(0, import_html_escape2.escapeHtml)(title)}</h1>
697
696
  <div class="edu-meta">${entries.length} term${entries.length !== 1 ? "s" : ""}${entries.length !== allEntries.length ? ` of ${allEntries.length}` : ""}</div>
698
697
  </div>`;
699
698
  return openPrintWindow(
@@ -814,13 +813,13 @@ function renderStudyDay(day, opts) {
814
813
  const milestone = (day.milestone ?? "").trim();
815
814
  const tasks = day.tasks.length > 0 ? `<ul class="sc-tasks">
816
815
  ${day.tasks.map(
817
- (task) => ` <li class="sc-task">${opts.taskCheckbox ? CHECKBOX_HTML : ""}<span class="sc-task-text">${escapeHtml(task)}</span></li>`
816
+ (task) => ` <li class="sc-task">${opts.taskCheckbox ? CHECKBOX_HTML : ""}<span class="sc-task-text">${(0, import_html_escape2.escapeHtml)(task)}</span></li>`
818
817
  ).join("\n")}
819
818
  </ul>` : `<div class="sc-empty-day">Rest / catch-up</div>`;
820
819
  const milestoneHtml = opts.showMilestones && milestone ? `
821
- <div class="sc-milestone"><span class="sc-milestone-flag">Milestone</span>${escapeHtml(milestone)}</div>` : "";
820
+ <div class="sc-milestone"><span class="sc-milestone-flag">Milestone</span>${(0, import_html_escape2.escapeHtml)(milestone)}</div>` : "";
822
821
  return ` <div class="sc-day">
823
- ${label ? `<div class="sc-day-label">${escapeHtml(label)}</div>` : ""}
822
+ ${label ? `<div class="sc-day-label">${(0, import_html_escape2.escapeHtml)(label)}</div>` : ""}
824
823
  ${tasks}${milestoneHtml}
825
824
  </div>`;
826
825
  }
@@ -834,7 +833,7 @@ function renderStudyCalendar(data, variant, opts) {
834
833
  const weeks = data.weeks.map((week) => {
835
834
  const label = week.label.trim();
836
835
  return `<section class="sc-week">
837
- ${label ? `<h2 class="sc-week-label">${escapeHtml(label)}</h2>` : ""}
836
+ ${label ? `<h2 class="sc-week-label">${(0, import_html_escape2.escapeHtml)(label)}</h2>` : ""}
838
837
  <div class="${daysClass}">
839
838
  ${week.days.map((day) => renderStudyDay(day, opts)).join("\n")}
840
839
  </div>
@@ -842,8 +841,8 @@ ${week.days.map((day) => renderStudyDay(day, opts)).join("\n")}
842
841
  }).join("\n");
843
842
  const hint = variant === "landscape-grid" ? `<div class="edu-hint">Set your printer to <strong>Landscape</strong> \u2014 each week prints as a row of day columns.</div>` : "";
844
843
  return `<div class="edu-header">
845
- <h1 class="edu-title">${escapeHtml(data.title)}</h1>
846
- ${subtitle ? `<p class="edu-subtitle">${escapeHtml(subtitle)}</p>` : ""}
844
+ <h1 class="edu-title">${(0, import_html_escape2.escapeHtml)(data.title)}</h1>
845
+ ${subtitle ? `<p class="edu-subtitle">${(0, import_html_escape2.escapeHtml)(subtitle)}</p>` : ""}
847
846
  <div class="edu-meta">${data.weeks.length} week${data.weeks.length !== 1 ? "s" : ""} &nbsp;\xB7&nbsp; ${taskCount} task${taskCount !== 1 ? "s" : ""}</div>
848
847
  </div>
849
848
  ${hint}
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/education.ts","../src/core.ts"],"sourcesContent":["/**\n * @ai-matrx/print/education — the education / test-prep artifact library.\n *\n * Wave 3. Modeled on what the test-prep leaders actually hand a student\n * (Magoosh/7Sage one-pagers, Kaplan/Quizlet vocabulary lists, 7Sage's\n * week-by-week study calendar) per\n * `common-docs/projects/npm-package-extraction/PRINT-EDUCATION-RESEARCH.md`:\n *\n * - density WITH hierarchy — never a wall of text; every unit is a titled,\n * break-safe block inside a dense column flow;\n * - every artifact is independently printable — one sheet, no companion file;\n * - ONE content source → MANY layouts chosen at print time (the Quizlet\n * Table/Glossary/Small/Large picker). Our BlockPrinter variants ARE that\n * picker: the caller's data never changes, only the variant id.\n *\n * Three printers, all pure string composition over `./core`:\n * cheatSheetPrinter — the dense one-pager (2-col / 3-col ultra / large print)\n * glossaryPrinter — vocabulary list (term/definition table / compact list)\n * studyCalendarPrinter — week-by-week schedule (week rows / landscape grid)\n *\n * Zero dependencies, no module-level mutable state, import-inert: `window` is\n * only touched inside `print()` via core's `openPrintWindow`.\n */\n\nimport {\n buildPrintDocument,\n openPrintWindow,\n escapeHtml,\n type BlockPrinter,\n type PrintOutcome,\n type PrintSettings,\n} from \"./core\";\n\n// ---------------------------------------------------------------------------\n// Data contracts\n// ---------------------------------------------------------------------------\n\n/** One line of a cheat sheet: a term, and any of definition / formula / note. */\nexport interface CheatSheetItem {\n term: string;\n definition?: string;\n formula?: string;\n note?: string;\n}\n\n/** A titled group of cheat-sheet items — renders as a dense heading bar. */\nexport interface CheatSheetSection {\n heading: string;\n items: CheatSheetItem[];\n}\n\n/** The whole one-pager. */\nexport interface CheatSheetData {\n title: string;\n subtitle?: string;\n sections: CheatSheetSection[];\n}\n\n/** One glossary / vocabulary row. */\nexport interface GlossaryEntry {\n term: string;\n definition: string;\n}\n\n/** The whole vocabulary list. */\nexport interface GlossaryData {\n title: string;\n entries: GlossaryEntry[];\n}\n\n/** One day of a study plan — its tasks, and optionally a milestone banner. */\nexport interface StudyDay {\n label: string;\n tasks: string[];\n milestone?: string;\n}\n\n/** One week of a study plan. */\nexport interface StudyWeek {\n label: string;\n days: StudyDay[];\n}\n\n/** The whole study calendar. */\nexport interface StudyCalendarData {\n title: string;\n subtitle?: string;\n weeks: StudyWeek[];\n}\n\nexport type CheatSheetVariant = \"two-column\" | \"three-column\" | \"one-column\";\nexport type GlossaryVariant = \"table\" | \"compact-list\";\nexport type StudyCalendarVariant = \"week-rows\" | \"landscape-grid\";\n\n// ---------------------------------------------------------------------------\n// Structural readers — the host may hand us anything; nothing here throws.\n// ---------------------------------------------------------------------------\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction readString(source: unknown, key: string): string {\n if (!isRecord(source)) return \"\";\n const value = source[key];\n return typeof value === \"string\" ? value : \"\";\n}\n\nfunction readArray(source: unknown, key: string): unknown[] {\n if (!isRecord(source)) return [];\n const value = source[key];\n return Array.isArray(value) ? value : [];\n}\n\n/** A trimmed title, or the given fallback when the host supplied none. */\nfunction readTitle(data: unknown, fallback: string): string {\n const title = readString(data, \"title\").trim();\n return title.length > 0 ? title : fallback;\n}\n\n/** String list with non-strings and blank entries dropped. */\nfunction readStringList(source: unknown, key: string): string[] {\n return readArray(source, key)\n .filter((entry): entry is string => typeof entry === \"string\")\n .map((entry) => entry.trim())\n .filter((entry) => entry.length > 0);\n}\n\nfunction extractCheatSheetSections(data: unknown): CheatSheetSection[] {\n return readArray(data, \"sections\")\n .map((raw): CheatSheetSection => {\n const items = readArray(raw, \"items\")\n .filter(isRecord)\n .map(\n (rawItem): CheatSheetItem => ({\n term: readString(rawItem, \"term\"),\n definition: readString(rawItem, \"definition\"),\n formula: readString(rawItem, \"formula\"),\n note: readString(rawItem, \"note\"),\n }),\n )\n .filter(\n (item) =>\n item.term.trim().length > 0 ||\n (item.definition ?? \"\").trim().length > 0 ||\n (item.formula ?? \"\").trim().length > 0,\n );\n return { heading: readString(raw, \"heading\"), items };\n })\n .filter((section) => section.items.length > 0);\n}\n\nfunction extractGlossaryEntries(data: unknown): GlossaryEntry[] {\n return readArray(data, \"entries\")\n .filter(isRecord)\n .map(\n (raw): GlossaryEntry => ({\n term: readString(raw, \"term\"),\n definition: readString(raw, \"definition\"),\n }),\n )\n .filter(\n (entry) =>\n entry.term.trim().length > 0 || entry.definition.trim().length > 0,\n );\n}\n\nfunction extractStudyWeeks(data: unknown): StudyWeek[] {\n return readArray(data, \"weeks\")\n .map((rawWeek): StudyWeek => {\n const days = readArray(rawWeek, \"days\")\n .filter(isRecord)\n .map((rawDay): StudyDay => {\n const milestone = readString(rawDay, \"milestone\");\n return {\n label: readString(rawDay, \"label\"),\n tasks: readStringList(rawDay, \"tasks\"),\n milestone,\n };\n })\n .filter(\n (day) =>\n day.label.trim().length > 0 ||\n day.tasks.length > 0 ||\n (day.milestone ?? \"\").trim().length > 0,\n );\n return { label: readString(rawWeek, \"label\"), days };\n })\n .filter((week) => week.days.length > 0);\n}\n\n// ---------------------------------------------------------------------------\n// Settings helpers\n// ---------------------------------------------------------------------------\n\nfunction boolSetting(\n raw: PrintSettings | undefined,\n key: string,\n fallback: boolean,\n): boolean {\n const value = raw?.[key];\n return value === undefined ? fallback : Boolean(value);\n}\n\nfunction numberSetting(raw: PrintSettings | undefined, key: string): number {\n const value = raw?.[key];\n return typeof value === \"number\" && Number.isFinite(value) ? value : 0;\n}\n\n/**\n * Resolve a `range` setting against a list length, the flashcards precedent:\n * 0 / missing / out-of-bounds fall back to the ends, an inverted pair is\n * swapped, and the result is always a valid 1-based inclusive window.\n * `startIndex` is the 0-based offset of the first kept item, so callers can\n * keep numbering items by their position in the FULL list.\n */\nfunction resolveRange(\n raw: PrintSettings | undefined,\n fromKey: string,\n toKey: string,\n total: number,\n): { startIndex: number; endIndex: number } {\n const rawFrom = numberSetting(raw, fromKey);\n const rawTo = numberSetting(raw, toKey);\n const start = rawFrom > 0 ? Math.min(rawFrom, total) : 1;\n const end = rawTo > 0 ? Math.min(rawTo, total) : total;\n const [from, to] = start <= end ? [start, end] : [end, start];\n return { startIndex: from - 1, endIndex: to };\n}\n\n/** The polite empty-data document (the flashcards precedent). */\nfunction printEmptyDocument(\n message: string,\n title: string,\n styles: string,\n filename: string,\n): PrintOutcome {\n return openPrintWindow(\n buildPrintDocument(`<p>${escapeHtml(message)}</p>`, title, styles),\n filename,\n );\n}\n\n/** A printable, ink-free checkbox — the \"mastered\" / task-done square. */\nconst CHECKBOX_HTML = `<span class=\"edu-check\" aria-hidden=\"true\"></span>`;\n\n/** Shared header + checkbox styles for every education artifact. */\nconst EDU_BASE_STYLES = `\n h1.edu-title {\n font-size: 15pt !important;\n font-weight: 700;\n color: #0f172a;\n margin: 0 0 3px 0;\n letter-spacing: -0.01em;\n border-bottom: none !important;\n }\n .edu-subtitle {\n font-size: 9pt;\n color: #475569;\n margin: 0;\n }\n .edu-meta {\n font-size: 8pt;\n color: #64748b;\n margin-top: 2px;\n }\n .edu-header {\n border-bottom: 1.5px solid #cbd5e1;\n padding-bottom: 8px;\n margin-bottom: 12px;\n }\n .edu-hint {\n font-size: 8pt;\n color: #475569;\n background: #f1f5f9;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 10px;\n margin-bottom: 12px;\n line-height: 1.45;\n }\n .edu-check {\n display: inline-block;\n width: 9pt;\n height: 9pt;\n border: 1px solid #64748b;\n border-radius: 2px;\n background: #fff;\n vertical-align: -1px;\n margin-right: 6px;\n flex: 0 0 auto;\n }\n\n @media print {\n @page { margin: 12mm 10mm; }\n .edu-hint { display: none !important; }\n .edu-check {\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n }\n`;\n\n// ---------------------------------------------------------------------------\n// 1. Cheat sheet — the Magoosh / 7Sage dense one-pager\n// ---------------------------------------------------------------------------\n\nconst CHEAT_SHEET_STYLES = `\n ${EDU_BASE_STYLES}\n\n .cs-sheet {\n column-gap: 14px;\n column-fill: auto;\n }\n .cs-cols-2 { column-count: 2; font-size: 8.5pt; }\n .cs-cols-3 { column-count: 3; font-size: 7.5pt; }\n .cs-cols-1 { column-count: 1; font-size: 11.5pt; }\n\n .cs-section {\n break-inside: auto;\n page-break-inside: auto;\n margin-bottom: 10px;\n }\n\n /* Section headings are dense BARS, not airy titles — the hierarchy that\n keeps a one-pager scannable instead of a wall of text. */\n .cs-section-bar {\n display: block;\n background: #1e293b;\n color: #f8fafc;\n font-size: 8pt !important;\n font-weight: 700;\n letter-spacing: 0.07em;\n text-transform: uppercase;\n padding: 3px 7px;\n margin: 0 0 5px 0 !important;\n border-radius: 3px;\n border-bottom: none !important;\n break-after: avoid;\n page-break-after: avoid;\n }\n .cs-cols-1 .cs-section-bar { font-size: 11pt !important; padding: 5px 10px; }\n\n .cs-section-rule {\n height: 0;\n border-bottom: 1px solid #cbd5e1;\n margin: 0 0 6px 0;\n }\n\n .cs-item {\n break-inside: avoid;\n page-break-inside: avoid;\n margin-bottom: 6px;\n padding-left: 5px;\n border-left: 2px solid #e2e8f0;\n line-height: 1.4;\n }\n .cs-term {\n font-weight: 700;\n color: #0f172a;\n }\n .cs-def {\n color: #334155;\n }\n .cs-note {\n color: #64748b;\n font-style: italic;\n }\n\n /* Boxed formula — mono + border is how every leader prints an equation. */\n code.cs-formula {\n display: block;\n font-family: \"SFMono-Regular\", Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 0.95em;\n background: #f8fafc;\n border: 1px solid #94a3b8;\n border-radius: 3px;\n padding: 2px 5px;\n margin: 3px 0;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n /* Compact: tighter leading and a smaller floor for a true single sheet. */\n .cs-compact.cs-cols-2 { font-size: 7.5pt; }\n .cs-compact.cs-cols-3 { font-size: 6.5pt; }\n .cs-compact.cs-cols-1 { font-size: 10pt; }\n .cs-compact .cs-item { margin-bottom: 3px; line-height: 1.25; }\n .cs-compact .cs-section { margin-bottom: 6px; }\n .cs-compact .cs-section-bar { padding: 2px 6px; margin-bottom: 3px !important; }\n\n @media print {\n .cs-section-bar {\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n code.cs-formula {\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n }\n`;\n\nconst CHEAT_SHEET_COLUMN_CLASS: Record<CheatSheetVariant, string> = {\n \"two-column\": \"cs-cols-2\",\n \"three-column\": \"cs-cols-3\",\n \"one-column\": \"cs-cols-1\",\n};\n\nfunction renderCheatSheetItem(item: CheatSheetItem): string {\n const parts: string[] = [];\n const term = item.term.trim();\n const definition = (item.definition ?? \"\").trim();\n const formula = (item.formula ?? \"\").trim();\n const note = (item.note ?? \"\").trim();\n\n if (term) parts.push(`<span class=\"cs-term\">${escapeHtml(term)}</span>`);\n if (definition) {\n parts.push(\n `${term ? \" \" : \"\"}<span class=\"cs-def\">${escapeHtml(definition)}</span>`,\n );\n }\n if (formula) {\n parts.push(`<code class=\"cs-formula\">${escapeHtml(formula)}</code>`);\n }\n if (note) parts.push(`<div class=\"cs-note\">${escapeHtml(note)}</div>`);\n\n return `<div class=\"cs-item\">${parts.join(\"\")}</div>`;\n}\n\nfunction renderCheatSheet(\n data: CheatSheetData,\n variant: CheatSheetVariant,\n opts: { showSectionRules: boolean; compact: boolean; duplexHint: boolean },\n): string {\n const itemCount = data.sections.reduce(\n (total, section) => total + section.items.length,\n 0,\n );\n const subtitle = (data.subtitle ?? \"\").trim();\n\n const sections = data.sections\n .map((section) => {\n const heading = section.heading.trim();\n const bar = heading\n ? `<h2 class=\"cs-section-bar\">${escapeHtml(heading)}</h2>`\n : \"\";\n const rule = opts.showSectionRules\n ? `<div class=\"cs-section-rule\"></div>`\n : \"\";\n const items = section.items.map(renderCheatSheetItem).join(\"\\n\");\n return `<section class=\"cs-section\">\n${bar}${rule}\n${items}\n</section>`;\n })\n .join(\"\\n\");\n\n const hint = opts.duplexHint\n ? `<div class=\"edu-hint\">Two-sided sheet: print double-sided, flip on <strong>long edge</strong>, 100% scale, no margins — then trim or slip it into a sleeve.</div>`\n : \"\";\n\n const columnClass = CHEAT_SHEET_COLUMN_CLASS[variant];\n const compactClass = opts.compact ? \" cs-compact\" : \"\";\n\n return `<div class=\"edu-header\">\n <h1 class=\"edu-title\">${escapeHtml(data.title)}</h1>\n ${subtitle ? `<p class=\"edu-subtitle\">${escapeHtml(subtitle)}</p>` : \"\"}\n <div class=\"edu-meta\">${data.sections.length} section${data.sections.length !== 1 ? \"s\" : \"\"} &nbsp;·&nbsp; ${itemCount} item${itemCount !== 1 ? \"s\" : \"\"}</div>\n</div>\n${hint}\n<div class=\"cs-sheet ${columnClass}${compactClass}\">\n${sections}\n</div>`;\n}\n\nexport const cheatSheetPrinter: BlockPrinter = {\n label: \"Print cheat sheet\",\n\n variants: [\n {\n id: \"two-column\",\n label: \"Two column (recommended)\",\n description:\n \"The classic dense one-pager — two columns, boxed formulas, section bars. Fits a surprising amount on a single sheet and still reads at arm's length\",\n },\n {\n id: \"three-column\",\n label: \"Three column (ultra-dense)\",\n description:\n \"Maximum content per sheet — three narrow columns at a smaller type size. Best for a reference you scan rather than read\",\n },\n {\n id: \"one-column\",\n label: \"Single column (large print)\",\n description:\n \"One wide column at a comfortable reading size — for studying, for a wall, or for anyone who needs bigger type\",\n },\n ],\n\n settings: [\n {\n type: \"boolean\" as const,\n id: \"showSectionRules\",\n label: \"Rule under each section\",\n description:\n \"Draws a hairline under every section heading — extra separation when sections run together\",\n defaultValue: true,\n },\n {\n type: \"boolean\" as const,\n id: \"compact\",\n label: \"Compact\",\n description:\n \"Tighter leading and a smaller type floor — use when the sheet spills onto a second page\",\n defaultValue: false,\n },\n {\n type: \"boolean\" as const,\n id: \"duplexHint\",\n label: \"Show double-sided printing tip\",\n description:\n \"Adds an on-screen note with the duplex settings for a two-sided sheet. Never printed\",\n defaultValue: false,\n },\n ],\n\n print(\n data: unknown,\n variantId: string = \"two-column\",\n rawSettings?: PrintSettings,\n ): PrintOutcome {\n const title = readTitle(data, \"Cheat Sheet\");\n const sections = extractCheatSheetSections(data);\n\n if (sections.length === 0) {\n return printEmptyDocument(\n \"No cheat sheet data available to print.\",\n title,\n CHEAT_SHEET_STYLES,\n \"cheat-sheet\",\n );\n }\n\n const variant: CheatSheetVariant =\n variantId === \"three-column\" || variantId === \"one-column\"\n ? variantId\n : \"two-column\";\n\n const subtitle = readString(data, \"subtitle\");\n const body = renderCheatSheet(\n { title, subtitle, sections },\n variant,\n {\n showSectionRules: boolSetting(rawSettings, \"showSectionRules\", true),\n compact: boolSetting(rawSettings, \"compact\", false),\n duplexHint: boolSetting(rawSettings, \"duplexHint\", false),\n },\n );\n\n return openPrintWindow(\n buildPrintDocument(body, title, CHEAT_SHEET_STYLES),\n \"cheat-sheet\",\n );\n },\n};\n\n// ---------------------------------------------------------------------------\n// 2. Glossary / vocabulary list — Kaplan / Quizlet Glossary\n// ---------------------------------------------------------------------------\n\nconst GLOSSARY_STYLES = `\n ${EDU_BASE_STYLES}\n\n table.gl-table {\n width: 100%;\n border-collapse: collapse;\n margin-top: 4px;\n font-size: 9.5pt;\n }\n table.gl-table thead th {\n background: #1e293b;\n color: #f8fafc;\n text-align: left;\n font-size: 8pt;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n padding: 5px 9px;\n border: none;\n }\n table.gl-table th.gl-th-check { width: 22px; }\n table.gl-table th.gl-th-term { width: 32%; }\n\n table.gl-table tbody tr.gl-row {\n break-inside: avoid;\n page-break-inside: avoid;\n }\n table.gl-table tbody td {\n padding: 4px 9px;\n border-bottom: 1px solid #e2e8f0;\n vertical-align: top;\n line-height: 1.4;\n background: transparent;\n }\n table.gl-table tbody tr:nth-child(even) td { background: #f8fafc; }\n td.gl-check-cell { width: 22px; text-align: center; }\n td.gl-term-cell { font-weight: 700; color: #0f172a; }\n .gl-index {\n display: inline-block;\n min-width: 20px;\n font-size: 8pt;\n font-weight: 400;\n color: #94a3b8;\n }\n\n ul.gl-list {\n column-count: 2;\n column-gap: 18px;\n list-style: none;\n margin: 4px 0 0 0;\n padding: 0;\n font-size: 8.5pt;\n }\n ul.gl-list li.gl-entry {\n break-inside: avoid;\n page-break-inside: avoid;\n margin-bottom: 5px;\n padding-left: 4px;\n border-left: 2px solid #e2e8f0;\n line-height: 1.35;\n }\n .gl-term { font-weight: 700; color: #0f172a; }\n .gl-def { color: #334155; }\n\n @media print {\n table.gl-table thead th {\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n table.gl-table tbody tr:nth-child(even) td {\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n }\n`;\n\n/**\n * Alphabetize by term, case- and accent-insensitively. `Array.sort` is stable\n * (ES2019+), so equal terms keep their authored order — and with the setting\n * OFF the authored order is returned untouched.\n */\nfunction orderGlossaryEntries(\n entries: GlossaryEntry[],\n alphabetize: boolean,\n): GlossaryEntry[] {\n if (!alphabetize) return entries;\n return [...entries].sort((a, b) =>\n a.term.localeCompare(b.term, undefined, { sensitivity: \"base\" }),\n );\n}\n\nfunction renderGlossaryTable(\n entries: GlossaryEntry[],\n opts: { masteredCheckbox: boolean; showIndex: boolean; startIndex: number },\n): string {\n const rows = entries\n .map((entry, i) => {\n const check = opts.masteredCheckbox\n ? `<td class=\"gl-check-cell\">${CHECKBOX_HTML}</td>`\n : \"\";\n const index = opts.showIndex\n ? `<span class=\"gl-index\">${opts.startIndex + i + 1}.</span>`\n : \"\";\n return ` <tr class=\"gl-row\">\n${check ? ` ${check}\\n` : \"\"} <td class=\"gl-term-cell\">${index}${escapeHtml(entry.term)}</td>\n <td class=\"gl-def\">${escapeHtml(entry.definition)}</td>\n </tr>`;\n })\n .join(\"\\n\");\n\n const checkHead = opts.masteredCheckbox\n ? `<th class=\"gl-th-check\">✓</th>`\n : \"\";\n\n return `<table class=\"gl-table\">\n <thead>\n <tr>\n ${checkHead}<th class=\"gl-th-term\">Term</th>\n <th>Definition</th>\n </tr>\n </thead>\n <tbody>\n${rows}\n </tbody>\n</table>`;\n}\n\nfunction renderGlossaryList(\n entries: GlossaryEntry[],\n opts: { masteredCheckbox: boolean; showIndex: boolean; startIndex: number },\n): string {\n const items = entries\n .map((entry, i) => {\n const check = opts.masteredCheckbox ? CHECKBOX_HTML : \"\";\n const index = opts.showIndex\n ? `<span class=\"gl-index\">${opts.startIndex + i + 1}.</span>`\n : \"\";\n return ` <li class=\"gl-entry\">${check}${index}<span class=\"gl-term\">${escapeHtml(entry.term)}</span> — <span class=\"gl-def\">${escapeHtml(entry.definition)}</span></li>`;\n })\n .join(\"\\n\");\n\n return `<ul class=\"gl-list\">\n${items}\n</ul>`;\n}\n\nexport const glossaryPrinter: BlockPrinter = {\n label: \"Print glossary\",\n\n variants: [\n {\n id: \"table\",\n label: \"Term / definition table (recommended)\",\n description:\n \"Two-column table with a mastered checkbox per row — the Kaplan/Quizlet vocabulary sheet\",\n },\n {\n id: \"compact-list\",\n label: \"Compact list\",\n description:\n \"Two columns of one-line entries — many more terms per sheet, for a final review pass\",\n },\n ],\n\n settings: [\n {\n type: \"range\" as const,\n id: \"entryRange\",\n label: \"Entries to print\",\n description: \"Leave blank to print the whole list\",\n fromId: \"fromEntry\",\n toId: \"toEntry\",\n defaultFrom: 0,\n defaultTo: 0,\n min: 1,\n fromPlaceholder: \"1\",\n toPlaceholder: \"end\",\n },\n {\n type: \"boolean\" as const,\n id: \"alphabetize\",\n label: \"Alphabetize\",\n description:\n \"Sorts by term. Off prints the entries in the order they were authored\",\n defaultValue: false,\n },\n {\n type: \"boolean\" as const,\n id: \"masteredCheckbox\",\n label: \"Mastered checkbox\",\n description:\n \"An empty box on every row to tick off as each term is learned\",\n defaultValue: true,\n },\n {\n type: \"boolean\" as const,\n id: \"showIndex\",\n label: \"Number the entries\",\n description:\n \"Prints 1., 2., 3. … so a study partner can call out terms by number\",\n defaultValue: false,\n },\n ],\n\n print(\n data: unknown,\n variantId: string = \"table\",\n rawSettings?: PrintSettings,\n ): PrintOutcome {\n const title = readTitle(data, \"Glossary\");\n const allEntries = extractGlossaryEntries(data);\n\n if (allEntries.length === 0) {\n return printEmptyDocument(\n \"No glossary data available to print.\",\n title,\n GLOSSARY_STYLES,\n \"glossary\",\n );\n }\n\n // Order first, THEN slice: the range names positions on the printed page,\n // so \"entries 1–20\" alphabetized means the first twenty alphabetically.\n const ordered = orderGlossaryEntries(\n allEntries,\n boolSetting(rawSettings, \"alphabetize\", false),\n );\n const { startIndex, endIndex } = resolveRange(\n rawSettings,\n \"fromEntry\",\n \"toEntry\",\n ordered.length,\n );\n const entries = ordered.slice(startIndex, endIndex);\n\n const opts = {\n masteredCheckbox: boolSetting(rawSettings, \"masteredCheckbox\", true),\n showIndex: boolSetting(rawSettings, \"showIndex\", false),\n startIndex,\n };\n\n const variant: GlossaryVariant =\n variantId === \"compact-list\" ? \"compact-list\" : \"table\";\n const body =\n variant === \"compact-list\"\n ? renderGlossaryList(entries, opts)\n : renderGlossaryTable(entries, opts);\n\n const header = `<div class=\"edu-header\">\n <h1 class=\"edu-title\">${escapeHtml(title)}</h1>\n <div class=\"edu-meta\">${entries.length} term${entries.length !== 1 ? \"s\" : \"\"}${entries.length !== allEntries.length ? ` of ${allEntries.length}` : \"\"}</div>\n</div>`;\n\n return openPrintWindow(\n buildPrintDocument(`${header}\\n${body}`, title, GLOSSARY_STYLES),\n \"glossary\",\n );\n },\n};\n\n// ---------------------------------------------------------------------------\n// 3. Study calendar / schedule — the 7Sage week-by-week plan\n// ---------------------------------------------------------------------------\n\nconst STUDY_CALENDAR_STYLES = `\n ${EDU_BASE_STYLES}\n\n /* A week is the unit that must never split across pages when it fits. */\n .sc-week {\n break-inside: avoid;\n page-break-inside: avoid;\n margin-bottom: 12px;\n border: 1px solid #cbd5e1;\n border-radius: 5px;\n overflow: hidden;\n }\n .sc-week-label {\n background: #1e293b;\n color: #f8fafc;\n font-size: 9pt !important;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n padding: 4px 9px;\n margin: 0 !important;\n border-bottom: none !important;\n }\n\n .sc-days-rows .sc-day {\n display: block;\n border-top: 1px solid #e2e8f0;\n padding: 5px 9px;\n }\n .sc-days-rows .sc-day:first-child { border-top: none; }\n\n .sc-days-grid {\n display: grid;\n grid-auto-flow: column;\n grid-auto-columns: 1fr;\n }\n .sc-days-grid .sc-day {\n border-left: 1px solid #e2e8f0;\n padding: 5px 7px;\n min-height: 1.1in;\n }\n .sc-days-grid .sc-day:first-child { border-left: none; }\n\n .sc-day-label {\n font-size: 8pt;\n font-weight: 700;\n color: #0f172a;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n margin-bottom: 3px;\n }\n\n ul.sc-tasks {\n list-style: none;\n margin: 0;\n padding: 0;\n font-size: 8.5pt;\n }\n li.sc-task {\n display: flex;\n align-items: flex-start;\n line-height: 1.35;\n margin-bottom: 2px;\n break-inside: avoid;\n page-break-inside: avoid;\n color: #334155;\n }\n\n /* Milestones are the thing a student actually plans around — they are\n visually LOUD and survive a black-and-white printer. */\n .sc-milestone {\n margin-top: 4px;\n border: 1.5px solid #b45309;\n border-left-width: 5px;\n background: #fef3c7;\n color: #78350f;\n font-size: 8pt;\n font-weight: 700;\n padding: 3px 7px;\n border-radius: 3px;\n line-height: 1.3;\n }\n .sc-milestone-flag {\n letter-spacing: 0.08em;\n text-transform: uppercase;\n margin-right: 5px;\n font-size: 7pt;\n }\n\n .sc-empty-day { font-size: 8pt; color: #94a3b8; font-style: italic; }\n\n @media print {\n .sc-week-label,\n .sc-milestone {\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n }\n`;\n\n/** Landscape geometry lives with the variant, not the shared sheet. */\nconst STUDY_CALENDAR_LANDSCAPE_STYLES = `\n ${STUDY_CALENDAR_STYLES}\n\n @media print {\n @page { size: landscape; margin: 10mm; }\n }\n`;\n\nfunction renderStudyDay(\n day: StudyDay,\n opts: { taskCheckbox: boolean; showMilestones: boolean },\n): string {\n const label = day.label.trim();\n const milestone = (day.milestone ?? \"\").trim();\n\n const tasks =\n day.tasks.length > 0\n ? `<ul class=\"sc-tasks\">\n${day.tasks\n .map(\n (task) =>\n ` <li class=\"sc-task\">${opts.taskCheckbox ? CHECKBOX_HTML : \"\"}<span class=\"sc-task-text\">${escapeHtml(task)}</span></li>`,\n )\n .join(\"\\n\")}\n </ul>`\n : `<div class=\"sc-empty-day\">Rest / catch-up</div>`;\n\n const milestoneHtml =\n opts.showMilestones && milestone\n ? `\\n <div class=\"sc-milestone\"><span class=\"sc-milestone-flag\">Milestone</span>${escapeHtml(milestone)}</div>`\n : \"\";\n\n return ` <div class=\"sc-day\">\n ${label ? `<div class=\"sc-day-label\">${escapeHtml(label)}</div>` : \"\"}\n ${tasks}${milestoneHtml}\n </div>`;\n}\n\nfunction renderStudyCalendar(\n data: StudyCalendarData,\n variant: StudyCalendarVariant,\n opts: { taskCheckbox: boolean; showMilestones: boolean },\n): string {\n const daysClass =\n variant === \"landscape-grid\" ? \"sc-days sc-days-grid\" : \"sc-days sc-days-rows\";\n const subtitle = (data.subtitle ?? \"\").trim();\n const taskCount = data.weeks.reduce(\n (total, week) =>\n total + week.days.reduce((sum, day) => sum + day.tasks.length, 0),\n 0,\n );\n\n const weeks = data.weeks\n .map((week) => {\n const label = week.label.trim();\n return `<section class=\"sc-week\">\n ${label ? `<h2 class=\"sc-week-label\">${escapeHtml(label)}</h2>` : \"\"}\n <div class=\"${daysClass}\">\n${week.days.map((day) => renderStudyDay(day, opts)).join(\"\\n\")}\n </div>\n</section>`;\n })\n .join(\"\\n\");\n\n const hint =\n variant === \"landscape-grid\"\n ? `<div class=\"edu-hint\">Set your printer to <strong>Landscape</strong> — each week prints as a row of day columns.</div>`\n : \"\";\n\n return `<div class=\"edu-header\">\n <h1 class=\"edu-title\">${escapeHtml(data.title)}</h1>\n ${subtitle ? `<p class=\"edu-subtitle\">${escapeHtml(subtitle)}</p>` : \"\"}\n <div class=\"edu-meta\">${data.weeks.length} week${data.weeks.length !== 1 ? \"s\" : \"\"} &nbsp;·&nbsp; ${taskCount} task${taskCount !== 1 ? \"s\" : \"\"}</div>\n</div>\n${hint}\n<div class=\"sc-plan\">\n${weeks}\n</div>`;\n}\n\nexport const studyCalendarPrinter: BlockPrinter = {\n label: \"Print study calendar\",\n\n variants: [\n {\n id: \"week-rows\",\n label: \"Week per block (portrait, recommended)\",\n description:\n \"Each week is one block with its days stacked as rows — the most room for tasks, and a week never splits across pages when it fits\",\n },\n {\n id: \"landscape-grid\",\n label: \"Calendar grid (landscape)\",\n description:\n \"Each week is a row of day columns, calendar-style — best for a wall or a binder pocket\",\n },\n ],\n\n settings: [\n {\n type: \"range\" as const,\n id: \"weekRange\",\n label: \"Weeks to print\",\n description: \"Leave blank to print the whole plan\",\n fromId: \"fromWeek\",\n toId: \"toWeek\",\n defaultFrom: 0,\n defaultTo: 0,\n min: 1,\n fromPlaceholder: \"1\",\n toPlaceholder: \"end\",\n },\n {\n type: \"boolean\" as const,\n id: \"taskCheckbox\",\n label: \"Checkbox per task\",\n description: \"An empty box in front of every task to tick off as it is done\",\n defaultValue: true,\n },\n {\n type: \"boolean\" as const,\n id: \"showMilestones\",\n label: \"Show milestones\",\n description:\n \"Prints each milestone as a bordered banner on its day — practice tests, deadlines, review checkpoints\",\n defaultValue: true,\n },\n ],\n\n print(\n data: unknown,\n variantId: string = \"week-rows\",\n rawSettings?: PrintSettings,\n ): PrintOutcome {\n const title = readTitle(data, \"Study Calendar\");\n const allWeeks = extractStudyWeeks(data);\n const variant: StudyCalendarVariant =\n variantId === \"landscape-grid\" ? \"landscape-grid\" : \"week-rows\";\n const styles =\n variant === \"landscape-grid\"\n ? STUDY_CALENDAR_LANDSCAPE_STYLES\n : STUDY_CALENDAR_STYLES;\n\n if (allWeeks.length === 0) {\n return printEmptyDocument(\n \"No study calendar data available to print.\",\n title,\n styles,\n \"study-calendar\",\n );\n }\n\n const { startIndex, endIndex } = resolveRange(\n rawSettings,\n \"fromWeek\",\n \"toWeek\",\n allWeeks.length,\n );\n const weeks = allWeeks.slice(startIndex, endIndex);\n\n const subtitle = readString(data, \"subtitle\");\n const body = renderStudyCalendar({ title, subtitle, weeks }, variant, {\n taskCheckbox: boolSetting(rawSettings, \"taskCheckbox\", true),\n showMilestones: boolSetting(rawSettings, \"showMilestones\", true),\n });\n\n return openPrintWindow(\n buildPrintDocument(body, title, styles),\n \"study-calendar\",\n );\n },\n};\n","/**\n * @ai-matrx/print/core — the Block Print contract & print-window composition.\n *\n * Ported VERBATIM from matrx-frontend `lib/block-print/block-print-utils.ts`\n * (2026-08-29 state, including the `number` and `select` PrintSetting types the\n * commerce label lane added). Zero dependencies; touches `window` only at call\n * time, so the module stays import-inert and RSC-safe.\n *\n * BlockPrinter is the only interface a host system needs to know about.\n * Each printable unit exports its own BlockPrinter alongside its render\n * component; host code only ever calls print(data, variantId, settings).\n *\n * `buildFitTextScript` is the ONE auto-shrink script (the flashcards\n * FIT_TEXT pattern, parameterized by selector so the label lane shares it).\n */\n\n// ─── Contract ────────────────────────────────────────────────────────────────\n\nexport interface PrintVariant {\n id: string;\n label: string;\n description?: string;\n}\n\n/**\n * A single user-configurable setting shown in the print dialog.\n * Extend the discriminated union for new control types\n * without breaking existing printers.\n */\nexport type PrintSetting =\n | {\n type: \"boolean\";\n id: string;\n label: string;\n description?: string;\n /** Default value shown when the dialog first opens */\n defaultValue: boolean;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n }\n | {\n /**\n * A numeric \"from → to\" range control, rendered as two small number inputs.\n * Writes two entries into the resolved PrintSettings map — one under fromId,\n * one under toId. Printers interpret 0 (or missing) as \"no bound\" by convention.\n */\n type: \"range\";\n /** Stable id used as React key — not written to the settings map */\n id: string;\n label: string;\n description?: string;\n /** Settings map key for the lower bound value */\n fromId: string;\n /** Settings map key for the upper bound value */\n toId: string;\n defaultFrom: number;\n defaultTo: number;\n /** Minimum accepted value for each input (inclusive). Default 0. */\n min?: number;\n /** Placeholder shown when the input is empty (0). Default \"start\" / \"end\". */\n fromPlaceholder?: string;\n toPlaceholder?: string;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n }\n | {\n /** A single numeric input (e.g. \"start at label #\", \"copies\"). */\n type: \"number\";\n id: string;\n label: string;\n description?: string;\n defaultValue: number;\n /** Minimum accepted value (inclusive). Default 0. */\n min?: number;\n /** Maximum accepted value (inclusive). Omit = unbounded. */\n max?: number;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n }\n | {\n /** A one-of-N choice rendered as a compact segmented row. */\n type: \"select\";\n id: string;\n label: string;\n description?: string;\n options: { value: string; label: string }[];\n defaultValue: string;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n };\n\n/** A resolved map of setting id → current value, passed to print(). */\nexport type PrintSettings = Record<string, boolean | string | number>;\n\n/**\n * The self-contained print capability that each block can export.\n * Host code only ever calls print(data, variantId, settings) — it never inspects internals.\n */\nexport interface BlockPrinter {\n /** Tooltip / aria-label for the print button in the block header */\n label: string;\n\n /**\n * Variants offered to the user before printing.\n * Empty array = no options dialog shown; prints immediately with defaults.\n */\n variants: PrintVariant[];\n\n /**\n * Optional user-configurable settings shown below the variant picker.\n * Omit entirely if the block has no configurable settings.\n */\n settings?: PrintSetting[];\n\n /**\n * Execute the print.\n * data — the block's structured data (serverData or parsed content)\n * variantId — which variant was chosen (undefined = default / no variants)\n * settings — resolved setting values chosen by the user\n *\n * MAY resolve to a PrintOutcome (\"opened\" | \"downloaded\") so the host can\n * tell the user when the popup-blocked download fallback fired; `void`\n * stays legal for printers that don't go through a print window.\n */\n print: (\n data: unknown,\n variantId?: string,\n settings?: PrintSettings,\n ) => void | PrintOutcome | Promise<void | PrintOutcome>;\n}\n\n// ─── Shared Utilities ─────────────────────────────────────────────────────────\n\nconst PRINT_STYLES = `\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n body {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;\n font-size: 11pt;\n line-height: 1.6;\n color: #1a1a1a;\n background: #fff;\n max-width: 860px;\n margin: 0 auto;\n padding: 28px 36px;\n }\n\n .print-actions {\n display: flex;\n gap: 10px;\n margin-bottom: 24px;\n padding-bottom: 18px;\n border-bottom: 2px solid #e5e7eb;\n }\n\n .print-btn {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 8px 20px;\n border-radius: 8px;\n font-size: 13px;\n font-family: inherit;\n cursor: pointer;\n border: none;\n font-weight: 600;\n }\n\n .print-btn-primary { background: #2563eb; color: #fff; }\n .print-btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }\n\n h1 { font-size: 22pt; font-weight: 700; margin-bottom: 0.5em; }\n h2 { font-size: 16pt; font-weight: 700; margin: 1.2em 0 0.4em; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.2em; }\n h3 { font-size: 13pt; font-weight: 600; margin: 1em 0 0.3em; }\n p { margin-bottom: 0.7em; }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 1em 0;\n font-size: 10pt;\n }\n th {\n background: #1e293b;\n color: #f8fafc;\n font-weight: 600;\n text-align: left;\n padding: 7px 12px;\n border: 1px solid #334155;\n }\n td {\n padding: 6px 12px;\n border: 1px solid #e2e8f0;\n vertical-align: top;\n }\n tr:nth-child(even) td { background: #f8fafc; }\n\n @media print {\n body { max-width: 100%; padding: 0; }\n .print-actions { display: none !important; }\n th { -webkit-print-color-adjust: exact; print-color-adjust: exact; }\n }\n`;\n\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#39;\");\n}\n\n/**\n * Wrap body HTML in a complete, styled print document.\n */\nexport function buildPrintDocument(\n bodyHtml: string,\n title = \"AI Response\",\n extraStyles = \"\",\n): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>${escapeHtml(title)}</title>\n <style>\n${PRINT_STYLES}\n${extraStyles}\n </style>\n</head>\n<body>\n <div class=\"print-actions\">\n <button class=\"print-btn print-btn-primary\" onclick=\"window.print()\">Print / Save as PDF</button>\n <button class=\"print-btn print-btn-secondary\" onclick=\"window.close()\">Close</button>\n </div>\n <div class=\"content\">\n${bodyHtml}\n </div>\n</body>\n</html>`;\n}\n\n/**\n * How a print request actually reached the user.\n *\n * \"opened\" — a print window/tab was opened and the document written into it.\n * \"downloaded\" — the popup was blocked, so the document was downloaded as an\n * .html file instead (the contract's fallback). Hosts SHOULD\n * surface this to the user (e.g. a toast saying the print file\n * was downloaded) — a blocked popup plus a silent download is\n * indistinguishable from \"nothing happened\", which is exactly\n * how QA filed it (2026-08-30, F5/F6/F8).\n */\nexport type PrintOutcome = \"opened\" | \"downloaded\";\n\n/**\n * Open a new browser window and write a complete HTML document into it.\n * Falls back to downloading an .html file if the popup is blocked.\n *\n * Returns which path was taken so callers can tell the user when the\n * fallback fired. `windowFeatures` customizes the popup geometry (the\n * flashcards fixed-geometry variants pass a wider window).\n */\nexport function openPrintWindow(\n htmlDocument: string,\n filename = \"print\",\n windowFeatures = \"width=920,height=720,scrollbars=yes\",\n): PrintOutcome {\n const win = window.open(\"\", \"_blank\", windowFeatures);\n if (!win) {\n // Popup blocked — fall back to file download\n const blob = new Blob([htmlDocument], { type: \"text/html;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = `${filename.replace(/\\s+/g, \"-\").toLowerCase()}.html`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke on a macrotask so the browser has started the blob fetch —\n // a synchronous revoke races the download in some engines.\n setTimeout(() => URL.revokeObjectURL(url), 0);\n return \"downloaded\";\n }\n win.document.open();\n win.document.write(htmlDocument);\n win.document.close();\n return \"opened\";\n}\n\n/**\n * Shorthand: build the document and open it in one call.\n * Returns the same PrintOutcome as openPrintWindow.\n */\nexport function printHtmlContent(\n bodyHtml: string,\n title = \"AI Response\",\n extraStyles = \"\",\n): PrintOutcome {\n const doc = buildPrintDocument(bodyHtml, title, extraStyles);\n return openPrintWindow(doc, title);\n}\n\n/**\n * Build the auto-shrink script that runs INSIDE a printed window: for every\n * element matching each selector, its font-size is reduced (0.5pt steps, 6pt\n * floor) until it fits its parent element's box. This is the flashcards\n * FIT_TEXT behavior verbatim, parameterized by selector so every lane\n * (flashcards card text, Avery text, label captions) shares the ONE loop.\n *\n * Pass the same selectors the original used to get byte-identical behavior:\n * flashcards passes `[\".card-text .card-text-inner\", \".avery-text-wrap .avery-text\"]`.\n */\nexport function buildFitTextScript(selectors: readonly string[]): string {\n const selectorsJson = JSON.stringify([...selectors]);\n return `\n<script>\n(function() {\n function fitText(el, container) {\n // container is the bounding box we must fit inside\n var maxW = container.clientWidth;\n var maxH = container.clientHeight;\n var size = parseFloat(window.getComputedStyle(el).fontSize);\n var minSize = 6;\n // Give the element a chance to reflow at current size before measuring\n el.style.fontSize = size + \"pt\";\n while (size > minSize && (el.scrollWidth > maxW + 1 || el.scrollHeight > maxH + 1)) {\n size -= 0.5;\n el.style.fontSize = size + \"pt\";\n }\n }\n function runAll() {\n var selectors = ${selectorsJson};\n for (var s = 0; s < selectors.length; s++) {\n var els = document.querySelectorAll(selectors[s]);\n for (var i = 0; i < els.length; i++) {\n fitText(els[i], els[i].parentElement);\n }\n }\n }\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", runAll);\n } else {\n // document.write pages may already be loaded\n setTimeout(runAll, 0);\n }\n})();\n</script>`;\n}\n\nexport { escapeHtml };\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/education.ts","../src/core.ts"],"sourcesContent":["/**\n * @ai-matrx/print/education — the education / test-prep artifact library.\n *\n * Wave 3. Modeled on what the test-prep leaders actually hand a student\n * (Magoosh/7Sage one-pagers, Kaplan/Quizlet vocabulary lists, 7Sage's\n * week-by-week study calendar) per\n * `common-docs/projects/npm-package-extraction/PRINT-EDUCATION-RESEARCH.md`:\n *\n * - density WITH hierarchy — never a wall of text; every unit is a titled,\n * break-safe block inside a dense column flow;\n * - every artifact is independently printable — one sheet, no companion file;\n * - ONE content source → MANY layouts chosen at print time (the Quizlet\n * Table/Glossary/Small/Large picker). Our BlockPrinter variants ARE that\n * picker: the caller's data never changes, only the variant id.\n *\n * Three printers, all pure string composition over `./core`:\n * cheatSheetPrinter — the dense one-pager (2-col / 3-col ultra / large print)\n * glossaryPrinter — vocabulary list (term/definition table / compact list)\n * studyCalendarPrinter — week-by-week schedule (week rows / landscape grid)\n *\n * Zero dependencies, no module-level mutable state, import-inert: `window` is\n * only touched inside `print()` via core's `openPrintWindow`.\n */\n\nimport {\n buildPrintDocument,\n openPrintWindow,\n type BlockPrinter,\n type PrintOutcome,\n type PrintSettings,\n} from \"./core\";\nimport { escapeHtml } from \"@ai-matrx/kit/html-escape\";\n\n// ---------------------------------------------------------------------------\n// Data contracts\n// ---------------------------------------------------------------------------\n\n/** One line of a cheat sheet: a term, and any of definition / formula / note. */\nexport interface CheatSheetItem {\n term: string;\n definition?: string;\n formula?: string;\n note?: string;\n}\n\n/** A titled group of cheat-sheet items — renders as a dense heading bar. */\nexport interface CheatSheetSection {\n heading: string;\n items: CheatSheetItem[];\n}\n\n/** The whole one-pager. */\nexport interface CheatSheetData {\n title: string;\n subtitle?: string;\n sections: CheatSheetSection[];\n}\n\n/** One glossary / vocabulary row. */\nexport interface GlossaryEntry {\n term: string;\n definition: string;\n}\n\n/** The whole vocabulary list. */\nexport interface GlossaryData {\n title: string;\n entries: GlossaryEntry[];\n}\n\n/** One day of a study plan — its tasks, and optionally a milestone banner. */\nexport interface StudyDay {\n label: string;\n tasks: string[];\n milestone?: string;\n}\n\n/** One week of a study plan. */\nexport interface StudyWeek {\n label: string;\n days: StudyDay[];\n}\n\n/** The whole study calendar. */\nexport interface StudyCalendarData {\n title: string;\n subtitle?: string;\n weeks: StudyWeek[];\n}\n\nexport type CheatSheetVariant = \"two-column\" | \"three-column\" | \"one-column\";\nexport type GlossaryVariant = \"table\" | \"compact-list\";\nexport type StudyCalendarVariant = \"week-rows\" | \"landscape-grid\";\n\n// ---------------------------------------------------------------------------\n// Structural readers — the host may hand us anything; nothing here throws.\n// ---------------------------------------------------------------------------\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction readString(source: unknown, key: string): string {\n if (!isRecord(source)) return \"\";\n const value = source[key];\n return typeof value === \"string\" ? value : \"\";\n}\n\nfunction readArray(source: unknown, key: string): unknown[] {\n if (!isRecord(source)) return [];\n const value = source[key];\n return Array.isArray(value) ? value : [];\n}\n\n/** A trimmed title, or the given fallback when the host supplied none. */\nfunction readTitle(data: unknown, fallback: string): string {\n const title = readString(data, \"title\").trim();\n return title.length > 0 ? title : fallback;\n}\n\n/** String list with non-strings and blank entries dropped. */\nfunction readStringList(source: unknown, key: string): string[] {\n return readArray(source, key)\n .filter((entry): entry is string => typeof entry === \"string\")\n .map((entry) => entry.trim())\n .filter((entry) => entry.length > 0);\n}\n\nfunction extractCheatSheetSections(data: unknown): CheatSheetSection[] {\n return readArray(data, \"sections\")\n .map((raw): CheatSheetSection => {\n const items = readArray(raw, \"items\")\n .filter(isRecord)\n .map(\n (rawItem): CheatSheetItem => ({\n term: readString(rawItem, \"term\"),\n definition: readString(rawItem, \"definition\"),\n formula: readString(rawItem, \"formula\"),\n note: readString(rawItem, \"note\"),\n }),\n )\n .filter(\n (item) =>\n item.term.trim().length > 0 ||\n (item.definition ?? \"\").trim().length > 0 ||\n (item.formula ?? \"\").trim().length > 0,\n );\n return { heading: readString(raw, \"heading\"), items };\n })\n .filter((section) => section.items.length > 0);\n}\n\nfunction extractGlossaryEntries(data: unknown): GlossaryEntry[] {\n return readArray(data, \"entries\")\n .filter(isRecord)\n .map(\n (raw): GlossaryEntry => ({\n term: readString(raw, \"term\"),\n definition: readString(raw, \"definition\"),\n }),\n )\n .filter(\n (entry) =>\n entry.term.trim().length > 0 || entry.definition.trim().length > 0,\n );\n}\n\nfunction extractStudyWeeks(data: unknown): StudyWeek[] {\n return readArray(data, \"weeks\")\n .map((rawWeek): StudyWeek => {\n const days = readArray(rawWeek, \"days\")\n .filter(isRecord)\n .map((rawDay): StudyDay => {\n const milestone = readString(rawDay, \"milestone\");\n return {\n label: readString(rawDay, \"label\"),\n tasks: readStringList(rawDay, \"tasks\"),\n milestone,\n };\n })\n .filter(\n (day) =>\n day.label.trim().length > 0 ||\n day.tasks.length > 0 ||\n (day.milestone ?? \"\").trim().length > 0,\n );\n return { label: readString(rawWeek, \"label\"), days };\n })\n .filter((week) => week.days.length > 0);\n}\n\n// ---------------------------------------------------------------------------\n// Settings helpers\n// ---------------------------------------------------------------------------\n\nfunction boolSetting(\n raw: PrintSettings | undefined,\n key: string,\n fallback: boolean,\n): boolean {\n const value = raw?.[key];\n return value === undefined ? fallback : Boolean(value);\n}\n\nfunction numberSetting(raw: PrintSettings | undefined, key: string): number {\n const value = raw?.[key];\n return typeof value === \"number\" && Number.isFinite(value) ? value : 0;\n}\n\n/**\n * Resolve a `range` setting against a list length, the flashcards precedent:\n * 0 / missing / out-of-bounds fall back to the ends, an inverted pair is\n * swapped, and the result is always a valid 1-based inclusive window.\n * `startIndex` is the 0-based offset of the first kept item, so callers can\n * keep numbering items by their position in the FULL list.\n */\nfunction resolveRange(\n raw: PrintSettings | undefined,\n fromKey: string,\n toKey: string,\n total: number,\n): { startIndex: number; endIndex: number } {\n const rawFrom = numberSetting(raw, fromKey);\n const rawTo = numberSetting(raw, toKey);\n const start = rawFrom > 0 ? Math.min(rawFrom, total) : 1;\n const end = rawTo > 0 ? Math.min(rawTo, total) : total;\n const [from, to] = start <= end ? [start, end] : [end, start];\n return { startIndex: from - 1, endIndex: to };\n}\n\n/** The polite empty-data document (the flashcards precedent). */\nfunction printEmptyDocument(\n message: string,\n title: string,\n styles: string,\n filename: string,\n): PrintOutcome {\n return openPrintWindow(\n buildPrintDocument(`<p>${escapeHtml(message)}</p>`, title, styles),\n filename,\n );\n}\n\n/** A printable, ink-free checkbox — the \"mastered\" / task-done square. */\nconst CHECKBOX_HTML = `<span class=\"edu-check\" aria-hidden=\"true\"></span>`;\n\n/** Shared header + checkbox styles for every education artifact. */\nconst EDU_BASE_STYLES = `\n h1.edu-title {\n font-size: 15pt !important;\n font-weight: 700;\n color: #0f172a;\n margin: 0 0 3px 0;\n letter-spacing: -0.01em;\n border-bottom: none !important;\n }\n .edu-subtitle {\n font-size: 9pt;\n color: #475569;\n margin: 0;\n }\n .edu-meta {\n font-size: 8pt;\n color: #64748b;\n margin-top: 2px;\n }\n .edu-header {\n border-bottom: 1.5px solid #cbd5e1;\n padding-bottom: 8px;\n margin-bottom: 12px;\n }\n .edu-hint {\n font-size: 8pt;\n color: #475569;\n background: #f1f5f9;\n border: 1px solid #cbd5e1;\n border-radius: 6px;\n padding: 6px 10px;\n margin-bottom: 12px;\n line-height: 1.45;\n }\n .edu-check {\n display: inline-block;\n width: 9pt;\n height: 9pt;\n border: 1px solid #64748b;\n border-radius: 2px;\n background: #fff;\n vertical-align: -1px;\n margin-right: 6px;\n flex: 0 0 auto;\n }\n\n @media print {\n @page { margin: 12mm 10mm; }\n .edu-hint { display: none !important; }\n .edu-check {\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n }\n`;\n\n// ---------------------------------------------------------------------------\n// 1. Cheat sheet — the Magoosh / 7Sage dense one-pager\n// ---------------------------------------------------------------------------\n\nconst CHEAT_SHEET_STYLES = `\n ${EDU_BASE_STYLES}\n\n .cs-sheet {\n column-gap: 14px;\n column-fill: auto;\n }\n .cs-cols-2 { column-count: 2; font-size: 8.5pt; }\n .cs-cols-3 { column-count: 3; font-size: 7.5pt; }\n .cs-cols-1 { column-count: 1; font-size: 11.5pt; }\n\n .cs-section {\n break-inside: auto;\n page-break-inside: auto;\n margin-bottom: 10px;\n }\n\n /* Section headings are dense BARS, not airy titles — the hierarchy that\n keeps a one-pager scannable instead of a wall of text. */\n .cs-section-bar {\n display: block;\n background: #1e293b;\n color: #f8fafc;\n font-size: 8pt !important;\n font-weight: 700;\n letter-spacing: 0.07em;\n text-transform: uppercase;\n padding: 3px 7px;\n margin: 0 0 5px 0 !important;\n border-radius: 3px;\n border-bottom: none !important;\n break-after: avoid;\n page-break-after: avoid;\n }\n .cs-cols-1 .cs-section-bar { font-size: 11pt !important; padding: 5px 10px; }\n\n .cs-section-rule {\n height: 0;\n border-bottom: 1px solid #cbd5e1;\n margin: 0 0 6px 0;\n }\n\n .cs-item {\n break-inside: avoid;\n page-break-inside: avoid;\n margin-bottom: 6px;\n padding-left: 5px;\n border-left: 2px solid #e2e8f0;\n line-height: 1.4;\n }\n .cs-term {\n font-weight: 700;\n color: #0f172a;\n }\n .cs-def {\n color: #334155;\n }\n .cs-note {\n color: #64748b;\n font-style: italic;\n }\n\n /* Boxed formula — mono + border is how every leader prints an equation. */\n code.cs-formula {\n display: block;\n font-family: \"SFMono-Regular\", Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 0.95em;\n background: #f8fafc;\n border: 1px solid #94a3b8;\n border-radius: 3px;\n padding: 2px 5px;\n margin: 3px 0;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n /* Compact: tighter leading and a smaller floor for a true single sheet. */\n .cs-compact.cs-cols-2 { font-size: 7.5pt; }\n .cs-compact.cs-cols-3 { font-size: 6.5pt; }\n .cs-compact.cs-cols-1 { font-size: 10pt; }\n .cs-compact .cs-item { margin-bottom: 3px; line-height: 1.25; }\n .cs-compact .cs-section { margin-bottom: 6px; }\n .cs-compact .cs-section-bar { padding: 2px 6px; margin-bottom: 3px !important; }\n\n @media print {\n .cs-section-bar {\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n code.cs-formula {\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n }\n`;\n\nconst CHEAT_SHEET_COLUMN_CLASS: Record<CheatSheetVariant, string> = {\n \"two-column\": \"cs-cols-2\",\n \"three-column\": \"cs-cols-3\",\n \"one-column\": \"cs-cols-1\",\n};\n\nfunction renderCheatSheetItem(item: CheatSheetItem): string {\n const parts: string[] = [];\n const term = item.term.trim();\n const definition = (item.definition ?? \"\").trim();\n const formula = (item.formula ?? \"\").trim();\n const note = (item.note ?? \"\").trim();\n\n if (term) parts.push(`<span class=\"cs-term\">${escapeHtml(term)}</span>`);\n if (definition) {\n parts.push(\n `${term ? \" \" : \"\"}<span class=\"cs-def\">${escapeHtml(definition)}</span>`,\n );\n }\n if (formula) {\n parts.push(`<code class=\"cs-formula\">${escapeHtml(formula)}</code>`);\n }\n if (note) parts.push(`<div class=\"cs-note\">${escapeHtml(note)}</div>`);\n\n return `<div class=\"cs-item\">${parts.join(\"\")}</div>`;\n}\n\nfunction renderCheatSheet(\n data: CheatSheetData,\n variant: CheatSheetVariant,\n opts: { showSectionRules: boolean; compact: boolean; duplexHint: boolean },\n): string {\n const itemCount = data.sections.reduce(\n (total, section) => total + section.items.length,\n 0,\n );\n const subtitle = (data.subtitle ?? \"\").trim();\n\n const sections = data.sections\n .map((section) => {\n const heading = section.heading.trim();\n const bar = heading\n ? `<h2 class=\"cs-section-bar\">${escapeHtml(heading)}</h2>`\n : \"\";\n const rule = opts.showSectionRules\n ? `<div class=\"cs-section-rule\"></div>`\n : \"\";\n const items = section.items.map(renderCheatSheetItem).join(\"\\n\");\n return `<section class=\"cs-section\">\n${bar}${rule}\n${items}\n</section>`;\n })\n .join(\"\\n\");\n\n const hint = opts.duplexHint\n ? `<div class=\"edu-hint\">Two-sided sheet: print double-sided, flip on <strong>long edge</strong>, 100% scale, no margins — then trim or slip it into a sleeve.</div>`\n : \"\";\n\n const columnClass = CHEAT_SHEET_COLUMN_CLASS[variant];\n const compactClass = opts.compact ? \" cs-compact\" : \"\";\n\n return `<div class=\"edu-header\">\n <h1 class=\"edu-title\">${escapeHtml(data.title)}</h1>\n ${subtitle ? `<p class=\"edu-subtitle\">${escapeHtml(subtitle)}</p>` : \"\"}\n <div class=\"edu-meta\">${data.sections.length} section${data.sections.length !== 1 ? \"s\" : \"\"} &nbsp;·&nbsp; ${itemCount} item${itemCount !== 1 ? \"s\" : \"\"}</div>\n</div>\n${hint}\n<div class=\"cs-sheet ${columnClass}${compactClass}\">\n${sections}\n</div>`;\n}\n\nexport const cheatSheetPrinter: BlockPrinter = {\n label: \"Print cheat sheet\",\n\n variants: [\n {\n id: \"two-column\",\n label: \"Two column (recommended)\",\n description:\n \"The classic dense one-pager — two columns, boxed formulas, section bars. Fits a surprising amount on a single sheet and still reads at arm's length\",\n },\n {\n id: \"three-column\",\n label: \"Three column (ultra-dense)\",\n description:\n \"Maximum content per sheet — three narrow columns at a smaller type size. Best for a reference you scan rather than read\",\n },\n {\n id: \"one-column\",\n label: \"Single column (large print)\",\n description:\n \"One wide column at a comfortable reading size — for studying, for a wall, or for anyone who needs bigger type\",\n },\n ],\n\n settings: [\n {\n type: \"boolean\" as const,\n id: \"showSectionRules\",\n label: \"Rule under each section\",\n description:\n \"Draws a hairline under every section heading — extra separation when sections run together\",\n defaultValue: true,\n },\n {\n type: \"boolean\" as const,\n id: \"compact\",\n label: \"Compact\",\n description:\n \"Tighter leading and a smaller type floor — use when the sheet spills onto a second page\",\n defaultValue: false,\n },\n {\n type: \"boolean\" as const,\n id: \"duplexHint\",\n label: \"Show double-sided printing tip\",\n description:\n \"Adds an on-screen note with the duplex settings for a two-sided sheet. Never printed\",\n defaultValue: false,\n },\n ],\n\n print(\n data: unknown,\n variantId: string = \"two-column\",\n rawSettings?: PrintSettings,\n ): PrintOutcome {\n const title = readTitle(data, \"Cheat Sheet\");\n const sections = extractCheatSheetSections(data);\n\n if (sections.length === 0) {\n return printEmptyDocument(\n \"No cheat sheet data available to print.\",\n title,\n CHEAT_SHEET_STYLES,\n \"cheat-sheet\",\n );\n }\n\n const variant: CheatSheetVariant =\n variantId === \"three-column\" || variantId === \"one-column\"\n ? variantId\n : \"two-column\";\n\n const subtitle = readString(data, \"subtitle\");\n const body = renderCheatSheet(\n { title, subtitle, sections },\n variant,\n {\n showSectionRules: boolSetting(rawSettings, \"showSectionRules\", true),\n compact: boolSetting(rawSettings, \"compact\", false),\n duplexHint: boolSetting(rawSettings, \"duplexHint\", false),\n },\n );\n\n return openPrintWindow(\n buildPrintDocument(body, title, CHEAT_SHEET_STYLES),\n \"cheat-sheet\",\n );\n },\n};\n\n// ---------------------------------------------------------------------------\n// 2. Glossary / vocabulary list — Kaplan / Quizlet Glossary\n// ---------------------------------------------------------------------------\n\nconst GLOSSARY_STYLES = `\n ${EDU_BASE_STYLES}\n\n table.gl-table {\n width: 100%;\n border-collapse: collapse;\n margin-top: 4px;\n font-size: 9.5pt;\n }\n table.gl-table thead th {\n background: #1e293b;\n color: #f8fafc;\n text-align: left;\n font-size: 8pt;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n padding: 5px 9px;\n border: none;\n }\n table.gl-table th.gl-th-check { width: 22px; }\n table.gl-table th.gl-th-term { width: 32%; }\n\n table.gl-table tbody tr.gl-row {\n break-inside: avoid;\n page-break-inside: avoid;\n }\n table.gl-table tbody td {\n padding: 4px 9px;\n border-bottom: 1px solid #e2e8f0;\n vertical-align: top;\n line-height: 1.4;\n background: transparent;\n }\n table.gl-table tbody tr:nth-child(even) td { background: #f8fafc; }\n td.gl-check-cell { width: 22px; text-align: center; }\n td.gl-term-cell { font-weight: 700; color: #0f172a; }\n .gl-index {\n display: inline-block;\n min-width: 20px;\n font-size: 8pt;\n font-weight: 400;\n color: #94a3b8;\n }\n\n ul.gl-list {\n column-count: 2;\n column-gap: 18px;\n list-style: none;\n margin: 4px 0 0 0;\n padding: 0;\n font-size: 8.5pt;\n }\n ul.gl-list li.gl-entry {\n break-inside: avoid;\n page-break-inside: avoid;\n margin-bottom: 5px;\n padding-left: 4px;\n border-left: 2px solid #e2e8f0;\n line-height: 1.35;\n }\n .gl-term { font-weight: 700; color: #0f172a; }\n .gl-def { color: #334155; }\n\n @media print {\n table.gl-table thead th {\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n table.gl-table tbody tr:nth-child(even) td {\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n }\n`;\n\n/**\n * Alphabetize by term, case- and accent-insensitively. `Array.sort` is stable\n * (ES2019+), so equal terms keep their authored order — and with the setting\n * OFF the authored order is returned untouched.\n */\nfunction orderGlossaryEntries(\n entries: GlossaryEntry[],\n alphabetize: boolean,\n): GlossaryEntry[] {\n if (!alphabetize) return entries;\n return [...entries].sort((a, b) =>\n a.term.localeCompare(b.term, undefined, { sensitivity: \"base\" }),\n );\n}\n\nfunction renderGlossaryTable(\n entries: GlossaryEntry[],\n opts: { masteredCheckbox: boolean; showIndex: boolean; startIndex: number },\n): string {\n const rows = entries\n .map((entry, i) => {\n const check = opts.masteredCheckbox\n ? `<td class=\"gl-check-cell\">${CHECKBOX_HTML}</td>`\n : \"\";\n const index = opts.showIndex\n ? `<span class=\"gl-index\">${opts.startIndex + i + 1}.</span>`\n : \"\";\n return ` <tr class=\"gl-row\">\n${check ? ` ${check}\\n` : \"\"} <td class=\"gl-term-cell\">${index}${escapeHtml(entry.term)}</td>\n <td class=\"gl-def\">${escapeHtml(entry.definition)}</td>\n </tr>`;\n })\n .join(\"\\n\");\n\n const checkHead = opts.masteredCheckbox\n ? `<th class=\"gl-th-check\">✓</th>`\n : \"\";\n\n return `<table class=\"gl-table\">\n <thead>\n <tr>\n ${checkHead}<th class=\"gl-th-term\">Term</th>\n <th>Definition</th>\n </tr>\n </thead>\n <tbody>\n${rows}\n </tbody>\n</table>`;\n}\n\nfunction renderGlossaryList(\n entries: GlossaryEntry[],\n opts: { masteredCheckbox: boolean; showIndex: boolean; startIndex: number },\n): string {\n const items = entries\n .map((entry, i) => {\n const check = opts.masteredCheckbox ? CHECKBOX_HTML : \"\";\n const index = opts.showIndex\n ? `<span class=\"gl-index\">${opts.startIndex + i + 1}.</span>`\n : \"\";\n return ` <li class=\"gl-entry\">${check}${index}<span class=\"gl-term\">${escapeHtml(entry.term)}</span> — <span class=\"gl-def\">${escapeHtml(entry.definition)}</span></li>`;\n })\n .join(\"\\n\");\n\n return `<ul class=\"gl-list\">\n${items}\n</ul>`;\n}\n\nexport const glossaryPrinter: BlockPrinter = {\n label: \"Print glossary\",\n\n variants: [\n {\n id: \"table\",\n label: \"Term / definition table (recommended)\",\n description:\n \"Two-column table with a mastered checkbox per row — the Kaplan/Quizlet vocabulary sheet\",\n },\n {\n id: \"compact-list\",\n label: \"Compact list\",\n description:\n \"Two columns of one-line entries — many more terms per sheet, for a final review pass\",\n },\n ],\n\n settings: [\n {\n type: \"range\" as const,\n id: \"entryRange\",\n label: \"Entries to print\",\n description: \"Leave blank to print the whole list\",\n fromId: \"fromEntry\",\n toId: \"toEntry\",\n defaultFrom: 0,\n defaultTo: 0,\n min: 1,\n fromPlaceholder: \"1\",\n toPlaceholder: \"end\",\n },\n {\n type: \"boolean\" as const,\n id: \"alphabetize\",\n label: \"Alphabetize\",\n description:\n \"Sorts by term. Off prints the entries in the order they were authored\",\n defaultValue: false,\n },\n {\n type: \"boolean\" as const,\n id: \"masteredCheckbox\",\n label: \"Mastered checkbox\",\n description:\n \"An empty box on every row to tick off as each term is learned\",\n defaultValue: true,\n },\n {\n type: \"boolean\" as const,\n id: \"showIndex\",\n label: \"Number the entries\",\n description:\n \"Prints 1., 2., 3. … so a study partner can call out terms by number\",\n defaultValue: false,\n },\n ],\n\n print(\n data: unknown,\n variantId: string = \"table\",\n rawSettings?: PrintSettings,\n ): PrintOutcome {\n const title = readTitle(data, \"Glossary\");\n const allEntries = extractGlossaryEntries(data);\n\n if (allEntries.length === 0) {\n return printEmptyDocument(\n \"No glossary data available to print.\",\n title,\n GLOSSARY_STYLES,\n \"glossary\",\n );\n }\n\n // Order first, THEN slice: the range names positions on the printed page,\n // so \"entries 1–20\" alphabetized means the first twenty alphabetically.\n const ordered = orderGlossaryEntries(\n allEntries,\n boolSetting(rawSettings, \"alphabetize\", false),\n );\n const { startIndex, endIndex } = resolveRange(\n rawSettings,\n \"fromEntry\",\n \"toEntry\",\n ordered.length,\n );\n const entries = ordered.slice(startIndex, endIndex);\n\n const opts = {\n masteredCheckbox: boolSetting(rawSettings, \"masteredCheckbox\", true),\n showIndex: boolSetting(rawSettings, \"showIndex\", false),\n startIndex,\n };\n\n const variant: GlossaryVariant =\n variantId === \"compact-list\" ? \"compact-list\" : \"table\";\n const body =\n variant === \"compact-list\"\n ? renderGlossaryList(entries, opts)\n : renderGlossaryTable(entries, opts);\n\n const header = `<div class=\"edu-header\">\n <h1 class=\"edu-title\">${escapeHtml(title)}</h1>\n <div class=\"edu-meta\">${entries.length} term${entries.length !== 1 ? \"s\" : \"\"}${entries.length !== allEntries.length ? ` of ${allEntries.length}` : \"\"}</div>\n</div>`;\n\n return openPrintWindow(\n buildPrintDocument(`${header}\\n${body}`, title, GLOSSARY_STYLES),\n \"glossary\",\n );\n },\n};\n\n// ---------------------------------------------------------------------------\n// 3. Study calendar / schedule — the 7Sage week-by-week plan\n// ---------------------------------------------------------------------------\n\nconst STUDY_CALENDAR_STYLES = `\n ${EDU_BASE_STYLES}\n\n /* A week is the unit that must never split across pages when it fits. */\n .sc-week {\n break-inside: avoid;\n page-break-inside: avoid;\n margin-bottom: 12px;\n border: 1px solid #cbd5e1;\n border-radius: 5px;\n overflow: hidden;\n }\n .sc-week-label {\n background: #1e293b;\n color: #f8fafc;\n font-size: 9pt !important;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n padding: 4px 9px;\n margin: 0 !important;\n border-bottom: none !important;\n }\n\n .sc-days-rows .sc-day {\n display: block;\n border-top: 1px solid #e2e8f0;\n padding: 5px 9px;\n }\n .sc-days-rows .sc-day:first-child { border-top: none; }\n\n .sc-days-grid {\n display: grid;\n grid-auto-flow: column;\n grid-auto-columns: 1fr;\n }\n .sc-days-grid .sc-day {\n border-left: 1px solid #e2e8f0;\n padding: 5px 7px;\n min-height: 1.1in;\n }\n .sc-days-grid .sc-day:first-child { border-left: none; }\n\n .sc-day-label {\n font-size: 8pt;\n font-weight: 700;\n color: #0f172a;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n margin-bottom: 3px;\n }\n\n ul.sc-tasks {\n list-style: none;\n margin: 0;\n padding: 0;\n font-size: 8.5pt;\n }\n li.sc-task {\n display: flex;\n align-items: flex-start;\n line-height: 1.35;\n margin-bottom: 2px;\n break-inside: avoid;\n page-break-inside: avoid;\n color: #334155;\n }\n\n /* Milestones are the thing a student actually plans around — they are\n visually LOUD and survive a black-and-white printer. */\n .sc-milestone {\n margin-top: 4px;\n border: 1.5px solid #b45309;\n border-left-width: 5px;\n background: #fef3c7;\n color: #78350f;\n font-size: 8pt;\n font-weight: 700;\n padding: 3px 7px;\n border-radius: 3px;\n line-height: 1.3;\n }\n .sc-milestone-flag {\n letter-spacing: 0.08em;\n text-transform: uppercase;\n margin-right: 5px;\n font-size: 7pt;\n }\n\n .sc-empty-day { font-size: 8pt; color: #94a3b8; font-style: italic; }\n\n @media print {\n .sc-week-label,\n .sc-milestone {\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n }\n`;\n\n/** Landscape geometry lives with the variant, not the shared sheet. */\nconst STUDY_CALENDAR_LANDSCAPE_STYLES = `\n ${STUDY_CALENDAR_STYLES}\n\n @media print {\n @page { size: landscape; margin: 10mm; }\n }\n`;\n\nfunction renderStudyDay(\n day: StudyDay,\n opts: { taskCheckbox: boolean; showMilestones: boolean },\n): string {\n const label = day.label.trim();\n const milestone = (day.milestone ?? \"\").trim();\n\n const tasks =\n day.tasks.length > 0\n ? `<ul class=\"sc-tasks\">\n${day.tasks\n .map(\n (task) =>\n ` <li class=\"sc-task\">${opts.taskCheckbox ? CHECKBOX_HTML : \"\"}<span class=\"sc-task-text\">${escapeHtml(task)}</span></li>`,\n )\n .join(\"\\n\")}\n </ul>`\n : `<div class=\"sc-empty-day\">Rest / catch-up</div>`;\n\n const milestoneHtml =\n opts.showMilestones && milestone\n ? `\\n <div class=\"sc-milestone\"><span class=\"sc-milestone-flag\">Milestone</span>${escapeHtml(milestone)}</div>`\n : \"\";\n\n return ` <div class=\"sc-day\">\n ${label ? `<div class=\"sc-day-label\">${escapeHtml(label)}</div>` : \"\"}\n ${tasks}${milestoneHtml}\n </div>`;\n}\n\nfunction renderStudyCalendar(\n data: StudyCalendarData,\n variant: StudyCalendarVariant,\n opts: { taskCheckbox: boolean; showMilestones: boolean },\n): string {\n const daysClass =\n variant === \"landscape-grid\" ? \"sc-days sc-days-grid\" : \"sc-days sc-days-rows\";\n const subtitle = (data.subtitle ?? \"\").trim();\n const taskCount = data.weeks.reduce(\n (total, week) =>\n total + week.days.reduce((sum, day) => sum + day.tasks.length, 0),\n 0,\n );\n\n const weeks = data.weeks\n .map((week) => {\n const label = week.label.trim();\n return `<section class=\"sc-week\">\n ${label ? `<h2 class=\"sc-week-label\">${escapeHtml(label)}</h2>` : \"\"}\n <div class=\"${daysClass}\">\n${week.days.map((day) => renderStudyDay(day, opts)).join(\"\\n\")}\n </div>\n</section>`;\n })\n .join(\"\\n\");\n\n const hint =\n variant === \"landscape-grid\"\n ? `<div class=\"edu-hint\">Set your printer to <strong>Landscape</strong> — each week prints as a row of day columns.</div>`\n : \"\";\n\n return `<div class=\"edu-header\">\n <h1 class=\"edu-title\">${escapeHtml(data.title)}</h1>\n ${subtitle ? `<p class=\"edu-subtitle\">${escapeHtml(subtitle)}</p>` : \"\"}\n <div class=\"edu-meta\">${data.weeks.length} week${data.weeks.length !== 1 ? \"s\" : \"\"} &nbsp;·&nbsp; ${taskCount} task${taskCount !== 1 ? \"s\" : \"\"}</div>\n</div>\n${hint}\n<div class=\"sc-plan\">\n${weeks}\n</div>`;\n}\n\nexport const studyCalendarPrinter: BlockPrinter = {\n label: \"Print study calendar\",\n\n variants: [\n {\n id: \"week-rows\",\n label: \"Week per block (portrait, recommended)\",\n description:\n \"Each week is one block with its days stacked as rows — the most room for tasks, and a week never splits across pages when it fits\",\n },\n {\n id: \"landscape-grid\",\n label: \"Calendar grid (landscape)\",\n description:\n \"Each week is a row of day columns, calendar-style — best for a wall or a binder pocket\",\n },\n ],\n\n settings: [\n {\n type: \"range\" as const,\n id: \"weekRange\",\n label: \"Weeks to print\",\n description: \"Leave blank to print the whole plan\",\n fromId: \"fromWeek\",\n toId: \"toWeek\",\n defaultFrom: 0,\n defaultTo: 0,\n min: 1,\n fromPlaceholder: \"1\",\n toPlaceholder: \"end\",\n },\n {\n type: \"boolean\" as const,\n id: \"taskCheckbox\",\n label: \"Checkbox per task\",\n description: \"An empty box in front of every task to tick off as it is done\",\n defaultValue: true,\n },\n {\n type: \"boolean\" as const,\n id: \"showMilestones\",\n label: \"Show milestones\",\n description:\n \"Prints each milestone as a bordered banner on its day — practice tests, deadlines, review checkpoints\",\n defaultValue: true,\n },\n ],\n\n print(\n data: unknown,\n variantId: string = \"week-rows\",\n rawSettings?: PrintSettings,\n ): PrintOutcome {\n const title = readTitle(data, \"Study Calendar\");\n const allWeeks = extractStudyWeeks(data);\n const variant: StudyCalendarVariant =\n variantId === \"landscape-grid\" ? \"landscape-grid\" : \"week-rows\";\n const styles =\n variant === \"landscape-grid\"\n ? STUDY_CALENDAR_LANDSCAPE_STYLES\n : STUDY_CALENDAR_STYLES;\n\n if (allWeeks.length === 0) {\n return printEmptyDocument(\n \"No study calendar data available to print.\",\n title,\n styles,\n \"study-calendar\",\n );\n }\n\n const { startIndex, endIndex } = resolveRange(\n rawSettings,\n \"fromWeek\",\n \"toWeek\",\n allWeeks.length,\n );\n const weeks = allWeeks.slice(startIndex, endIndex);\n\n const subtitle = readString(data, \"subtitle\");\n const body = renderStudyCalendar({ title, subtitle, weeks }, variant, {\n taskCheckbox: boolSetting(rawSettings, \"taskCheckbox\", true),\n showMilestones: boolSetting(rawSettings, \"showMilestones\", true),\n });\n\n return openPrintWindow(\n buildPrintDocument(body, title, styles),\n \"study-calendar\",\n );\n },\n};\n","/**\n * @ai-matrx/print/core — the Block Print contract & print-window composition.\n *\n * Ported VERBATIM from matrx-frontend `lib/block-print/block-print-utils.ts`\n * (2026-08-29 state, including the `number` and `select` PrintSetting types the\n * commerce label lane added). Zero dependencies; touches `window` only at call\n * time, so the module stays import-inert and RSC-safe.\n *\n * BlockPrinter is the only interface a host system needs to know about.\n * Each printable unit exports its own BlockPrinter alongside its render\n * component; host code only ever calls print(data, variantId, settings).\n *\n * `buildFitTextScript` is the ONE auto-shrink script (the flashcards\n * FIT_TEXT pattern, parameterized by selector so the label lane shares it).\n */\n\n// THE ONE HTML escaper (duplication census row 14). Nine copies of\n// `escapeHtml` were live across the fleet and THEY DID NOT ESCAPE THE SAME\n// CHARACTERS — a string safe through one was an attribute break-out through\n// another. It moved to `@ai-matrx/kit/html-escape` with a decided character\n// set: an HTML escaper must not require this package's jspdf + html2canvas\n// graph. Not re-exported from here; import `@ai-matrx/kit/html-escape`.\nimport { escapeHtml } from \"@ai-matrx/kit/html-escape\";\n\n// ─── Contract ────────────────────────────────────────────────────────────────\n\nexport interface PrintVariant {\n id: string;\n label: string;\n description?: string;\n}\n\n/**\n * A single user-configurable setting shown in the print dialog.\n * Extend the discriminated union for new control types\n * without breaking existing printers.\n */\nexport type PrintSetting =\n | {\n type: \"boolean\";\n id: string;\n label: string;\n description?: string;\n /** Default value shown when the dialog first opens */\n defaultValue: boolean;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n }\n | {\n /**\n * A numeric \"from → to\" range control, rendered as two small number inputs.\n * Writes two entries into the resolved PrintSettings map — one under fromId,\n * one under toId. Printers interpret 0 (or missing) as \"no bound\" by convention.\n */\n type: \"range\";\n /** Stable id used as React key — not written to the settings map */\n id: string;\n label: string;\n description?: string;\n /** Settings map key for the lower bound value */\n fromId: string;\n /** Settings map key for the upper bound value */\n toId: string;\n defaultFrom: number;\n defaultTo: number;\n /** Minimum accepted value for each input (inclusive). Default 0. */\n min?: number;\n /** Placeholder shown when the input is empty (0). Default \"start\" / \"end\". */\n fromPlaceholder?: string;\n toPlaceholder?: string;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n }\n | {\n /** A single numeric input (e.g. \"start at label #\", \"copies\"). */\n type: \"number\";\n id: string;\n label: string;\n description?: string;\n defaultValue: number;\n /** Minimum accepted value (inclusive). Default 0. */\n min?: number;\n /** Maximum accepted value (inclusive). Omit = unbounded. */\n max?: number;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n }\n | {\n /** A one-of-N choice rendered as a compact segmented row. */\n type: \"select\";\n id: string;\n label: string;\n description?: string;\n options: { value: string; label: string }[];\n defaultValue: string;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n };\n\n/** A resolved map of setting id → current value, passed to print(). */\nexport type PrintSettings = Record<string, boolean | string | number>;\n\n/**\n * The self-contained print capability that each block can export.\n * Host code only ever calls print(data, variantId, settings) — it never inspects internals.\n */\nexport interface BlockPrinter {\n /** Tooltip / aria-label for the print button in the block header */\n label: string;\n\n /**\n * Variants offered to the user before printing.\n * Empty array = no options dialog shown; prints immediately with defaults.\n */\n variants: PrintVariant[];\n\n /**\n * Optional user-configurable settings shown below the variant picker.\n * Omit entirely if the block has no configurable settings.\n */\n settings?: PrintSetting[];\n\n /**\n * Execute the print.\n * data — the block's structured data (serverData or parsed content)\n * variantId — which variant was chosen (undefined = default / no variants)\n * settings — resolved setting values chosen by the user\n *\n * MAY resolve to a PrintOutcome (\"opened\" | \"downloaded\") so the host can\n * tell the user when the popup-blocked download fallback fired; `void`\n * stays legal for printers that don't go through a print window.\n */\n print: (\n data: unknown,\n variantId?: string,\n settings?: PrintSettings,\n ) => void | PrintOutcome | Promise<void | PrintOutcome>;\n}\n\n// ─── Shared Utilities ─────────────────────────────────────────────────────────\n\nconst PRINT_STYLES = `\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n body {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;\n font-size: 11pt;\n line-height: 1.6;\n color: #1a1a1a;\n background: #fff;\n max-width: 860px;\n margin: 0 auto;\n padding: 28px 36px;\n }\n\n .print-actions {\n display: flex;\n gap: 10px;\n margin-bottom: 24px;\n padding-bottom: 18px;\n border-bottom: 2px solid #e5e7eb;\n }\n\n .print-btn {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 8px 20px;\n border-radius: 8px;\n font-size: 13px;\n font-family: inherit;\n cursor: pointer;\n border: none;\n font-weight: 600;\n }\n\n .print-btn-primary { background: #2563eb; color: #fff; }\n .print-btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }\n\n h1 { font-size: 22pt; font-weight: 700; margin-bottom: 0.5em; }\n h2 { font-size: 16pt; font-weight: 700; margin: 1.2em 0 0.4em; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.2em; }\n h3 { font-size: 13pt; font-weight: 600; margin: 1em 0 0.3em; }\n p { margin-bottom: 0.7em; }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 1em 0;\n font-size: 10pt;\n }\n th {\n background: #1e293b;\n color: #f8fafc;\n font-weight: 600;\n text-align: left;\n padding: 7px 12px;\n border: 1px solid #334155;\n }\n td {\n padding: 6px 12px;\n border: 1px solid #e2e8f0;\n vertical-align: top;\n }\n tr:nth-child(even) td { background: #f8fafc; }\n\n @media print {\n body { max-width: 100%; padding: 0; }\n .print-actions { display: none !important; }\n th { -webkit-print-color-adjust: exact; print-color-adjust: exact; }\n }\n`;\n\n/**\n * Wrap body HTML in a complete, styled print document.\n */\nexport function buildPrintDocument(\n bodyHtml: string,\n title = \"AI Response\",\n extraStyles = \"\",\n): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>${escapeHtml(title)}</title>\n <style>\n${PRINT_STYLES}\n${extraStyles}\n </style>\n</head>\n<body>\n <div class=\"print-actions\">\n <button class=\"print-btn print-btn-primary\" onclick=\"window.print()\">Print / Save as PDF</button>\n <button class=\"print-btn print-btn-secondary\" onclick=\"window.close()\">Close</button>\n </div>\n <div class=\"content\">\n${bodyHtml}\n </div>\n</body>\n</html>`;\n}\n\n/**\n * How a print request actually reached the user.\n *\n * \"opened\" — a print window/tab was opened and the document written into it.\n * \"downloaded\" — the popup was blocked, so the document was downloaded as an\n * .html file instead (the contract's fallback). Hosts SHOULD\n * surface this to the user (e.g. a toast saying the print file\n * was downloaded) — a blocked popup plus a silent download is\n * indistinguishable from \"nothing happened\", which is exactly\n * how QA filed it (2026-08-30, F5/F6/F8).\n */\nexport type PrintOutcome = \"opened\" | \"downloaded\";\n\n/**\n * Open a new browser window and write a complete HTML document into it.\n * Falls back to downloading an .html file if the popup is blocked.\n *\n * Returns which path was taken so callers can tell the user when the\n * fallback fired. `windowFeatures` customizes the popup geometry (the\n * flashcards fixed-geometry variants pass a wider window).\n */\nexport function openPrintWindow(\n htmlDocument: string,\n filename = \"print\",\n windowFeatures = \"width=920,height=720,scrollbars=yes\",\n): PrintOutcome {\n const win = window.open(\"\", \"_blank\", windowFeatures);\n if (!win) {\n // Popup blocked — fall back to file download\n const blob = new Blob([htmlDocument], { type: \"text/html;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = `${filename.replace(/\\s+/g, \"-\").toLowerCase()}.html`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke on a macrotask so the browser has started the blob fetch —\n // a synchronous revoke races the download in some engines.\n setTimeout(() => URL.revokeObjectURL(url), 0);\n return \"downloaded\";\n }\n win.document.open();\n win.document.write(htmlDocument);\n win.document.close();\n return \"opened\";\n}\n\n/**\n * Shorthand: build the document and open it in one call.\n * Returns the same PrintOutcome as openPrintWindow.\n */\nexport function printHtmlContent(\n bodyHtml: string,\n title = \"AI Response\",\n extraStyles = \"\",\n): PrintOutcome {\n const doc = buildPrintDocument(bodyHtml, title, extraStyles);\n return openPrintWindow(doc, title);\n}\n\n/**\n * Build the auto-shrink script that runs INSIDE a printed window: for every\n * element matching each selector, its font-size is reduced (0.5pt steps, 6pt\n * floor) until it fits its parent element's box. This is the flashcards\n * FIT_TEXT behavior verbatim, parameterized by selector so every lane\n * (flashcards card text, Avery text, label captions) shares the ONE loop.\n *\n * Pass the same selectors the original used to get byte-identical behavior:\n * flashcards passes `[\".card-text .card-text-inner\", \".avery-text-wrap .avery-text\"]`.\n */\nexport function buildFitTextScript(selectors: readonly string[]): string {\n const selectorsJson = JSON.stringify([...selectors]);\n return `\n<script>\n(function() {\n function fitText(el, container) {\n // container is the bounding box we must fit inside\n var maxW = container.clientWidth;\n var maxH = container.clientHeight;\n var size = parseFloat(window.getComputedStyle(el).fontSize);\n var minSize = 6;\n // Give the element a chance to reflow at current size before measuring\n el.style.fontSize = size + \"pt\";\n while (size > minSize && (el.scrollWidth > maxW + 1 || el.scrollHeight > maxH + 1)) {\n size -= 0.5;\n el.style.fontSize = size + \"pt\";\n }\n }\n function runAll() {\n var selectors = ${selectorsJson};\n for (var s = 0; s < selectors.length; s++) {\n var els = document.querySelectorAll(selectors[s]);\n for (var i = 0; i < els.length; i++) {\n fitText(els[i], els[i].parentElement);\n }\n }\n }\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", runAll);\n } else {\n // document.write pages may already be loaded\n setTimeout(runAll, 0);\n }\n})();\n</script>`;\n}\n\n"],"mappings":"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","names":["import_html_escape"]}
package/dist/education.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/core.ts
2
+ import { escapeHtml } from "@ai-matrx/kit/html-escape";
2
3
  var PRINT_STYLES = `
3
4
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
4
5
 
@@ -69,9 +70,6 @@ var PRINT_STYLES = `
69
70
  th { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
70
71
  }
71
72
  `;
72
- function escapeHtml(str) {
73
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
74
- }
75
73
  function buildPrintDocument(bodyHtml, title = "AI Response", extraStyles = "") {
76
74
  return `<!DOCTYPE html>
77
75
  <html lang="en">
@@ -116,6 +114,7 @@ function openPrintWindow(htmlDocument, filename = "print", windowFeatures = "wid
116
114
  }
117
115
 
118
116
  // src/education.ts
117
+ import { escapeHtml as escapeHtml2 } from "@ai-matrx/kit/html-escape";
119
118
  function isRecord(value) {
120
119
  return typeof value === "object" && value !== null && !Array.isArray(value);
121
120
  }
@@ -194,7 +193,7 @@ function resolveRange(raw, fromKey, toKey, total) {
194
193
  }
195
194
  function printEmptyDocument(message, title, styles, filename) {
196
195
  return openPrintWindow(
197
- buildPrintDocument(`<p>${escapeHtml(message)}</p>`, title, styles),
196
+ buildPrintDocument(`<p>${escapeHtml2(message)}</p>`, title, styles),
198
197
  filename
199
198
  );
200
199
  }
@@ -360,16 +359,16 @@ function renderCheatSheetItem(item) {
360
359
  const definition = (item.definition ?? "").trim();
361
360
  const formula = (item.formula ?? "").trim();
362
361
  const note = (item.note ?? "").trim();
363
- if (term) parts.push(`<span class="cs-term">${escapeHtml(term)}</span>`);
362
+ if (term) parts.push(`<span class="cs-term">${escapeHtml2(term)}</span>`);
364
363
  if (definition) {
365
364
  parts.push(
366
- `${term ? " " : ""}<span class="cs-def">${escapeHtml(definition)}</span>`
365
+ `${term ? " " : ""}<span class="cs-def">${escapeHtml2(definition)}</span>`
367
366
  );
368
367
  }
369
368
  if (formula) {
370
- parts.push(`<code class="cs-formula">${escapeHtml(formula)}</code>`);
369
+ parts.push(`<code class="cs-formula">${escapeHtml2(formula)}</code>`);
371
370
  }
372
- if (note) parts.push(`<div class="cs-note">${escapeHtml(note)}</div>`);
371
+ if (note) parts.push(`<div class="cs-note">${escapeHtml2(note)}</div>`);
373
372
  return `<div class="cs-item">${parts.join("")}</div>`;
374
373
  }
375
374
  function renderCheatSheet(data, variant, opts) {
@@ -380,7 +379,7 @@ function renderCheatSheet(data, variant, opts) {
380
379
  const subtitle = (data.subtitle ?? "").trim();
381
380
  const sections = data.sections.map((section) => {
382
381
  const heading = section.heading.trim();
383
- const bar = heading ? `<h2 class="cs-section-bar">${escapeHtml(heading)}</h2>` : "";
382
+ const bar = heading ? `<h2 class="cs-section-bar">${escapeHtml2(heading)}</h2>` : "";
384
383
  const rule = opts.showSectionRules ? `<div class="cs-section-rule"></div>` : "";
385
384
  const items = section.items.map(renderCheatSheetItem).join("\n");
386
385
  return `<section class="cs-section">
@@ -392,8 +391,8 @@ ${items}
392
391
  const columnClass = CHEAT_SHEET_COLUMN_CLASS[variant];
393
392
  const compactClass = opts.compact ? " cs-compact" : "";
394
393
  return `<div class="edu-header">
395
- <h1 class="edu-title">${escapeHtml(data.title)}</h1>
396
- ${subtitle ? `<p class="edu-subtitle">${escapeHtml(subtitle)}</p>` : ""}
394
+ <h1 class="edu-title">${escapeHtml2(data.title)}</h1>
395
+ ${subtitle ? `<p class="edu-subtitle">${escapeHtml2(subtitle)}</p>` : ""}
397
396
  <div class="edu-meta">${data.sections.length} section${data.sections.length !== 1 ? "s" : ""} &nbsp;\xB7&nbsp; ${itemCount} item${itemCount !== 1 ? "s" : ""}</div>
398
397
  </div>
399
398
  ${hint}
@@ -558,8 +557,8 @@ function renderGlossaryTable(entries, opts) {
558
557
  const index = opts.showIndex ? `<span class="gl-index">${opts.startIndex + i + 1}.</span>` : "";
559
558
  return ` <tr class="gl-row">
560
559
  ${check ? ` ${check}
561
- ` : ""} <td class="gl-term-cell">${index}${escapeHtml(entry.term)}</td>
562
- <td class="gl-def">${escapeHtml(entry.definition)}</td>
560
+ ` : ""} <td class="gl-term-cell">${index}${escapeHtml2(entry.term)}</td>
561
+ <td class="gl-def">${escapeHtml2(entry.definition)}</td>
563
562
  </tr>`;
564
563
  }).join("\n");
565
564
  const checkHead = opts.masteredCheckbox ? `<th class="gl-th-check">\u2713</th>` : "";
@@ -579,7 +578,7 @@ function renderGlossaryList(entries, opts) {
579
578
  const items = entries.map((entry, i) => {
580
579
  const check = opts.masteredCheckbox ? CHECKBOX_HTML : "";
581
580
  const index = opts.showIndex ? `<span class="gl-index">${opts.startIndex + i + 1}.</span>` : "";
582
- return ` <li class="gl-entry">${check}${index}<span class="gl-term">${escapeHtml(entry.term)}</span> \u2014 <span class="gl-def">${escapeHtml(entry.definition)}</span></li>`;
581
+ return ` <li class="gl-entry">${check}${index}<span class="gl-term">${escapeHtml2(entry.term)}</span> \u2014 <span class="gl-def">${escapeHtml2(entry.definition)}</span></li>`;
583
582
  }).join("\n");
584
583
  return `<ul class="gl-list">
585
584
  ${items}
@@ -665,7 +664,7 @@ var glossaryPrinter = {
665
664
  const variant = variantId === "compact-list" ? "compact-list" : "table";
666
665
  const body = variant === "compact-list" ? renderGlossaryList(entries, opts) : renderGlossaryTable(entries, opts);
667
666
  const header = `<div class="edu-header">
668
- <h1 class="edu-title">${escapeHtml(title)}</h1>
667
+ <h1 class="edu-title">${escapeHtml2(title)}</h1>
669
668
  <div class="edu-meta">${entries.length} term${entries.length !== 1 ? "s" : ""}${entries.length !== allEntries.length ? ` of ${allEntries.length}` : ""}</div>
670
669
  </div>`;
671
670
  return openPrintWindow(
@@ -786,13 +785,13 @@ function renderStudyDay(day, opts) {
786
785
  const milestone = (day.milestone ?? "").trim();
787
786
  const tasks = day.tasks.length > 0 ? `<ul class="sc-tasks">
788
787
  ${day.tasks.map(
789
- (task) => ` <li class="sc-task">${opts.taskCheckbox ? CHECKBOX_HTML : ""}<span class="sc-task-text">${escapeHtml(task)}</span></li>`
788
+ (task) => ` <li class="sc-task">${opts.taskCheckbox ? CHECKBOX_HTML : ""}<span class="sc-task-text">${escapeHtml2(task)}</span></li>`
790
789
  ).join("\n")}
791
790
  </ul>` : `<div class="sc-empty-day">Rest / catch-up</div>`;
792
791
  const milestoneHtml = opts.showMilestones && milestone ? `
793
- <div class="sc-milestone"><span class="sc-milestone-flag">Milestone</span>${escapeHtml(milestone)}</div>` : "";
792
+ <div class="sc-milestone"><span class="sc-milestone-flag">Milestone</span>${escapeHtml2(milestone)}</div>` : "";
794
793
  return ` <div class="sc-day">
795
- ${label ? `<div class="sc-day-label">${escapeHtml(label)}</div>` : ""}
794
+ ${label ? `<div class="sc-day-label">${escapeHtml2(label)}</div>` : ""}
796
795
  ${tasks}${milestoneHtml}
797
796
  </div>`;
798
797
  }
@@ -806,7 +805,7 @@ function renderStudyCalendar(data, variant, opts) {
806
805
  const weeks = data.weeks.map((week) => {
807
806
  const label = week.label.trim();
808
807
  return `<section class="sc-week">
809
- ${label ? `<h2 class="sc-week-label">${escapeHtml(label)}</h2>` : ""}
808
+ ${label ? `<h2 class="sc-week-label">${escapeHtml2(label)}</h2>` : ""}
810
809
  <div class="${daysClass}">
811
810
  ${week.days.map((day) => renderStudyDay(day, opts)).join("\n")}
812
811
  </div>
@@ -814,8 +813,8 @@ ${week.days.map((day) => renderStudyDay(day, opts)).join("\n")}
814
813
  }).join("\n");
815
814
  const hint = variant === "landscape-grid" ? `<div class="edu-hint">Set your printer to <strong>Landscape</strong> \u2014 each week prints as a row of day columns.</div>` : "";
816
815
  return `<div class="edu-header">
817
- <h1 class="edu-title">${escapeHtml(data.title)}</h1>
818
- ${subtitle ? `<p class="edu-subtitle">${escapeHtml(subtitle)}</p>` : ""}
816
+ <h1 class="edu-title">${escapeHtml2(data.title)}</h1>
817
+ ${subtitle ? `<p class="edu-subtitle">${escapeHtml2(subtitle)}</p>` : ""}
819
818
  <div class="edu-meta">${data.weeks.length} week${data.weeks.length !== 1 ? "s" : ""} &nbsp;\xB7&nbsp; ${taskCount} task${taskCount !== 1 ? "s" : ""}</div>
820
819
  </div>
821
820
  ${hint}