@litfamily/lithermes 1.0.29 → 1.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +12 -9
  2. package/README_Ko-KR.md +12 -9
  3. package/assets/lithermes-plugin/README.md +15 -2
  4. package/assets/lithermes-plugin/__init__.py +49 -10
  5. package/assets/lithermes-plugin/adhd_stack.py +228 -0
  6. package/assets/lithermes-plugin/core.py +30 -4
  7. package/assets/lithermes-plugin/core_commands.py +99 -0
  8. package/assets/lithermes-plugin/core_contexts.py +54 -4
  9. package/assets/lithermes-plugin/core_contract.py +2 -2
  10. package/assets/lithermes-plugin/core_routing.py +126 -0
  11. package/assets/lithermes-plugin/diagnostics.py +3 -2
  12. package/assets/lithermes-plugin/lit_mark.py +75 -0
  13. package/assets/lithermes-plugin/payload-version.json +470 -58
  14. package/assets/lithermes-plugin/plugin.yaml +1 -1
  15. package/assets/lithermes-plugin/scientific_visualization.py +23 -0
  16. package/assets/lithermes-plugin/skills/frontend-ui-ux/SKILL.md +7 -7
  17. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/components.css +414 -0
  18. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/fonts.json +42 -0
  19. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  20. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  21. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  22. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  23. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  24. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  25. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  26. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  27. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
  28. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/README.md +2 -4
  29. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +4 -4
  30. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/craft-floor.md +10 -0
  31. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  32. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe-rules.json +6 -1
  33. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +76 -4
  34. package/assets/lithermes-plugin/skills/lit-adhd/SKILL.md +194 -0
  35. package/assets/lithermes-plugin/skills/lit-adhd/references/brief-contract.md +55 -0
  36. package/assets/lithermes-plugin/skills/lit-adhd/references/card-examples.md +86 -0
  37. package/assets/lithermes-plugin/skills/lit-adhd/references/status-taxonomy.md +40 -0
  38. package/assets/lithermes-plugin/skills/lit-adhd/scripts/adhd.mjs +11 -0
  39. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/collect.mjs +554 -0
  40. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/gauge.mjs +89 -0
  41. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/hermes-sessions.mjs +219 -0
  42. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/next-step.mjs +101 -0
  43. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/plan-task-rows.mjs +32 -0
  44. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/redact.mjs +42 -0
  45. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/secret-shapes.mjs +59 -0
  46. package/assets/lithermes-plugin/skills/lit-diagram-drawer/SKILL.md +7 -4
  47. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/style-guide.md +20 -11
  48. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
  49. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
  50. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-tree.md +3 -3
  51. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
  52. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/verifier-guide.md +5 -0
  53. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/diagram_quality.py +4 -4
  54. package/assets/lithermes-plugin/skills/lit-docx/SKILL.md +1 -1
  55. package/assets/lithermes-plugin/skills/lit-hijack/SKILL.md +239 -0
  56. package/assets/lithermes-plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
  57. package/assets/lithermes-plugin/skills/lit-hijack/references/clean-room-build.md +128 -0
  58. package/assets/lithermes-plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
  59. package/assets/lithermes-plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
  60. package/assets/lithermes-plugin/skills/lit-hijack/references/parity-and-report.md +131 -0
  61. package/assets/lithermes-plugin/skills/lit-hijack/scripts/hijack.mjs +995 -0
  62. package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +1 -0
  63. package/assets/lithermes-plugin/skills/lit-pptx/SKILL.md +11 -6
  64. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  65. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  66. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  67. package/assets/lithermes-plugin/skills/lit-pptx/fonts/provenance.json +1 -0
  68. package/assets/lithermes-plugin/skills/lit-pptx/references/anti-slop-checklist.md +3 -0
  69. package/assets/lithermes-plugin/skills/lit-pptx/references/authoring-guide.md +1 -1
  70. package/assets/lithermes-plugin/skills/lit-pptx/references/consensus-protocol.md +1 -1
  71. package/assets/lithermes-plugin/skills/lit-pptx/references/direction-step.md +8 -2
  72. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +175 -0
  73. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +156 -0
  74. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +141 -0
  75. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +160 -0
  76. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +183 -0
  77. package/assets/lithermes-plugin/skills/lit-pptx/references/layout-families.md +1 -1
  78. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/bauhaus.md +105 -0
  79. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +111 -0
  80. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +114 -0
  81. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/newsroom.md +106 -0
  82. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/riso.md +101 -0
  83. package/assets/lithermes-plugin/skills/lit-pptx/scripts/craft_extras.py +49 -0
  84. package/assets/lithermes-plugin/skills/lit-pptx/scripts/deck_output.py +4 -2
  85. package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/render-pack.js +96 -10
  86. package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/template-registry.js +50 -6
  87. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +103 -0
  88. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  89. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  90. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  91. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  92. package/assets/lithermes-plugin/skills/lit-recap/SKILL.md +1 -1
  93. package/assets/lithermes-plugin/skills/lit-schematic/SKILL.md +144 -0
  94. package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
  95. package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
  96. package/assets/lithermes-plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
  97. package/assets/lithermes-plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
  98. package/assets/lithermes-plugin/skills/lit-schematic/references/cutaway-outputs.md +70 -0
  99. package/assets/lithermes-plugin/skills/lit-schematic/references/isometric-craft.md +101 -0
  100. package/assets/lithermes-plugin/skills/lit-schematic/references/journal-figures.md +72 -0
  101. package/assets/lithermes-plugin/skills/lit-schematic/references/kit-api.md +134 -0
  102. package/assets/lithermes-plugin/skills/lit-schematic/references/pfd-and-layout.md +53 -0
  103. package/assets/lithermes-plugin/skills/lit-schematic/references/pid-drafting.md +103 -0
  104. package/assets/lithermes-plugin/skills/lit-schematic/references/pid-symbols.md +163 -0
  105. package/assets/lithermes-plugin/skills/lit-schematic/references/verification.md +100 -0
  106. package/assets/lithermes-plugin/skills/lit-schematic/scripts/doctor.mjs +57 -0
  107. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
  108. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/fonts.mjs +27 -0
  109. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
  110. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
  111. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
  112. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
  113. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
  114. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
  115. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/png.mjs +132 -0
  116. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
  117. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/route.mjs +218 -0
  118. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
  119. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
  120. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
  121. package/assets/lithermes-plugin/skills/lit-schematic/scripts/motion.mjs +129 -0
  122. package/assets/lithermes-plugin/skills/lit-schematic/scripts/qa.mjs +92 -0
  123. package/assets/lithermes-plugin/skills/lit-schematic/scripts/render.mjs +233 -0
  124. package/assets/lithermes-plugin/skills/lit-schematic/scripts/scaffold.mjs +60 -0
  125. package/assets/lithermes-plugin/skills/lit-scientific-visualization/SKILL.md +19 -1
  126. package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +12 -11
  127. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +56 -2
  128. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +129 -4
  129. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +9 -0
  130. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
  131. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender.mjs +154 -0
  132. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/board.mjs +132 -0
  133. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/completion.mjs +48 -5
  134. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/gate.mjs +13 -0
  135. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/house.mjs +44 -0
  136. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/scaffold.mjs +238 -0
  137. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/score.mjs +643 -0
  138. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound-dsp.mjs +21 -0
  139. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +171 -0
  140. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage-qa.mjs +23 -3
  141. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +124 -17
  142. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/styles.mjs +240 -0
  143. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +152 -10
  144. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  145. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  146. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  147. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  148. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +67 -0
  149. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
  150. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/depth.md +74 -0
  151. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/studio.md +87 -0
  152. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/style-bibles.md +9 -2
  153. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +24 -8
  154. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
  155. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
  156. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
  157. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
  158. package/assets/lithermes-plugin/skills/litresearch/SKILL.md +4 -0
  159. package/assets/lithermes-plugin/skills/readme-studio/SKILL.md +1 -1
  160. package/assets/lithermes-plugin/skills/review-work/SKILL.md +2 -0
  161. package/assets/lithermes-plugin/skills/start-work/SKILL.md +2 -1
  162. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
  163. package/package.json +1 -1
  164. package/readme-assets/badge-version.svg +1 -1
  165. package/src/lib/skillPayload.js +1 -1
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env node
2
+ // Design lock: the one place a build picks its look. It writes the chosen house style into the
3
+ // project as design.lock.json plus ui/tokens.css, ui/components.css, ui/ui.js and the fonts, and
4
+ // the interface probe then checks the rendered page against that lock (DS-* rules).
5
+ //
6
+ // node design-lock.mjs list
7
+ // node design-lock.mjs init --family <landing|workspace|settings> --dir <project> [--style <id>]
8
+ // [--brief "<request text>"] [--density rich|calm] [--moment <kind> --moment-note "<what it is>"
9
+ // [--moment-place "<where it sits>"]] [--scaffold] [--from <style.json>] [--force]
10
+ // node design-lock.mjs moment --dir <project> --kind <kind> --note "<what it is>" [--place "<where>"]
11
+ // Without --style a style the brief names by keyword applies (styleForBrief), else the family
12
+ // default (DEFAULT_STYLE_BY_FAMILY).
13
+ // node design-lock.mjs match "<request text>"
14
+ // node design-lock.mjs show --dir <project>
15
+ //
16
+ // `--from` takes a brand's own style file in the same schema as kit/styles/*.json; its id must not
17
+ // reuse a kit style id. Nothing here fetches from the network or installs anything.
18
+ import { createHash } from "node:crypto";
19
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
20
+ import { dirname, join, resolve } from "node:path";
21
+ import process from "node:process";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
25
+ const KIT = join(SKILL_ROOT, "kit");
26
+ export const LOCK_FILE = "design.lock.json";
27
+ export const LOCK_SCHEMA = "litfamily.design-lock/v1";
28
+ export const STYLE_SCHEMA = "litfamily.frontend-style/v1";
29
+ export const FAMILIES = Object.freeze({
30
+ landing: "read-once pages: landing, product, launch, portfolio, docs home",
31
+ workspace: "screens people work in: dashboards, consoles, admin, tools, tables",
32
+ settings: "guided forms: settings, account, onboarding, checkout steps",
33
+ });
34
+ // The user-decided defaults (2026-10-25) when a brief gives no visual direction: read-once pages
35
+ // keep the authored field-notes profile; work surfaces use graphite. meadow is never a default.
36
+ export const DEFAULT_STYLE_BY_FAMILY = Object.freeze({ landing: "field-notes", workspace: "graphite", settings: "graphite" });
37
+ // Round 2 (user decision A, 2026-10-25): the lock fixes the look, and every page names the one place
38
+ // it is allowed to be bold. Read-once pages take a bold moment above the fold; work surfaces stay
39
+ // calm and take a small one. The probe checks the declared kind against [data-ui-moment] (DS-008).
40
+ export const MOMENT_SCALE_BY_FAMILY = Object.freeze({ landing: "bold", workspace: "small", settings: "small" });
41
+ export const MOMENT_KINDS = Object.freeze({
42
+ bold: Object.freeze(["hero-composition", "type-set-piece", "data-moment", "diagram", "motion-detail", "interactive-object"]),
43
+ small: Object.freeze(["chart", "empty-state", "header-detail"]),
44
+ });
45
+ export const MOMENT_SELECTOR = "[data-ui-moment]";
46
+ export const COLOR_ROLES = Object.freeze(["canvas", "surface", "sunken", "ink", "ink-2", "ink-3", "line", "line-strong", "control", "accent", "accent-hover", "on-accent", "accent-wash", "accent-ink", "accent-2", "accent-2-ink", "accent-2-wash", "on-accent-2", "support", "support-ink", "focus", "positive", "positive-wash", "warning", "warning-wash", "negative", "negative-wash", "info", "info-wash", "chart-1", "chart-2", "chart-3", "chart-4", "chart-5"]);
47
+ const SCALE_KEYS = ["xs", "sm", "md", "base", "lg", "xl", "2xl", "3xl", "4xl", "5xl"];
48
+ const RADIUS_KEYS = ["xs", "sm", "md", "lg", "pill"];
49
+ const HEX = /^#[0-9a-f]{6}$/u;
50
+ // The shape block carries what makes a register more than a palette: border weights, which role
51
+ // draws outlines, the elevation grammar the probe enforces (DS-007), how a button presses, and the
52
+ // case and family of meta text. Every value is closed so components.css can stay token-only.
53
+ export const SHAPE_CHOICES = Object.freeze({
54
+ surface_line: ["line", "line-strong", "ink"],
55
+ control_edge: ["control", "ink"],
56
+ elevation: ["soft", "offset", "flat"],
57
+ press: ["scale", "shift", "none"],
58
+ meta_font: ["text", "mono"],
59
+ meta_case: ["none", "uppercase"],
60
+ display_case: ["none", "uppercase"],
61
+ canvas_pattern: ["none", "dots", "grid", "plot"],
62
+ heading_font: ["display", "mono"],
63
+ panel_head: ["plain", "inverse", "accent"],
64
+ section_marks: ["none", "coord"],
65
+ });
66
+
67
+ export function kitStyles() {
68
+ return readdirSync(join(KIT, "styles")).filter((name) => name.endsWith(".json")).sort()
69
+ .map((name) => JSON.parse(readFileSync(join(KIT, "styles", name), "utf8")));
70
+ }
71
+
72
+ // Returns a list of problems; an empty list means the style can be locked.
73
+ export function validateStyle(style) {
74
+ const issues = [];
75
+ if (!style || typeof style !== "object") return ["style is not an object"];
76
+ if (style.schema_id !== STYLE_SCHEMA) issues.push(`schema_id must be ${STYLE_SCHEMA}`);
77
+ if (!/^[a-z][a-z0-9-]{1,31}$/u.test(style.id ?? "")) issues.push("id must be a short kebab-case name");
78
+ for (const role of ["text", "display", "mono"]) if (typeof style.fonts?.[role] !== "string" || !style.fonts[role].trim()) issues.push(`fonts.${role} is required`);
79
+ for (const key of SCALE_KEYS) if (!Number.isFinite(style.type?.scale_px?.[key])) issues.push(`type.scale_px.${key} is required`);
80
+ if (!(style.type?.display_px?.min > 0 && style.type.display_px.max >= style.type.display_px.min)) issues.push("type.display_px needs min ≤ max");
81
+ if (!(style.type?.moment_px?.max > style.type?.display_px?.max)) issues.push("type.moment_px.max must sit above type.display_px.max");
82
+ for (const key of ["text", "medium", "strong", "display"]) if (!Number.isFinite(style.type?.weights?.[key])) issues.push(`type.weights.${key} is required`);
83
+ for (const key of RADIUS_KEYS) if (!Number.isFinite(style.radius_px?.[key])) issues.push(`radius_px.${key} is required`);
84
+ if (!Array.isArray(style.space_px) || style.space_px.some((v) => !Number.isInteger(v) || v % 4 !== 0)) issues.push("space_px must be multiples of 4");
85
+ for (const theme of ["light", "dark"]) {
86
+ for (const role of COLOR_ROLES) if (!HEX.test(style.color?.[theme]?.[role] ?? "")) issues.push(`color.${theme}.${role} must be #rrggbb`);
87
+ }
88
+ const shape = style.shape;
89
+ for (const key of ["rule", "surface", "control"]) if (!(Number.isInteger(shape?.border_px?.[key]) && shape.border_px[key] >= 1 && shape.border_px[key] <= 4)) issues.push(`shape.border_px.${key} must be 1 to 4`);
90
+ for (const [key, allowed] of Object.entries(SHAPE_CHOICES)) if (!allowed.includes(shape?.[key])) issues.push(`shape.${key} must be one of ${allowed.join(", ")}`);
91
+ for (const key of ["surface", "control"]) if (!(Number.isInteger(shape?.offset_px?.[key]) && shape.offset_px[key] >= 0)) issues.push(`shape.offset_px.${key} is required`);
92
+ if (shape?.elevation === "offset" && !(shape.offset_px?.surface > 0 && shape.offset_px?.control > 0)) issues.push("an offset elevation needs offset_px above 0");
93
+ if (shape?.elevation !== "offset" && (shape?.offset_px?.surface || shape?.offset_px?.control || shape?.press === "shift")) issues.push("offset_px and press shift belong to an offset elevation");
94
+ for (const key of ["keywords", "signature", "forbid"]) if (!Array.isArray(style[key]) || !style[key].length || style[key].some((item) => typeof item !== "string" || !item.trim())) issues.push(`${key} needs at least one entry`);
95
+ return issues;
96
+ }
97
+
98
+ // The kit style a brief names, or null. A brief that names a register by one of a style's keywords
99
+ // picks it; when two styles match ("neo-brutalism" also contains "brutalism") the longer keyword
100
+ // wins. Latin keywords must start at a word boundary so "crt" does not match inside a word.
101
+ export function styleForBrief(text, styles = kitStyles()) {
102
+ const haystack = String(text ?? "").toLowerCase();
103
+ let best = null;
104
+ for (const style of styles) {
105
+ for (const keyword of style.keywords ?? []) {
106
+ const word = keyword.toLowerCase();
107
+ const escaped = word.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
108
+ const pattern = /^[a-z]/u.test(word) ? new RegExp(`(?<![a-z0-9])${escaped}`, "u") : new RegExp(escaped, "u");
109
+ if (pattern.test(haystack) && (!best || word.length > best.keyword.length)) best = { id: style.id, keyword: word };
110
+ }
111
+ }
112
+ return best;
113
+ }
114
+
115
+ // Density (user rule, 2026-10-25): rich is the default register; calm only when the request asks
116
+ // for a minimal, quiet or restrained page, or --density calm. Calm keeps the broad, flat page the
117
+ // kit shipped before; rich tightens the section rhythm and turns on texture and a glow token.
118
+ export const DENSITIES = Object.freeze(["rich", "calm"]);
119
+ const CALM_WORDS = [/(?<![a-z])minimal(?:ist|ism)?(?![a-z])/u, /(?<![a-z])(?:calm|quiet|restrained|understated|pared[- ]back|sparse)(?![a-z])/u, /미니멀/u, /차분/u, /절제/u, /담백/u, /간결/u];
120
+ export function densityForBrief(text) {
121
+ const haystack = String(text ?? "").toLowerCase();
122
+ return CALM_WORDS.some((pattern) => pattern.test(haystack)) ? "calm" : "rich";
123
+ }
124
+ const checkDensity = (density) => {
125
+ if (!DENSITIES.includes(density)) throw new Error(`--density must be rich or calm, not "${density}"`);
126
+ return density;
127
+ };
128
+
129
+ const fontStack = (family, kind) => {
130
+ const tail = kind === "mono"
131
+ ? `ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace`
132
+ : `"Pretendard Variable", -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Noto Sans KR", "Segoe UI", sans-serif`;
133
+ return `"${family}", ${tail}`;
134
+ };
135
+
136
+ export function tokensCss(style, fontFiles, density = "rich") {
137
+ const calm = checkDensity(density) === "calm";
138
+ const t = style.type;
139
+ const { min, max } = t.display_px;
140
+ const slope = (max - min) / (1440 - 390);
141
+ const intercept = min - slope * 390;
142
+ const colors = (theme, indent = " ") => COLOR_ROLES.map((role) => `${indent}--c-${role}: ${style.color[theme][role]};`).join("\n");
143
+ const shape = style.shape;
144
+ const control = shape.offset_px.control;
145
+ const press = { scale: "scale(0.96)", shift: `translate(${control}px, ${control}px)`, none: "none" }[shape.press];
146
+ const grid = "linear-gradient(color-mix(in srgb, var(--c-line) 70%, transparent) 1px, transparent 1px), linear-gradient(90deg, color-mix(in srgb, var(--c-line) 70%, transparent) 1px, transparent 1px)";
147
+ // The plot grid: minor lines lighter than the grid style, a stronger line every five cells.
148
+ // Both stay on --c-line so body text keeps its contrast over every stop (CF-204) in light and dark.
149
+ const minor = grid.replaceAll("70%", "50%");
150
+ const major = grid.replaceAll("70%", "85%");
151
+ // Notebook dots are texture, so the calm register drops them; grid and plot are a register's structure and stay.
152
+ const dots = "radial-gradient(circle, color-mix(in srgb, var(--c-line-strong) 55%, transparent) 1px, transparent 1.5px)";
153
+ const pattern = { none: ["none", "auto"], dots: calm ? ["none", "auto"] : [dots, "24px 24px"], grid: [grid, "32px 32px"], plot: [`${minor}, ${major}`, "32px 32px, 32px 32px, 160px 160px, 160px 160px"] }[shape.canvas_pattern];
154
+ // A soft accent glow for one atmosphere surface; flat and offset registers keep their own elevation grammar (DS-007).
155
+ const glow = calm || shape.elevation !== "soft" ? "none" : "0 0 48px color-mix(in srgb, var(--c-accent) 30%, transparent), 0 0 96px color-mix(in srgb, var(--c-accent-2) 18%, transparent)";
156
+ const pad = calm ? { section: [64, 96, 128], hero: [48, 64, 80] } : { section: [48, 72, 96], hero: [40, 56, 64] };
157
+ const head = { plain: ["transparent", "inherit", "initial", 0], inverse: ["var(--c-ink)", "var(--c-canvas)", "var(--c-canvas)", 1], accent: ["var(--c-accent)", "var(--c-on-accent)", "var(--c-on-accent)", 1] }[shape.panel_head];
158
+ const moment = t.moment_px.max;
159
+ const momentSlope = (moment - min) / (1440 - 390);
160
+ const faces = fontFiles.map((font) => `@font-face {\n font-family: "${font.family}";\n src: url("fonts/${font.file}") format("${font.file.endsWith(".woff2") ? "woff2" : "truetype"}");\n font-weight: ${font.weight};\n font-style: normal;\n font-display: swap;\n}`).join("\n");
161
+ return `/* Generated by frontend-ui-ux scripts/design-lock.mjs for the "${style.id}" style. Do not edit by hand:
162
+ re-run design-lock.mjs, or change the style file and lock again. */
163
+ ${faces}
164
+ :root {
165
+ --ui-lock: "${style.id}";
166
+ --density: "${calm ? "calm" : "rich"}";
167
+ --font-text: ${fontStack(style.fonts.text, "text")};
168
+ --font-display: ${fontStack(style.fonts.display, "text")};
169
+ --font-mono: ${fontStack(style.fonts.mono, "mono")};
170
+ --font-text-ko: ${style.fonts.text === style.fonts.mono ? fontStack(style.fonts.display, "text") : "var(--font-text)"};
171
+ ${SCALE_KEYS.map((key) => ` --fs-${key}: ${t.scale_px[key]}px;`).join("\n")}
172
+ --fs-display: clamp(${min}px, ${intercept.toFixed(2)}px + ${(slope * 100).toFixed(3)}vw, ${max}px);
173
+ --fs-moment: clamp(${min}px, ${(min - momentSlope * 390).toFixed(2)}px + ${(momentSlope * 100).toFixed(3)}vw, ${moment}px);
174
+ --fw-text: ${t.weights.text};
175
+ --fw-medium: ${t.weights.medium};
176
+ --fw-strong: ${t.weights.strong};
177
+ --fw-display: ${t.weights.display};
178
+ --track-display: ${t.tracking_em.display}em;
179
+ --track-label: ${t.tracking_em.label}em;
180
+ --lh-body: ${t.leading.body};
181
+ --lh-tight: ${t.leading.tight};
182
+ --lh-display: ${t.leading.display_latin};
183
+ --lh-display-ko: ${t.leading.display_korean};
184
+ ${style.space_px.map((value) => ` --sp-${value}: ${value}px;`).join("\n")}
185
+ ${RADIUS_KEYS.map((key) => ` --radius-${key}: ${style.radius_px[key]}px;`).join("\n")}
186
+ --shadow-raised: ${style.shadow.raised};
187
+ --shadow-overlay: ${style.shadow.overlay};
188
+ --shadow-control: ${shape.elevation === "offset" ? `${control}px ${control}px 0 0 var(--c-ink)` : "none"};
189
+ --shadow-control-pressed: ${shape.elevation === "offset" ? "0 0 0 0 var(--c-ink)" : "none"};
190
+ --press: ${press};
191
+ --bw-rule: ${shape.border_px.rule}px;
192
+ --bw-surface: ${shape.border_px.surface}px;
193
+ --bw-control: ${shape.border_px.control}px;
194
+ --c-frame: var(--c-${shape.surface_line});
195
+ --control-edge: ${shape.control_edge === "ink" ? "var(--c-ink)" : "initial"};
196
+ --font-meta: ${shape.meta_font === "mono" ? "var(--font-mono)" : "var(--font-text)"};
197
+ --font-heading: ${shape.heading_font === "mono" ? "var(--font-mono)" : "var(--font-display)"};
198
+ --font-heading-ko: ${shape.heading_font === "mono" ? "var(--font-display)" : "var(--font-heading)"};
199
+ --panel-head-bg: ${head[0]};
200
+ --panel-head-ink: ${head[1]};
201
+ --panel-head-status: ${head[2]};
202
+ --panel-head-bleed: ${head[3]};
203
+ --section-marks: ${shape.section_marks === "coord" ? "block" : "none"};
204
+ --meta-case: ${shape.meta_case};
205
+ --display-case: ${shape.display_case};
206
+ --canvas-pattern: ${pattern[0]};
207
+ --canvas-pattern-size: ${pattern[1]};
208
+ --glow: ${glow};
209
+ --dur-fast: ${style.motion.fast_ms}ms;
210
+ --dur-ui: ${style.motion.ui_ms}ms;
211
+ --dur-enter: ${style.motion.enter_ms}ms;
212
+ --dur-exit: ${style.motion.exit_ms}ms;
213
+ --ease: ${style.motion.ease};
214
+ --gutter: 16px;
215
+ --work-gutter: 16px;
216
+ --section-pad: ${pad.section[0]}px;
217
+ --hero-pad: ${pad.hero[0]}px;
218
+ ${colors("light")}
219
+ }
220
+ @media (min-width: 640px) { :root { --gutter: 24px; --work-gutter: 24px; --section-pad: ${pad.section[1]}px; --hero-pad: ${pad.hero[1]}px; } }
221
+ @media (min-width: 1024px) { :root { --gutter: 40px; --work-gutter: 32px; --section-pad: ${pad.section[2]}px; --hero-pad: ${pad.hero[2]}px; } }
222
+ @media (prefers-color-scheme: dark) {
223
+ :root:not([data-theme="light"]) {
224
+ ${colors("dark", " ")}
225
+ }
226
+ }
227
+ :root[data-theme="dark"] {
228
+ ${colors("dark")}
229
+ }
230
+ `;
231
+ }
232
+
233
+ const sha256 = (path) => createHash("sha256").update(readFileSync(path)).digest("hex");
234
+
235
+ function kitFonts() {
236
+ const manifest = JSON.parse(readFileSync(join(KIT, "fonts.json"), "utf8"));
237
+ return manifest.fonts.map((font) => {
238
+ const source = resolve(KIT, font.source);
239
+ const licence = resolve(KIT, font.licence.source);
240
+ if (!existsSync(source) || sha256(source) !== font.sha256) throw new Error(`font ${font.file} is missing or does not match kit/fonts.json`);
241
+ if (!existsSync(licence) || sha256(licence) !== font.licence.sha256) throw new Error(`licence for ${font.family} is missing or does not match kit/fonts.json`);
242
+ return { ...font, sourcePath: source, licencePath: licence };
243
+ });
244
+ }
245
+
246
+ // What a moment is and where it sits. A lock without a kind is valid to write (the page is not
247
+ // built yet) but DS-008 fails every render until the moment is declared and present.
248
+ function momentRecord(family, { kind = null, note = null, place = null } = {}) {
249
+ const scale = MOMENT_SCALE_BY_FAMILY[family];
250
+ if (kind !== null) {
251
+ const all = [...MOMENT_KINDS.bold, ...MOMENT_KINDS.small];
252
+ if (!all.includes(kind)) throw new Error(`unknown moment "${kind}"; one of ${all.join(", ")}`);
253
+ if (!MOMENT_KINDS[scale].includes(kind)) throw new Error(`a ${scale === "small" ? "work surface takes a small" : "read-once page takes a bold"} moment: ${MOMENT_KINDS[scale].join(", ")}`);
254
+ if (!String(note ?? "").trim()) throw new Error("a moment needs --moment-note: what it shows, in a few words");
255
+ }
256
+ return { scale, kind, note: kind === null ? null : String(note).trim(), place: kind === null || !String(place ?? "").trim() ? null : String(place).trim(), selector: MOMENT_SELECTOR, above_fold: scale === "bold" };
257
+ }
258
+
259
+ export function lockRecord(style, family, moment = {}, density = "rich") {
260
+ return {
261
+ schema_id: LOCK_SCHEMA,
262
+ style: style.id,
263
+ family,
264
+ density: checkDensity(density),
265
+ fonts: [...new Set([style.fonts.text, style.fonts.display, style.fonts.mono])],
266
+ type: { scale_px: Object.values(style.type.scale_px), display_px: style.type.display_px, moment_px: style.type.moment_px },
267
+ radius_px: [...new Set([0, ...RADIUS_KEYS.map((key) => style.radius_px[key])])],
268
+ space_px: style.space_px,
269
+ colors: { light: style.color.light, dark: style.color.dark },
270
+ elevation: style.shape.elevation,
271
+ files: { tokens: "ui/tokens.css", components: "ui/components.css", script: "ui/ui.js" },
272
+ accent_art: style.accent_art ?? "none",
273
+ moment: momentRecord(family, moment),
274
+ };
275
+ }
276
+
277
+ export function setMoment({ dir, kind, note, place = null }) {
278
+ const lockPath = join(resolve(dir), LOCK_FILE);
279
+ const lock = JSON.parse(readFileSync(lockPath, "utf8"));
280
+ lock.moment = momentRecord(lock.family, { kind, note, place });
281
+ writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
282
+ return lock.moment;
283
+ }
284
+
285
+ export function initLock({ styleId = null, family, dir, scaffold = false, from = null, force = false, brief = null, moment = null, momentNote = null, momentPlace = null, density = null }) {
286
+ if (!FAMILIES[family]) throw new Error(`--family must be one of ${Object.keys(FAMILIES).join(", ")}`);
287
+ const register = checkDensity(density ?? densityForBrief(brief));
288
+ const declared = momentRecord(family, { kind: moment, note: momentNote, place: momentPlace });
289
+ const styles = kitStyles();
290
+ let style;
291
+ if (from) {
292
+ style = JSON.parse(readFileSync(resolve(from), "utf8"));
293
+ if (styles.some((kit) => kit.id === style.id)) throw new Error(`--from style id "${style.id}" reuses a kit style id; give the brand style its own id`);
294
+ } else {
295
+ style = styles.find((kit) => kit.id === (styleId ?? styleForBrief(brief, styles)?.id ?? DEFAULT_STYLE_BY_FAMILY[family]));
296
+ if (!style) throw new Error(`unknown style "${styleId}"; run \`design-lock.mjs list\``);
297
+ }
298
+ const issues = validateStyle(style);
299
+ if (issues.length) throw new Error(`style ${style.id} cannot be locked:\n- ${issues.join("\n- ")}`);
300
+ const project = resolve(dir);
301
+ mkdirSync(join(project, "ui", "fonts"), { recursive: true });
302
+ const lockPath = join(project, LOCK_FILE);
303
+ if (existsSync(lockPath) && !force) {
304
+ const current = JSON.parse(readFileSync(lockPath, "utf8"));
305
+ if (current.style !== style.id) throw new Error(`${LOCK_FILE} already locks "${current.style}"; pass --force only if the user changed the direction`);
306
+ }
307
+ const fonts = kitFonts();
308
+ for (const font of fonts) {
309
+ copyFileSync(font.sourcePath, join(project, "ui", "fonts", font.file));
310
+ copyFileSync(font.licencePath, join(project, "ui", "fonts", font.licence.file));
311
+ }
312
+ writeFileSync(join(project, "ui", "tokens.css"), tokensCss(style, fonts, register));
313
+ copyFileSync(join(KIT, "components.css"), join(project, "ui", "components.css"));
314
+ copyFileSync(join(KIT, "ui.js"), join(project, "ui", "ui.js"));
315
+ writeFileSync(lockPath, `${JSON.stringify({ ...lockRecord(style, family, {}, register), moment: declared }, null, 2)}\n`);
316
+ let scaffolded = null;
317
+ if (scaffold) {
318
+ const page = join(project, "index.html");
319
+ if (existsSync(page) && !force) scaffolded = "kept existing index.html";
320
+ else {
321
+ copyFileSync(join(KIT, "layouts", `${family}.html`), page);
322
+ scaffolded = "index.html";
323
+ }
324
+ }
325
+ return { style, family, project, scaffolded, density: register };
326
+ }
327
+
328
+ function parse(argv) {
329
+ const [command, ...rest] = argv;
330
+ const options = { command };
331
+ for (let i = 0; i < rest.length; i += 1) {
332
+ const arg = rest[i];
333
+ if (arg === "--scaffold" || arg === "--force") options[arg.slice(2)] = true;
334
+ else if (["--style", "--family", "--dir", "--from", "--brief", "--moment", "--moment-note", "--moment-place", "--density", "--kind", "--note", "--place"].includes(arg)) options[arg.slice(2)] = rest[++i];
335
+ else if (command === "match" && options.text === undefined) options.text = arg;
336
+ else throw new Error(`unknown argument ${arg}`);
337
+ }
338
+ return options;
339
+ }
340
+
341
+ function main(argv) {
342
+ const options = parse(argv);
343
+ if (options.command === "list") {
344
+ for (const style of kitStyles()) {
345
+ console.log(`${style.id} — ${style.name}\n ${style.summary}\n use when: ${style.use_when.join("; ")}\n avoid when: ${style.avoid_when.join("; ")}\n picked by: ${style.keywords.join(", ")}\n signature: ${style.signature.join("; ")}\n never: ${style.forbid.join("; ")}\n`);
346
+ }
347
+ console.log("Families (layout starters in kit/layouts/):");
348
+ for (const [id, text] of Object.entries(FAMILIES)) console.log(` ${id} — ${text} (default style: ${DEFAULT_STYLE_BY_FAMILY[id]})\n moments (${id}): ${MOMENT_KINDS[MOMENT_SCALE_BY_FAMILY[id]].join(", ")}`);
349
+ return 0;
350
+ }
351
+ if (options.command === "init") {
352
+ if (!options.dir || !options.family) throw new Error("init needs --family and --dir (--style or --from only to override the family default)");
353
+ const result = initLock({ styleId: options.style, family: options.family, dir: options.dir, scaffold: options.scaffold, from: options.from, force: options.force, brief: options.brief, density: options.density ?? null, moment: options.moment ?? null, momentNote: options["moment-note"] ?? null, momentPlace: options["moment-place"] ?? null });
354
+ console.log(`Locked "${result.style.id}" (${result.family}, ${result.density}) in ${result.project}`);
355
+ console.log(`Wrote ${LOCK_FILE}, ui/tokens.css, ui/components.css, ui/ui.js and ui/fonts/.${result.scaffolded ? ` Scaffold: ${result.scaffolded}.` : ""}`);
356
+ console.log("Link ui/tokens.css then ui/components.css (and ui/ui.js with defer when you use charts, sortable tables or tabs).");
357
+ console.log("Build with the kit's ui-* classes and var(--c-*), var(--fs-*), var(--sp-*), var(--radius-*) only; the interface probe checks the page against this lock.");
358
+ const declared = JSON.parse(readFileSync(join(result.project, LOCK_FILE), "utf8")).moment;
359
+ console.log(declared.kind
360
+ ? `Signature moment: ${declared.kind} (${declared.note}). Mark that one element with data-ui-moment="${declared.kind}"${declared.above_fold ? " in the first screen" : ""}.`
361
+ : `No signature moment yet: decide the ${declared.scale} moment (${MOMENT_KINDS[declared.scale].join(", ")}) and record it with \`design-lock.mjs moment\`; the probe fails the page until it is declared and present (DS-008).`);
362
+ return 0;
363
+ }
364
+ if (options.command === "moment") {
365
+ if (!options.dir || !options.kind) throw new Error("moment needs --dir, --kind and --note");
366
+ const moment = setMoment({ dir: options.dir, kind: options.kind, note: options.note, place: options.place ?? null });
367
+ console.log(`Recorded ${moment.scale} moment ${moment.kind}: mark it with data-ui-moment="${moment.kind}"${moment.above_fold ? " in the first screen" : ""}.`);
368
+ return 0;
369
+ }
370
+ if (options.command === "match") {
371
+ const hit = styleForBrief(options.text);
372
+ console.log(hit ? `${hit.id} (keyword "${hit.keyword}")` : "no style named; the family default applies");
373
+ const density = densityForBrief(options.text);
374
+ console.log(`density: ${density}${density === "calm" ? " (the request asks for a calm page)" : " (the default; calm only when the request asks)"}`);
375
+ return 0;
376
+ }
377
+ if (options.command === "show") {
378
+ const lock = JSON.parse(readFileSync(join(resolve(options.dir ?? "."), LOCK_FILE), "utf8"));
379
+ console.log(JSON.stringify({ style: lock.style, family: lock.family, density: lock.density ?? null, fonts: lock.fonts, type: lock.type, radius_px: lock.radius_px, moment: lock.moment ?? null }, null, 2));
380
+ return 0;
381
+ }
382
+ throw new Error("usage: design-lock.mjs list | init [--style <id>] [--brief <text>] [--density rich|calm] --family <landing|workspace|settings> --dir <project> [--moment <kind> --moment-note <text> [--moment-place <text>]] [--scaffold] [--from <style.json>] [--force] | moment --dir <project> --kind <kind> --note <text> [--place <text>] | match <text> | show --dir <project>");
383
+ }
384
+
385
+ const startedDirectly = () => {
386
+ try {
387
+ return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
388
+ } catch {
389
+ return false;
390
+ }
391
+ };
392
+
393
+ if (startedDirectly()) {
394
+ try {
395
+ process.exitCode = main(process.argv.slice(2));
396
+ } catch (error) {
397
+ console.error(`design-lock: ${error.message}`);
398
+ process.exitCode = 1;
399
+ }
400
+ }
@@ -42,5 +42,10 @@
42
42
  "glowOffsetMaxPx": 2,
43
43
  "glowBlurMinPx": 16,
44
44
  "glowSaturationMin": 0.4,
45
- "hoverMaximumMs": 150
45
+ "hoverMaximumMs": 150,
46
+ "designLock": {
47
+ "tokensFile": "tokens.css",
48
+ "momentAttribute": "data-ui-moment",
49
+ "slotPattern": "\\{\\{(?! )[^{}\\n]{1,80}(?<! )\\}\\}"
50
+ }
46
51
  }
@@ -4,7 +4,7 @@ import { spawnTool } from './hostexec.mjs';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
6
6
  import { tmpdir } from 'node:os';
7
- import { dirname, extname, join, resolve } from 'node:path';
7
+ import { dirname, extname, join, resolve, sep as separator } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
 
10
10
  const scriptDir = dirname(fileURLToPath(import.meta.url));
@@ -25,8 +25,9 @@ mkdirSync(out, { recursive: true });
25
25
  const thresholds = JSON.parse(readFileSync(join(scriptDir, 'probe-rules.json'), 'utf8'));
26
26
  const referenceDir = resolve(scriptDir, '../references');
27
27
  const allRules = [...new Set(['craft-floor.md', 'slop-register.md'].flatMap((name) =>
28
- [...readFileSync(join(referenceDir, name), 'utf8').matchAll(/^\| ((?:CF|RS|SLOP)-\d{3}) \|/gm)].map((match) => match[1])))];
29
- const staticRules = new Set(['SLOP-058', 'CF-503', 'SLOP-009', 'SLOP-057', 'SLOP-060', 'SLOP-061', 'CF-507']);
28
+ [...readFileSync(join(referenceDir, name), 'utf8').matchAll(/^\| ((?:CF|RS|SLOP|DS)-\d{3}) \|/gm)].map((match) => match[1])))];
29
+ const staticRules = new Set(['SLOP-058', 'CF-503', 'SLOP-009', 'SLOP-057', 'SLOP-060', 'SLOP-061', 'CF-507',
30
+ 'DS-001', 'DS-002', 'DS-003', 'DS-006', 'DS-008']);
30
31
  const liveRules = new Set(['RS-001', 'RS-002', 'RS-003', 'RS-006', 'RS-007', 'RS-008', 'CF-101', 'CF-102', 'CF-103',
31
32
  'CF-107', 'CF-201', 'CF-202', 'CF-205', 'CF-401', 'CF-404', 'CF-406', 'CF-503', 'CF-507', 'CF-603', 'CF-701', 'CF-806', 'CF-807', 'SLOP-008',
32
33
  'SLOP-009', 'SLOP-036', 'SLOP-040', 'SLOP-053', 'SLOP-057', 'SLOP-058', 'SLOP-059', 'SLOP-060', 'SLOP-061']);
@@ -35,6 +36,33 @@ const notVerified = allRules.filter((rule) => !staticRules.has(rule))
35
36
 
36
37
  function staticFallback(file) {
37
38
  if (!file || !existsSync(file)) return { findings: [], not_verified: notVerified, source: null };
39
+ const root = lstatSync(file).isDirectory() ? file : dirname(file);
40
+ const locks = new Map();
41
+ const collect = (item) => {
42
+ const info = lstatSync(item);
43
+ if (info.isSymbolicLink()) return;
44
+ if (info.isDirectory()) {
45
+ for (const child of readdirSync(item)) {
46
+ if (child === 'node_modules' || child === '.git') continue;
47
+ collect(join(item, child));
48
+ }
49
+ return;
50
+ }
51
+ if (item.endsWith(`${separator}design.lock.json`) || item === join(root, 'design.lock.json')) {
52
+ try { locks.set(dirname(item), JSON.parse(readFileSync(item, 'utf8'))); } catch { /* unparseable lock: no DS cover */ }
53
+ }
54
+ };
55
+ try { collect(root); } catch { /* unreadable tree: no DS cover */ }
56
+ const lockFor = (item) => {
57
+ let directory = dirname(item);
58
+ while (directory.startsWith(root)) {
59
+ if (locks.has(directory)) return locks.get(directory);
60
+ const parent = dirname(directory);
61
+ if (parent === directory) break;
62
+ directory = parent;
63
+ }
64
+ return null;
65
+ };
38
66
  const findings = [];
39
67
  const visit = (item) => {
40
68
  const info = lstatSync(item);
@@ -62,7 +90,46 @@ function staticFallback(file) {
62
90
  if (/lorem ipsum|\[placeholder\]|\bTODO\b/i.test(contents)) add('SLOP-060', 'placeholder copy', 'finished copy', 'LOW');
63
91
  if (/<marquee\b/i.test(contents)) add('SLOP-061', 'marquee', 'stationary copy');
64
92
  if (/will-change\s*:\s*(?:all|width|height|margin|padding)/i.test(contents)) add('CF-507', 'layout property', 'active compositor property');
93
+ const lock = item && ['.html', '.htm', '.css'].includes(extension) ? lockFor(item) : null;
94
+ if (lock && ['.html', '.htm'].includes(extension) && !/tokens\.css/.test(contents))
95
+ add('DS-001', 'design lock not applied', 'locked tokens.css linked', 'HIGH', 'measured');
96
+ if (lock && ['.html', '.htm'].includes(extension)) {
97
+ const slot = /\{\{(?! )[^{}\n]{1,80}(?<! )\}\}/.exec(contents);
98
+ if (slot) add('DS-006', 'starter slot left in the page', 'slots replaced with content', 'HIGH', 'measured', slot);
99
+ const kind = lock.moment && lock.moment.kind;
100
+ if (!kind || !contents.includes(`data-ui-moment="${kind}"`))
101
+ add('DS-008', 'signature moment missing', `data-ui-moment="${kind || 'a declared kind'}" on content`, 'HIGH', 'measured');
102
+ }
103
+ if (lock && extension === '.css' && extname(item) !== '.map' && !/(^|[\\/])tokens\.css$/.test(item)) {
104
+ const bare = contents.replace(/\/\*[\s\S]*?\*\//g, ' ')
105
+ .replace(/@font-face\s*\{[^{}]*\}/gi, ' ')
106
+ .replace(/:root\s*\{[^{}]*\}/gi, ' ');
107
+ const masked = maskVarSpans(bare);
108
+ const family = /font-family\s*:\s*([^;{}]+);/gi;
109
+ let declared;
110
+ while ((declared = family.exec(masked)) !== null) {
111
+ if (declared[1].trim() !== '' && !/^\s*var\(/.test(declared[1])) {
112
+ add('DS-002', 'font family outside the lock', 'var(--font-*) family', 'HIGH', 'measured');
113
+ break;
114
+ }
115
+ }
116
+ const colour = /(#[0-9a-fA-F]{3,8}\b|rgba?\([^()]*\)|hsla?\([^()]*\))/g;
117
+ const hit = colour.exec(masked);
118
+ if (hit) add('DS-003', 'colour outside the locked palette', 'var(--c-*) role', 'HIGH', 'measured', hit);
119
+ }
65
120
  };
121
+ function maskVarSpans(text) {
122
+ let depth = 0;
123
+ let out = '';
124
+ for (let index = 0; index < text.length; index += 1) {
125
+ if (text.startsWith('var(', index)) { depth += 1; out += ' '; index += 3; continue; }
126
+ const char = text[index];
127
+ if (char === '(') depth += 1;
128
+ else if (char === ')') depth = Math.max(0, depth - 1);
129
+ out += depth > 0 ? ' ' : char;
130
+ }
131
+ return out;
132
+ }
66
133
  visit(file);
67
134
  return { findings, not_verified: notVerified, source: file };
68
135
  }
@@ -88,7 +155,12 @@ function save(report) {
88
155
  }
89
156
 
90
157
  function fixFor(rule) {
91
- const named = { 'RS-006': 'Remove page overflow or contain the intentional rail',
158
+ const named = { 'DS-001': 'Link the locked ui/tokens.css before any other stylesheet',
159
+ 'DS-002': 'Use var(--font-text), var(--font-display) or var(--font-mono); a new family needs a new lock',
160
+ 'DS-003': 'Replace the literal colour with the var(--c-*) role that means the same thing',
161
+ 'DS-006': 'Replace every {{slot}} from the kit layout with real content, or delete the element',
162
+ 'DS-008': 'Record one signature moment in design.lock.json and mark that element with data-ui-moment',
163
+ 'RS-006': 'Remove page overflow or contain the intentional rail',
92
164
  'RS-007': 'Restore readable text and its container', 'CF-201': 'Use a contrasting ink or surface',
93
165
  'CF-701': 'Expand the target without shifting visual hierarchy', 'CF-503': 'Start entrance at or above the scale floor',
94
166
  'SLOP-058': 'Use a working destination', 'SLOP-060': 'Replace unfinished copy' };