@sableui/react 0.0.0-stage → 0.1.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.
Files changed (247) hide show
  1. package/AGENTS.md +157 -0
  2. package/CHANGELOG.md +55 -0
  3. package/CONTRIBUTING.md +221 -0
  4. package/DESIGN.md +548 -0
  5. package/LICENSE +21 -0
  6. package/README.md +194 -2
  7. package/THIRD_PARTY_NOTICES.md +44 -0
  8. package/bin/sable-lint.mjs +0 -0
  9. package/bin/sable.mjs +1223 -0
  10. package/dist/_contract-CYScOdf5.d.ts +150 -0
  11. package/dist/chunk-3IWZYBGH.js +398 -0
  12. package/dist/chunk-5Z5KLOAU.js +167 -0
  13. package/dist/chunk-7MG4SHYX.js +15 -0
  14. package/dist/chunk-ADLLDWLW.js +15 -0
  15. package/dist/chunk-EP55KSYA.js +14 -0
  16. package/dist/chunk-J23ELQQZ.js +9396 -0
  17. package/dist/chunk-JI7WJP2L.js +286 -0
  18. package/dist/chunk-K7RUHUGC.js +14 -0
  19. package/dist/chunk-KO3NAEBF.js +15 -0
  20. package/dist/chunk-L4IEIQQA.js +14 -0
  21. package/dist/chunk-L6Y46C62.js +270 -0
  22. package/dist/chunk-NEQNXI2G.js +25 -0
  23. package/dist/chunk-PAOZBZME.js +40 -0
  24. package/dist/chunk-RWP4XBSK.js +139 -0
  25. package/dist/chunk-SRQWQUTW.js +63 -0
  26. package/dist/chunk-YXBOYWBN.js +15 -0
  27. package/dist/foundations/index.d.ts +88 -0
  28. package/dist/foundations/index.js +792 -0
  29. package/dist/icon/index.d.ts +60 -0
  30. package/dist/icon/index.js +2 -0
  31. package/dist/icon-sets/brands.d.ts +44 -0
  32. package/dist/icon-sets/brands.js +147 -0
  33. package/dist/icon-sets/reicon.d.ts +8 -0
  34. package/dist/icon-sets/reicon.js +1 -0
  35. package/dist/index.d.ts +7013 -0
  36. package/dist/index.js +15462 -0
  37. package/dist/ios/chrome.css +83 -0
  38. package/dist/ios/frame.css +5 -0
  39. package/dist/ios/index.d.ts +70 -0
  40. package/dist/ios/index.js +313 -0
  41. package/dist/ios/preview.d.ts +64 -0
  42. package/dist/ios/preview.js +133 -0
  43. package/dist/ios/styles.css +77 -0
  44. package/dist/layout/index.d.ts +280 -0
  45. package/dist/layout/index.js +2 -0
  46. package/dist/meta-6dUzgC6M.d.ts +205 -0
  47. package/dist/patterns/index.d.ts +36 -0
  48. package/dist/patterns/index.js +934 -0
  49. package/dist/presets/index.d.ts +37 -0
  50. package/dist/presets/index.js +117 -0
  51. package/dist/registry-CyL28OzJ.d.ts +28 -0
  52. package/dist/styles.css +1893 -0
  53. package/dist/theme-provider-BWcizhAt.d.ts +105 -0
  54. package/dist/themes/atelier/atelier.css +213 -0
  55. package/dist/themes/atelier/index.d.ts +9 -0
  56. package/dist/themes/atelier/index.js +1 -0
  57. package/dist/themes/base/base.css +443 -0
  58. package/dist/themes/base/index.d.ts +9 -0
  59. package/dist/themes/base/index.js +1 -0
  60. package/dist/themes/ember/ember.css +270 -0
  61. package/dist/themes/ember/index.d.ts +9 -0
  62. package/dist/themes/ember/index.js +1 -0
  63. package/dist/themes/index.d.ts +10 -0
  64. package/dist/themes/index.js +8 -0
  65. package/dist/themes/meridian/index.d.ts +9 -0
  66. package/dist/themes/meridian/index.js +1 -0
  67. package/dist/themes/meridian/meridian.css +255 -0
  68. package/dist/themes/poppy/index.d.ts +6 -0
  69. package/dist/themes/poppy/index.js +1 -0
  70. package/dist/themes/poppy/poppy.css +174 -0
  71. package/dist/themes/prism/index.d.ts +6 -0
  72. package/dist/themes/prism/index.js +1 -0
  73. package/dist/themes/prism/prism.css +183 -0
  74. package/dist/themes/verdant/index.d.ts +9 -0
  75. package/dist/themes/verdant/index.js +1 -0
  76. package/dist/themes/verdant/verdant.css +210 -0
  77. package/dist/themes.css +30 -0
  78. package/llms/components/accordion.md +93 -0
  79. package/llms/components/app-header.md +70 -0
  80. package/llms/components/app-shell.md +117 -0
  81. package/llms/components/attachment.md +105 -0
  82. package/llms/components/avatar.md +141 -0
  83. package/llms/components/badge.md +117 -0
  84. package/llms/components/banner.md +119 -0
  85. package/llms/components/bottom-nav.md +80 -0
  86. package/llms/components/breadcrumbs.md +75 -0
  87. package/llms/components/browser.md +67 -0
  88. package/llms/components/bubble.md +119 -0
  89. package/llms/components/button.md +199 -0
  90. package/llms/components/calendar.md +75 -0
  91. package/llms/components/card.md +135 -0
  92. package/llms/components/carousel.md +87 -0
  93. package/llms/components/chat-composer.md +126 -0
  94. package/llms/components/checkbox-card.md +98 -0
  95. package/llms/components/checkbox.md +90 -0
  96. package/llms/components/code-block.md +81 -0
  97. package/llms/components/code.md +62 -0
  98. package/llms/components/collapsible.md +83 -0
  99. package/llms/components/color-field.md +100 -0
  100. package/llms/components/combobox.md +119 -0
  101. package/llms/components/command-menu.md +158 -0
  102. package/llms/components/context-card.md +73 -0
  103. package/llms/components/context-menu.md +79 -0
  104. package/llms/components/data-table.md +99 -0
  105. package/llms/components/date-picker.md +97 -0
  106. package/llms/components/description.md +96 -0
  107. package/llms/components/destructive-action-modal.md +76 -0
  108. package/llms/components/dialog.md +127 -0
  109. package/llms/components/dots-menu.md +64 -0
  110. package/llms/components/empty-state.md +113 -0
  111. package/llms/components/entity.md +67 -0
  112. package/llms/components/feedback.md +63 -0
  113. package/llms/components/fieldset.md +106 -0
  114. package/llms/components/file-dropzone.md +103 -0
  115. package/llms/components/form.md +111 -0
  116. package/llms/components/gliding-highlight.md +84 -0
  117. package/llms/components/inline-status.md +62 -0
  118. package/llms/components/input.md +127 -0
  119. package/llms/components/kbd.md +57 -0
  120. package/llms/components/label.md +73 -0
  121. package/llms/components/marker.md +99 -0
  122. package/llms/components/marquee.md +66 -0
  123. package/llms/components/menu.md +122 -0
  124. package/llms/components/message-scroller.md +110 -0
  125. package/llms/components/message.md +114 -0
  126. package/llms/components/meter.md +124 -0
  127. package/llms/components/middle-truncate.md +62 -0
  128. package/llms/components/note.md +81 -0
  129. package/llms/components/notification-badge.md +67 -0
  130. package/llms/components/number-field.md +70 -0
  131. package/llms/components/otp-field.md +101 -0
  132. package/llms/components/page-header.md +76 -0
  133. package/llms/components/page-loader.md +67 -0
  134. package/llms/components/pagination.md +92 -0
  135. package/llms/components/panel-group.md +120 -0
  136. package/llms/components/panel-section.md +122 -0
  137. package/llms/components/phone.md +70 -0
  138. package/llms/components/pill.md +73 -0
  139. package/llms/components/popover.md +79 -0
  140. package/llms/components/progress.md +98 -0
  141. package/llms/components/radio-card.md +90 -0
  142. package/llms/components/radio.md +86 -0
  143. package/llms/components/relative-time.md +60 -0
  144. package/llms/components/scroller.md +88 -0
  145. package/llms/components/scrub-field.md +97 -0
  146. package/llms/components/search-input.md +84 -0
  147. package/llms/components/select.md +106 -0
  148. package/llms/components/separator.md +70 -0
  149. package/llms/components/settings-menu.md +139 -0
  150. package/llms/components/sheet.md +97 -0
  151. package/llms/components/show-more.md +59 -0
  152. package/llms/components/side-nav.md +106 -0
  153. package/llms/components/sidebar.md +87 -0
  154. package/llms/components/skeleton.md +62 -0
  155. package/llms/components/slider.md +114 -0
  156. package/llms/components/spinner.md +87 -0
  157. package/llms/components/status-bar.md +91 -0
  158. package/llms/components/status-dot.md +70 -0
  159. package/llms/components/stepper.md +125 -0
  160. package/llms/components/streaming-text.md +86 -0
  161. package/llms/components/switch.md +87 -0
  162. package/llms/components/table.md +112 -0
  163. package/llms/components/tabs.md +84 -0
  164. package/llms/components/textarea.md +88 -0
  165. package/llms/components/theme-selector.md +95 -0
  166. package/llms/components/theme-switcher.md +71 -0
  167. package/llms/components/thinking-orb.md +91 -0
  168. package/llms/components/toast.md +130 -0
  169. package/llms/components/toggle.md +95 -0
  170. package/llms/components/toolbar.md +168 -0
  171. package/llms/components/tooltip.md +93 -0
  172. package/llms/components/tree.md +227 -0
  173. package/llms/components/video.md +66 -0
  174. package/llms/components/workspace.md +138 -0
  175. package/llms/foundations/accessibility.md +60 -0
  176. package/llms/foundations/motion.md +96 -0
  177. package/llms/foundations/scrolling.md +41 -0
  178. package/llms/foundations/shape.md +73 -0
  179. package/llms/foundations/spacing.md +82 -0
  180. package/llms/foundations/surfaces.md +69 -0
  181. package/llms/foundations/typography.md +120 -0
  182. package/llms/layout/aspect-box.md +37 -0
  183. package/llms/layout/bleed.md +31 -0
  184. package/llms/layout/center.md +37 -0
  185. package/llms/layout/columns.md +51 -0
  186. package/llms/layout/grid-overlay.md +40 -0
  187. package/llms/layout/grid.md +47 -0
  188. package/llms/layout/page.md +39 -0
  189. package/llms/layout/region.md +49 -0
  190. package/llms/layout/row.md +40 -0
  191. package/llms/layout/safe-area.md +37 -0
  192. package/llms/layout/spacer.md +39 -0
  193. package/llms/layout/split-pane.md +41 -0
  194. package/llms/layout/stack.md +39 -0
  195. package/llms/patterns/auth-page.md +68 -0
  196. package/llms/patterns/brand-landing-page.md +423 -0
  197. package/llms/patterns/chat-panel.md +210 -0
  198. package/llms/patterns/dashboard-page.md +170 -0
  199. package/llms/patterns/feed-page.md +103 -0
  200. package/llms/patterns/landing-page.md +122 -0
  201. package/llms/patterns/list-detail-page.md +100 -0
  202. package/llms/patterns/settings-page.md +121 -0
  203. package/llms/reference/token-intent.md +324 -0
  204. package/llms/reference/utilities.md +129 -0
  205. package/llms/tasks/adding-a-component.md +113 -0
  206. package/llms/tasks/auditing-an-app.md +216 -0
  207. package/llms/tasks/building-a-page.md +109 -0
  208. package/llms/tasks/building-a-tool.md +141 -0
  209. package/llms/tasks/building-an-ios-prototype.md +105 -0
  210. package/llms/tasks/building-with-sable.md +73 -0
  211. package/llms/tasks/choosing-a-component.md +109 -0
  212. package/llms/tasks/converting-an-app.md +139 -0
  213. package/llms/tasks/creating-a-preset.md +43 -0
  214. package/llms/tasks/creating-design-assets.md +96 -0
  215. package/llms/tasks/designing-a-surface.md +115 -0
  216. package/llms/tasks/developing-taste.md +157 -0
  217. package/llms/tasks/getting-started.md +63 -0
  218. package/llms/tasks/learning-from-builds.md +74 -0
  219. package/llms/tasks/reviewing-a-design.md +114 -0
  220. package/llms/tasks/reviewing-a-journey.md +84 -0
  221. package/llms/tasks/reviewing-motion.md +92 -0
  222. package/llms/tasks/using-presets.md +87 -0
  223. package/llms/taste/asset-plan.template.json +53 -0
  224. package/llms/taste/composition-follows-content.md +20 -0
  225. package/llms/taste/hierarchy-is-a-relationship.md +22 -0
  226. package/llms/taste/lessons.json +57 -0
  227. package/llms/taste/motion-needs-a-reason.md +21 -0
  228. package/llms/taste/surface-states-travel-together.md +20 -0
  229. package/llms-full.txt +14906 -0
  230. package/llms.txt +405 -0
  231. package/package.json +185 -3
  232. package/registry.json +18812 -0
  233. package/scripts/agent-setup.mjs +54 -0
  234. package/scripts/design-atlas.mjs +221 -0
  235. package/scripts/design-directions.mjs +123 -0
  236. package/scripts/design-memory.mjs +166 -0
  237. package/scripts/design-preferences.mjs +227 -0
  238. package/scripts/design-presets.mjs +48 -0
  239. package/scripts/grade.mjs +509 -0
  240. package/scripts/lint-rules.mjs +1065 -0
  241. package/scripts/review-evidence.mjs +120 -0
  242. package/scripts/skeleton.mjs +248 -0
  243. package/skills/entry/SKILL.md +34 -0
  244. package/skills/sable/SKILL.md +187 -0
  245. package/skills/sable/agents/openai.yaml +6 -0
  246. package/targets/ios.json +982 -0
  247. package/third-party/reicon-LICENSE.txt +20 -0
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto"
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
3
+ import { dirname, join } from "node:path"
4
+
5
+ const hash = content => createHash("sha256").update(content).digest("hex")
6
+
7
+ /** Install discoverable loaders; the installed package owns the actual workflow. */
8
+ export function installAgentSkill({ root, packageRoot, target = "all", update = false, dryRun = false }) {
9
+ const reject = (message) => { throw new Error(message) }
10
+ if (!["all", "codex", "claude", "cursor"].includes(target)) reject("Choose --target all, codex, claude, or cursor.")
11
+ if (!existsSync(join(root, "package.json"))) reject("Run setup from a project with a package.json.")
12
+ const canonical = join(packageRoot, "skills/sable/SKILL.md")
13
+ const entry = join(packageRoot, "skills/entry/SKILL.md")
14
+ if (!existsSync(canonical) || !existsSync(entry)) reject("The installed package is missing its skill. Reinstall Sable.")
15
+ const destinations = target === "all" ? [".agents/skills/sable", ".claude/skills/sable"]
16
+ : [target === "claude" ? ".claude/skills/sable" : ".agents/skills/sable"]
17
+ const receiptPath = ".sable/agent-setup.json"
18
+ function checkPath(path) {
19
+ let current = root
20
+ for (const part of path.split("/")) {
21
+ current = join(current, part)
22
+ try { if (lstatSync(current).isSymbolicLink()) reject(`Refusing a linked destination: ${path}`) }
23
+ catch (error) { if (error.code !== "ENOENT") throw error }
24
+ }
25
+ }
26
+ checkPath(receiptPath)
27
+ let receipt = { schemaVersion: 1, files: {} }
28
+ if (existsSync(join(root, receiptPath))) {
29
+ receipt = JSON.parse(readFileSync(join(root, receiptPath), "utf8"))
30
+ if (receipt.schemaVersion !== 1 || !receipt.files || typeof receipt.files !== "object") reject("Invalid Sable setup receipt.")
31
+ }
32
+ const files = destinations.flatMap(destination => [
33
+ { path: `${destination}/SKILL.md`, content: readFileSync(entry, "utf8") },
34
+ ...(destination.startsWith(".agents/") ? [{ path: `${destination}/agents/openai.yaml`, content: readFileSync(join(packageRoot, "skills/sable/agents/openai.yaml"), "utf8") }] : []),
35
+ ])
36
+ // Preflight every host and the receipt before any writes; preserve local edits.
37
+ for (const file of files) {
38
+ checkPath(file.path)
39
+ if (!existsSync(join(root, file.path))) continue
40
+ const current = readFileSync(join(root, file.path), "utf8")
41
+ if (current === file.content) continue
42
+ if (!update || receipt.files[file.path] !== hash(current)) reject(`Existing skill differs at ${file.path}. Use --update for managed files; keep or move local edits before setup.`)
43
+ }
44
+ if (!dryRun) {
45
+ for (const file of files) {
46
+ mkdirSync(dirname(join(root, file.path)), { recursive: true })
47
+ writeFileSync(join(root, file.path), file.content)
48
+ receipt.files[file.path] = hash(file.content)
49
+ }
50
+ mkdirSync(dirname(join(root, receiptPath)), { recursive: true })
51
+ writeFileSync(join(root, receiptPath), JSON.stringify(receipt, null, 2) + "\n")
52
+ }
53
+ return { destination: destinations[0], destinations, files: files.map(file => file.path), dryRun, update, next: "Use $sable build <your brief> in Codex or /sable build <your brief> in Claude Code or Cursor." }
54
+ }
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ /** Offline, evidence-backed visual references. Retrieval is lexical, not a taste score. */
3
+ import { createHash } from "node:crypto"
4
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
5
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ const PACKAGE = resolve(dirname(fileURLToPath(import.meta.url)), "..")
9
+ const MODES = ["persuade", "operate", "read", "experience"]
10
+ const DESIGN = ["composition", "typography", "spacing", "color", "imagery", "motion", "interaction", "story", "responsive"]
11
+ const ENTRY = ["id", "title", "url", "discovery", "family", "domains", "modes", "tags", "summary", "design", "appliesWhen", "avoidWhen", "transferPrinciple", "evidence"]
12
+ const text = (value) => typeof value === "string" && value.trim().length > 0
13
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
14
+ const slug = (value) => typeof value === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)
15
+ const stopwords = new Set("a an and are as at be but by for from has have in into is it of on or our that the their these this to use with website websites design designs inspiration site sites".split(" "))
16
+ const words = (value) => [...new Set((String(value).toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).filter((word) => !stopwords.has(word)))]
17
+ const inside = (root, file) => {
18
+ const path = relative(root, file)
19
+ return path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path)
20
+ }
21
+ const webUrl = (value) => {
22
+ try { return typeof value === "string" && ["https:", "http:"].includes(new URL(value).protocol) } catch { return false }
23
+ }
24
+ const keys = (value, allowed, label, errors) => {
25
+ if (!object(value)) { errors.push(`${label} must be an object.`); return false }
26
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) errors.push(`${label}.${key} is not a supported field.`)
27
+ return true
28
+ }
29
+ const strings = (value) => Array.isArray(value) && value.length > 0 && value.every(text) && new Set(value).size === value.length
30
+ const portablePath = (value) => text(value) && !isAbsolute(value) && !/^[a-z]:/i.test(value) && !/[\\:\x00]/.test(value) && value.split("/").every((part) => part !== "" && part !== "." && part !== "..")
31
+
32
+ /** Schema checks always run; --check additionally verifies every file and its physical containment. */
33
+ export function validateAtlas(atlas, { root = PACKAGE, verifyEvidence = false } = {}) {
34
+ const errors = []
35
+ if (!keys(atlas, ["schemaVersion", "entries"], "atlas", errors)) return errors
36
+ if (atlas.schemaVersion !== 1) errors.push("atlas.schemaVersion must be 1.")
37
+ if (!Array.isArray(atlas.entries) || !atlas.entries.length) {
38
+ errors.push("atlas.entries must contain at least one reference.")
39
+ return errors
40
+ }
41
+ const ids = new Set()
42
+ for (const [index, entry] of atlas.entries.entries()) {
43
+ const label = `entries[${index}]`
44
+ if (!keys(entry, ENTRY, label, errors)) continue
45
+ for (const key of ["id", "family"]) if (!slug(entry[key])) errors.push(`${label}.${key} must be a lowercase hyphenated slug.`)
46
+ if (ids.has(entry.id)) errors.push(`${label}.id duplicates ${entry.id}.`)
47
+ ids.add(entry.id)
48
+ for (const key of ["title", "summary", "appliesWhen", "avoidWhen", "transferPrinciple"]) if (!text(entry[key])) errors.push(`${label}.${key} must describe the reference and its context.`)
49
+ if (!webUrl(entry.url)) errors.push(`${label}.url must be an http or https URL.`)
50
+ if (keys(entry.discovery, ["url", "gallery"], `${label}.discovery`, errors)) {
51
+ if (!webUrl(entry.discovery.url) || !text(entry.discovery.gallery)) errors.push(`${label}.discovery needs a gallery name and http or https URL.`)
52
+ }
53
+ for (const key of ["domains", "tags", "modes"]) if (!strings(entry[key])) errors.push(`${label}.${key} must be a nonempty array of unique strings.`)
54
+ if (Array.isArray(entry.modes) && entry.modes.some((mode) => !MODES.includes(mode))) errors.push(`${label}.modes must use ${MODES.join(", ")}.`)
55
+ if (keys(entry.design, DESIGN, `${label}.design`, errors)) {
56
+ for (const key of DESIGN) if (!text(entry.design[key])) errors.push(`${label}.design.${key} must describe an observation or explicitly say it was unobserved.`)
57
+ }
58
+ if (!Array.isArray(entry.evidence) || !entry.evidence.length) { errors.push(`${label}.evidence must contain a captured reference.`); continue }
59
+ const evidencePaths = new Set()
60
+ for (const [number, evidence] of entry.evidence.entries()) {
61
+ const item = `${label}.evidence[${number}]`
62
+ if (!keys(evidence, ["kind", "path", "sha256", "observation", "capturedAt", "viewport", "observed"], item, errors)) continue
63
+ if (!["desktop", "mobile", "interaction", "gallery"].includes(evidence.kind)) errors.push(`${item}.kind must be desktop, mobile, interaction, or gallery.`)
64
+ if (!portablePath(evidence.path)) errors.push(`${item}.path must be a portable package-relative path without traversal.`)
65
+ if (!/^[a-f0-9]{64}$/.test(evidence.sha256 ?? "")) errors.push(`${item}.sha256 must be a lowercase SHA-256 digest.`)
66
+ if (!text(evidence.observation)) errors.push(`${item}.observation must state what was actually inspected.`)
67
+ if (evidence.observed !== true) errors.push(`${item}.observed must be true; uninspected captures are not evidence.`)
68
+ if (!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))?$/.test(evidence.capturedAt ?? "") || !Number.isFinite(Date.parse(evidence.capturedAt))) errors.push(`${item}.capturedAt must be an ISO date or timestamp.`)
69
+ if (keys(evidence.viewport, ["width", "height"], `${item}.viewport`, errors)) {
70
+ for (const dimension of ["width", "height"]) if (!Number.isSafeInteger(evidence.viewport[dimension]) || evidence.viewport[dimension] < 1) errors.push(`${item}.viewport.${dimension} must be a positive integer.`)
71
+ }
72
+ if (evidencePaths.has(evidence.path)) errors.push(`${item}.path duplicates a capture in this entry.`)
73
+ evidencePaths.add(evidence.path)
74
+ if (!verifyEvidence || !portablePath(evidence.path)) continue
75
+ try {
76
+ const physicalRoot = realpathSync(root)
77
+ const file = resolve(root, evidence.path)
78
+ if (!inside(root, file) || !inside(physicalRoot, realpathSync(file))) { errors.push(`${item}.path must physically remain inside the package (including symlinks).`); continue }
79
+ if (!statSync(file).isFile() || statSync(file).size === 0) { errors.push(`${item}.path must name a nonempty file.`); continue }
80
+ const digest = createHash("sha256").update(readFileSync(file)).digest("hex")
81
+ if (digest !== evidence.sha256) errors.push(`${item}.sha256 does not match the captured file.`)
82
+ } catch (error) { errors.push(`${item}.path cannot be read: ${error.code ?? error.message}`) }
83
+ }
84
+ }
85
+ return errors
86
+ }
87
+
88
+ /*
89
+ * Where an atlas lives. The published package ships none: its references are
90
+ * captures of other companies' sites, which are not ours to redistribute. A
91
+ * project can keep its own at `.sable/atlas/atlas.json` (evidence paths relative
92
+ * to the project root); a workspace checkout of Sable still has its internal one.
93
+ */
94
+ export function locateAtlas({ root = PACKAGE, cwd = process.cwd() } = {}) {
95
+ for (const [base, file] of [[root, "llms/atlas/atlas.json"], [cwd, ".sable/atlas/atlas.json"]]) {
96
+ if (existsSync(resolve(base, file))) return { root: base, file }
97
+ }
98
+ return null
99
+ }
100
+
101
+ export function readAtlas({ root = PACKAGE, file = "llms/atlas/atlas.json", verifyEvidence = false } = {}) {
102
+ const atlas = JSON.parse(readFileSync(resolve(root, file), "utf8"))
103
+ return { entries: atlas.entries, errors: validateAtlas(atlas, { root, verifyEvidence }) }
104
+ }
105
+
106
+ const jaccard = (a, b) => {
107
+ const union = new Set([...a, ...b])
108
+ return union.size ? [...a].filter((term) => b.has(term)).length / union.size : 0
109
+ }
110
+
111
+ /** Context filters are hard constraints. Contrasting choices must remain positively relevant. */
112
+ export function searchAtlas(entries, { query = "", mode, family, limit = 6, contrast = false } = {}) {
113
+ const terms = words(query)
114
+ const candidates = entries.filter((entry) => (!mode || entry.modes.includes(mode)) && (!family || entry.family === family)).map((entry) => {
115
+ const identity = new Set(words([entry.id, entry.title, entry.family, ...entry.domains, ...entry.modes, ...entry.tags].join(" ")))
116
+ const context = new Set(words(`${entry.summary} ${entry.appliesWhen} ${entry.transferPrinciple}`))
117
+ const design = new Set(words(Object.values(entry.design).join(" ")))
118
+ // Avoid-when text deliberately does not attract a reference to a use it warns against.
119
+ const matchedTerms = terms.filter((term) => identity.has(term) || context.has(term) || design.has(term))
120
+ const raw = terms.reduce((sum, term) => sum + (identity.has(term) ? 5 : context.has(term) ? 3 : design.has(term) ? 1 : 0), 0)
121
+ const relevance = terms.length ? raw * (matchedTerms.length / terms.length) : 1
122
+ return { ...entry, relevance, matchedTerms }
123
+ }).filter((entry) => entry.relevance > 0 && (!text(query) || terms.length > 0)).sort((a, b) => b.relevance - a.relevance || a.id.localeCompare(b.id))
124
+ if (!contrast || !candidates.length) return { entries: candidates.slice(0, limit), total: candidates.length }
125
+ const maximum = candidates[0].relevance
126
+ // A weak keyword coincidence is not a useful contrasting direction.
127
+ const remaining = candidates.filter((entry) => entry.relevance >= maximum * 0.35)
128
+ const selected = []
129
+ while (remaining.length && selected.length < limit) {
130
+ let best = 0
131
+ let bestScore = -Infinity
132
+ for (const [index, candidate] of remaining.entries()) {
133
+ const tags = new Set(candidate.tags)
134
+ const similarity = selected.length ? Math.max(...selected.map((entry) => (candidate.family === entry.family ? 0.75 : 0) + 0.25 * jaccard(tags, new Set(entry.tags)))) : 0
135
+ const score = candidate.relevance / maximum - 0.45 * similarity
136
+ if (score > bestScore) { best = index; bestScore = score }
137
+ }
138
+ selected.push(remaining.splice(best, 1)[0])
139
+ }
140
+ return { entries: selected, total: candidates.length }
141
+ }
142
+
143
+ const notice = "References are contextual observations, not templates or proof of usability. View the local evidence; preserve uncertainty about unobserved behavior. Retrieval is lexical and contrast favors different visual families among relevant matches."
144
+ const brief = (entry) => ({ id: entry.id, title: entry.title, url: entry.url, family: entry.family, domains: entry.domains, modes: entry.modes, summary: entry.summary, evidenceKinds: [...new Set(entry.evidence.map((item) => item.kind))], ...(entry.relevance !== undefined ? { relevance: entry.relevance, matchedTerms: entry.matchedTerms } : {}), retrieve: `sable atlas --show ${entry.id} --json` })
145
+ const detail = (entry, root) => ({ ...entry, evidence: entry.evidence.map((item) => ({ ...item, absolutePath: resolve(root, item.path) })) })
146
+
147
+ export function runAtlas(args, { root = PACKAGE, stdout = process.stdout, stderr = process.stderr } = {}) {
148
+ const fail = (code, message, suggestions = []) => { stderr.write(`${JSON.stringify({ type: "error", code, message, suggestions })}\n`); return 1 }
149
+ const flags = new Map()
150
+ const values = new Set(["--search", "--show", "--mode", "--family", "--limit"])
151
+ const switches = new Set(["--list", "--check", "--contrast", "--json", "--help"])
152
+ for (let index = 0; index < args.length; index++) {
153
+ const flag = args[index]
154
+ if (!values.has(flag) && !switches.has(flag)) return fail("ERR_BAD_FLAG", `Unknown flag: ${flag}`, ["sable atlas --help"])
155
+ if (flags.has(flag)) return fail("ERR_BAD_FLAG", `Repeated flag: ${flag}`)
156
+ if (values.has(flag)) {
157
+ if (!text(args[index + 1]) || args[index + 1].startsWith("--")) return fail("ERR_MISSING_ARGUMENT", `${flag} needs a value.`)
158
+ flags.set(flag, args[++index])
159
+ } else flags.set(flag, true)
160
+ }
161
+ const actions = ["--list", "--search", "--show", "--check"].filter((flag) => flags.has(flag))
162
+ if (actions.length > 1) return fail("ERR_BAD_FLAG", "Choose one of --list, --search, --show, or --check.")
163
+ const modifiers = ["--mode", "--family", "--limit", "--contrast"]
164
+ if ((flags.has("--show") || flags.has("--check")) && modifiers.some((flag) => flags.has(flag))) return fail("ERR_BAD_FLAG", "Context filters, --limit, and --contrast apply only to listing or searching.")
165
+ if (flags.has("--mode") && !MODES.includes(flags.get("--mode"))) return fail("ERR_BAD_ARGUMENT", `--mode must be ${MODES.join(", ")}.`)
166
+ if (flags.has("--limit") && (!/^[1-9]\d*$/.test(flags.get("--limit")) || Number(flags.get("--limit")) > 100)) return fail("ERR_BAD_ARGUMENT", "--limit must be an integer from 1 to 100.")
167
+ if (flags.has("--help")) {
168
+ stdout.write("sable atlas — inspect an offline visual reference atlas\n\n --list | --search <words> | --show <id> | --check\n --mode persuade|operate|read|experience filter by user activity\n --family <slug> filter by visual family\n --limit <1..100> default: search 6, list 100\n --contrast favor relevant, different families\n --json structured { type, data } output\n\nListings are brief; --show returns all observations and absolute evidence paths.\n--check verifies schema, file hashes, and physical path containment, offline.\nNo reference is forced when a search has no relevant match.\n")
169
+ return 0
170
+ }
171
+ const located = locateAtlas({ root })
172
+ if (!located) {
173
+ // Not an error: no atlas is installed, so there is nothing to retrieve.
174
+ const data = { entries: [], total: 0, count: 0, notice: "No visual atlas is installed. The Sable package ships none; a project can add its own at .sable/atlas/atlas.json.", suggestion: "Work from the brief and the user's own references, or proceed with an original direction." }
175
+ if (flags.has("--json")) stdout.write(`${JSON.stringify({ type: flags.has("--check") ? "atlas.check" : "atlas.list", data }, null, 2)}
176
+ `)
177
+ else stdout.write(`${data.notice}
178
+ ${data.suggestion}
179
+ `)
180
+ return 0
181
+ }
182
+ root = located.root
183
+ try {
184
+ const atlas = readAtlas({ root, file: located.file, verifyEvidence: flags.has("--check") })
185
+ if (atlas.errors.length) return fail("ERR_ATLAS_INVALID", atlas.errors.join("\n"))
186
+ const families = [...new Set(atlas.entries.map((entry) => entry.family))].sort()
187
+ if (flags.has("--family") && !families.includes(flags.get("--family"))) return fail("ERR_UNKNOWN_FAMILY", `No visual family ${flags.get("--family")}.`, families)
188
+ if (flags.has("--check")) {
189
+ stdout.write(`${JSON.stringify({ type: "atlas.check", data: { count: atlas.entries.length, evidenceCount: atlas.entries.reduce((sum, entry) => sum + entry.evidence.length, 0), families, notice } }, null, 2)}\n`)
190
+ return 0
191
+ }
192
+ let found
193
+ if (flags.has("--show")) {
194
+ const entry = atlas.entries.find((item) => item.id === flags.get("--show"))
195
+ if (!entry) return fail("ERR_UNKNOWN_REFERENCE", `No reference ${flags.get("--show")}.`, ["sable atlas --list"])
196
+ // Only inspect the requested files, so retrieval remains inexpensive for a large atlas.
197
+ const errors = validateAtlas({ schemaVersion: 1, entries: [entry] }, { root, verifyEvidence: true })
198
+ if (errors.length) return fail("ERR_ATLAS_INVALID", errors.join("\n"))
199
+ found = { entries: [detail(entry, root)], total: 1 }
200
+ } else {
201
+ found = searchAtlas(atlas.entries, { query: flags.get("--search"), mode: flags.get("--mode"), family: flags.get("--family"), limit: Number(flags.get("--limit") ?? (flags.has("--search") ? 6 : 100)), contrast: flags.has("--contrast") })
202
+ found.entries = found.entries.map(brief)
203
+ }
204
+ const data = { ...found, count: found.entries.length, notice, ...(!found.entries.length ? { suggestion: "Broaden the query or filter, or proceed with an original direction; the atlas is not exhaustive." } : {}) }
205
+ if (flags.has("--json")) stdout.write(`${JSON.stringify({ type: flags.has("--show") ? "atlas.detail" : "atlas.list", data }, null, 2)}\n`)
206
+ else {
207
+ for (const entry of found.entries) {
208
+ stdout.write(`${entry.id} — ${entry.title} [${entry.family}; ${entry.modes.join(", ")}]\n ${entry.summary}\n`)
209
+ if (flags.has("--show")) {
210
+ for (const key of DESIGN) stdout.write(` ${key}: ${entry.design[key]}\n`)
211
+ stdout.write(` Apply: ${entry.appliesWhen}\n Avoid: ${entry.avoidWhen}\n Transfer: ${entry.transferPrinciple}\n Source: ${entry.url}\n Discovered: ${entry.discovery.url}\n`)
212
+ for (const item of entry.evidence) stdout.write(` Evidence (${item.kind}): ${item.absolutePath}\n ${item.observation}\n`)
213
+ } else stdout.write(` Inspect: ${entry.retrieve}\n`)
214
+ }
215
+ stdout.write(`${data.count} of ${data.total} matching reference(s). ${data.notice}\n${data.suggestion ? `${data.suggestion}\n` : ""}`)
216
+ }
217
+ return 0
218
+ } catch (error) { return fail("ERR_ATLAS_IO", error.message) }
219
+ }
220
+
221
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = runAtlas(process.argv.slice(2))
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /** Rendered art-direction comparison; declared variety is not an automated taste score. */
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
4
+ import { resolve } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+ import { artifactMime, hasText, inspectArtifacts, parseFlags, portablePath, projectPath, sha256, validId, validateScope } from "./design-preferences.mjs"
7
+
8
+ export const AXES = ["composition", "typography", "density", "imagery", "story", "motion"]
9
+ const STRUCTURAL = ["composition", "density", "story"]
10
+ const normalize = (value) => String(value).trim().toLowerCase().replace(/\s+/g, " ")
11
+ export const directionTemplate = {
12
+ schemaVersion: 1, id: "replace-with-exploration-id", scope: { kind: "project", value: "" },
13
+ brief: { prompt: "", outcome: "", audience: "", mode: "persuade", contentConstraints: [""] },
14
+ directions: ["direction-a", "direction-b", "direction-c"].map((id) => ({ id, title: "", axes: Object.fromEntries(AXES.map((axis) => [axis, ""])), rationale: "", artifacts: ["desktop", "mobile", "interaction"].map((kind) => ({ kind, path: "", sha256: "", description: "" })) })),
15
+ }
16
+
17
+ export function validateDirections(manifest, { root = process.cwd() } = {}) {
18
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return { errors: ["Exploration must be an object."], manifest: null }
19
+ const errors = []
20
+ if (manifest.schemaVersion !== 1) errors.push("schemaVersion must be 1.")
21
+ if (!validId(manifest.id)) errors.push("id must be a lowercase hyphenated slug of at most 80 characters.")
22
+ errors.push(...validateScope(manifest.scope))
23
+ for (const key of ["prompt", "outcome", "audience"]) if (!hasText(manifest.brief?.[key])) errors.push(`brief.${key} must state the common brief for every direction.`)
24
+ if (!["persuade", "operate", "read", "experience"].includes(manifest.brief?.mode)) errors.push("brief.mode must be persuade, operate, read, or experience.")
25
+ if (!Array.isArray(manifest.brief?.contentConstraints) || !manifest.brief.contentConstraints.length || manifest.brief.contentConstraints.some((item) => !hasText(item))) errors.push("brief.contentConstraints must name the content and functional constraints held constant across directions.")
26
+ const directions = Array.isArray(manifest.directions) ? manifest.directions : []
27
+ if (directions.length < 3 || directions.length > 6) errors.push("Render between 3 and 6 directions before comparison.")
28
+ const ids = directions.map((direction) => direction?.id)
29
+ if (ids.some((id) => !validId(id) || id === "tie") || new Set(ids).size !== ids.length) errors.push("Direction ids must be unique slugs and cannot be tie.")
30
+ const seen = new Set()
31
+ let totalBytes = 0
32
+ const sealed = directions.map((direction, index) => {
33
+ for (const key of ["title", "rationale"]) if (!hasText(direction?.[key])) errors.push(`directions[${index}].${key} is required.`)
34
+ for (const axis of AXES) if (!hasText(direction?.axes?.[axis])) errors.push(`directions[${index}].axes.${axis} must describe a concrete choice, including an intentional absence of motion or imagery.`)
35
+ for (const kind of ["desktop", "mobile", "interaction"]) if (!direction?.artifacts?.some((item) => item?.kind === kind)) errors.push(`directions[${index}] needs a ${kind} artifact.`)
36
+ const result = inspectArtifacts(direction?.artifacts, { root, requireHashes: true, seen, label: `directions[${index}].artifacts` })
37
+ errors.push(...result.errors)
38
+ for (const artifact of result.artifacts) totalBytes += readFileSync(projectPath(root, artifact.path)).length
39
+ return { id: direction?.id, title: direction?.title, axes: direction?.axes, rationale: direction?.rationale, artifacts: result.artifacts }
40
+ })
41
+ if (totalBytes > 60 * 1024 * 1024) errors.push("Combined artifacts must be at most 60 MiB; resize screenshots or shorten recordings before comparison.")
42
+ const diversity = []
43
+ for (let left = 0; left < directions.length; left++) for (let right = left + 1; right < directions.length; right++) {
44
+ const a = directions[left], b = directions[right]
45
+ const changed = AXES.filter((axis) => hasText(a?.axes?.[axis]) && hasText(b?.axes?.[axis]) && normalize(a.axes[axis]) !== normalize(b.axes[axis]))
46
+ const structural = changed.filter((axis) => STRUCTURAL.includes(axis))
47
+ if (changed.length < 2 || structural.length === 0) errors.push(`${a?.id} / ${b?.id}: vary at least two declared axes, including composition, density, or story. Palette-only variants do not demonstrate direction diversity.`)
48
+ diversity.push({ left: a?.id, right: b?.id, changedAxes: changed, structuralAxes: structural })
49
+ }
50
+ return { errors, manifest: { schemaVersion: 1, id: manifest.id, scope: manifest.scope, brief: manifest.brief, directions: sealed }, diversity, notice: "Checks verify declared variation and captured evidence, not perceived originality or design quality. A still interaction capture shows a state, not animation timing. Review the rendered alternatives before choosing." }
51
+ }
52
+
53
+ const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char])
54
+ const safeJson = (value) => JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")
55
+
56
+ /** Labels are deterministic for this exploration but omit titles and rationales until revealed. */
57
+ export function comparisonOrder(manifest) {
58
+ return [...manifest.directions].sort((a, b) => sha256(`${manifest.id}:${a.id}`).localeCompare(sha256(`${manifest.id}:${b.id}`))).map((direction, index) => ({ label: String.fromCharCode(65 + index), direction }))
59
+ }
60
+
61
+ export function renderComparison(manifest, { root, sourcePath, sourceHash }) {
62
+ const order = comparisonOrder(manifest)
63
+ const captures = (direction, label, kind) => direction.artifacts.filter((artifact) => artifact.kind === kind).map((artifact) => {
64
+ const bytes = readFileSync(projectPath(root, artifact.path))
65
+ if (sha256(bytes) !== artifact.sha256) throw new Error(`Evidence changed while building comparison: ${artifact.path}`)
66
+ const mime = artifactMime(bytes)
67
+ const src = `data:${mime};base64,${bytes.toString("base64")}`
68
+ const caption = `Direction ${label} · ${kind} capture`
69
+ return `<figure>${mime.startsWith("video/") ? `<video controls muted playsinline preload="metadata" aria-label="${escapeHtml(caption)}"><source src="${src}" type="${mime}"></video>` : `<img src="${src}" alt="${escapeHtml(caption)}" loading="lazy">`}<figcaption>${escapeHtml(caption)}</figcaption></figure>`
70
+ }).join("")
71
+ const cards = order.map(({ label, direction }) => `<article aria-labelledby="label-${label}"><h2 id="label-${label}">Direction ${label}</h2>${captures(direction, label, "desktop")}<details><summary>Mobile and interaction evidence for ${label}</summary>${captures(direction, label, "mobile")}${captures(direction, label, "interaction")}</details></article>`).join("")
72
+ const rationales = order.map(({ label, direction }) => `<section><h3>${label}: ${escapeHtml(direction.title)}</h3><p>${escapeHtml(direction.rationale)}</p><dl>${AXES.map((axis) => `<dt>${axis}</dt><dd>${escapeHtml(direction.axes[axis])}</dd>`).join("")}</dl><h4>Capture notes</h4><ul>${direction.artifacts.map((artifact) => `<li>${escapeHtml(artifact.kind)}: ${escapeHtml(artifact.description)}</li>`).join("")}</ul></section>`).join("")
73
+ const payload = { id: manifest.id, scope: manifest.scope, brief: manifest.brief.prompt, options: order.map(({ label, direction }) => ({ label, id: direction.id, artifacts: direction.artifacts })), source: { kind: "comparison", reference: sourcePath, sha256: sourceHash } }
74
+ const script = `const data = JSON.parse(document.getElementById('comparison-data').textContent);
75
+ document.getElementById('judgment').addEventListener('submit', (event) => {
76
+ event.preventDefault();
77
+ const form = event.currentTarget;
78
+ if (!form.reportValidity()) return;
79
+ const values = new FormData(form);
80
+ const label = values.get('choice');
81
+ const choice = label === 'tie' ? 'tie' : data.options.find(option => option.label === label).id;
82
+ const now = new Date();
83
+ const record = {schemaVersion:1, id:data.id.slice(0,48)+'-'+now.toISOString().replace(/[^0-9]/g,''), scope:data.scope, brief:data.brief, options:data.options.map(({label,...option})=>option), choice, reviewer:{kind:values.get('kind'),name:values.get('name').trim()}, rationale:values.get('rationale').trim(),date:now.toISOString().slice(0,10),source:data.source};
84
+ if (!record.reviewer.name || !record.rationale) {document.getElementById('status').textContent='Enter your name and a concrete reason before exporting.'; return;}
85
+ const blob = new Blob([JSON.stringify(record,null,2)+'\\n'], {type:'application/json'});
86
+ const url = URL.createObjectURL(blob);
87
+ const link = document.createElement('a'); link.href=url; link.download=record.id+'.json'; link.click();
88
+ setTimeout(()=>URL.revokeObjectURL(url),1000);
89
+ document.getElementById('status').textContent='Preference JSON downloaded. Nothing has been saved to Sable memory. Move it into the project, then run: sable prefer --record '+record.id+'.json';
90
+ });`
91
+ const scriptHash = sha256(Buffer.from(script))
92
+ const cspHash = Buffer.from(scriptHash, "hex").toString("base64")
93
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; media-src data:; style-src 'unsafe-inline'; script-src 'sha256-${cspHash}'; form-action 'none'; base-uri 'none'"><title>Rendered direction comparison</title><style>
94
+ :root{color-scheme:light dark;font-family:system-ui,sans-serif;background:Canvas;color:CanvasText;line-height:1.55}*{box-sizing:border-box}body{margin:0}main{max-width:1600px;margin:auto;padding:clamp(16px,4vw,56px)}h1{font-size:clamp(1.8rem,4vw,3rem);line-height:1.1;max-width:24ch}h2,h3{line-height:1.25}p,li{max-width:80ch}.comparison{display:grid;grid-template-columns:repeat(${Math.min(order.length, 3)},minmax(0,1fr));gap:24px;align-items:start;margin-block:32px}article{min-width:0}figure{margin:0 0 20px}img,video{display:block;width:100%;height:auto;border:1px solid GrayText}figcaption{font-size:.85rem;margin-top:8px;overflow-wrap:anywhere}details{border-block:1px solid GrayText;padding-block:12px;margin-block:20px}summary{cursor:pointer;padding:8px 0;font-weight:600}details section{margin-block:24px}dl{display:grid;grid-template-columns:8rem 1fr;gap:8px}dt{font-weight:600}dd{margin:0}form{max-width:760px}fieldset{padding:16px;margin:16px 0;border:1px solid GrayText}legend{font-weight:600}.choices{display:flex;flex-wrap:wrap;gap:12px}.choices label{display:flex;align-items:center;gap:8px;padding:12px;border:1px solid GrayText;cursor:pointer}label.field{display:grid;gap:8px;margin-block:20px}input,select,textarea,button{font:inherit}input,select,textarea{padding:12px;min-width:0;border:1px solid GrayText;border-radius:0;background:Field;color:FieldText}textarea{min-height:120px;resize:vertical}button{padding:12px 24px;cursor:pointer;background:ButtonFace;color:ButtonText;border:2px solid ButtonText}a:focus-visible,button:focus-visible,summary:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid Highlight;outline-offset:4px}code{overflow-wrap:anywhere}#status{min-height:3em}.notice{border-left:3px solid GrayText;padding-left:16px}@media(max-width:900px){.comparison{grid-template-columns:1fr}article{max-width:760px}dl{grid-template-columns:1fr}dd{margin-bottom:8px}}
95
+ </style></head><body><main><header><p>Sable · rendered exploration</p><h1>Compare the directions</h1><p>${escapeHtml(manifest.brief.prompt)}</p><p><strong>Outcome:</strong> ${escapeHtml(manifest.brief.outcome)} <strong>Audience:</strong> ${escapeHtml(manifest.brief.audience)}</p><ul>${manifest.brief.contentConstraints.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><p class="notice">Review the visual evidence before revealing the rationale. Labels are stable within this comparison. Still captures show states; they do not establish animation timing or prove that an interaction works.</p></header><div class="comparison">${cards}</div><details><summary>Reveal direction names and rationale</summary>${rationales}</details><section aria-labelledby="judgment-title"><h2 id="judgment-title">Record a contextual judgment</h2><p>This choice applies to <strong>${escapeHtml(manifest.scope.kind)}: ${escapeHtml(manifest.scope.value)}</strong>. An agent decision remains an agent observation. This page downloads a record; it does not save anything to Sable automatically.</p><form id="judgment"><fieldset><legend>Which direction best serves this brief?</legend><div class="choices">${order.map(({ label }) => `<label><input type="radio" name="choice" value="${label}" required>Direction ${label}</label>`).join("")}<label><input type="radio" name="choice" value="tie" required>Tie / no preference</label></div></fieldset><label class="field">Reviewer type<select name="kind" required><option value="">Choose explicitly</option><option value="human">Human judgment</option><option value="agent">Agent observation</option></select></label><label class="field">Reviewer name<input name="name" autocomplete="name" required maxlength="200"></label><label class="field">Reason and context<textarea name="rationale" required maxlength="10000" placeholder="Explain which visible relationships serve this audience, and any tradeoff or limitation."></textarea></label><button type="submit">Download preference JSON</button><p id="status" role="status" aria-live="polite"></p></form><p>Move the downloaded JSON into your project, then run <code>sable prefer --record &lt;downloaded-file.json&gt;</code>. Original evidence paths and hashes remain bound to the project that generated this comparison.</p></section></main><script id="comparison-data" type="application/json">${safeJson(payload)}</script><script>${script}</script></body></html>`
96
+ }
97
+
98
+ function main() {
99
+ const flags = parseFlags(process.argv.slice(2), ["--check", "--compare"], ["--template", "--json", "--help"])
100
+ if (["--check", "--compare", "--template"].filter((key) => flags.has(key)).length !== 1 && !flags.has("--help")) throw new Error("Choose one action: --template, --check <file.json>, or --compare <file.json>.")
101
+ if (flags.has("--help")) { process.stdout.write("sable explore — compare rendered art directions\n --template | --check <file.json> | --compare <file.json>\n --json\nRun from the project root. Render at least three directions, changing at least two declared axes including composition, density, or story. Every direction needs desktop, mobile, and interaction evidence with SHA-256 hashes. Checks establish evidence integrity and declared variety; they cannot judge visual quality. Comparison pages are local, anonymous until rationale is revealed, and export explicitly attributed preference JSON.\n"); return }
102
+ if (flags.has("--template")) { process.stdout.write(`${JSON.stringify(flags.has("--json") ? { type: "explore.template", data: directionTemplate } : directionTemplate, null, 2)}\n`); return }
103
+ const root = process.cwd()
104
+ const file = projectPath(root, flags.get("--check") ?? flags.get("--compare"))
105
+ const result = validateDirections(JSON.parse(readFileSync(file, "utf8")), { root })
106
+ if (result.errors.length) throw new Error(result.errors.join("\n"))
107
+ if (flags.has("--check")) { process.stdout.write(`${JSON.stringify({ type: "explore.check", data: { id: result.manifest.id, directions: result.manifest.directions.length, diversity: result.diversity, notice: result.notice } }, null, 2)}\n`); return }
108
+ const directory = projectPath(root, ".sable/comparisons", { existing: false })
109
+ mkdirSync(directory, { recursive: true })
110
+ const sourcePath = `.sable/comparisons/${result.manifest.id}.manifest.json`
111
+ const manifestPath = projectPath(root, sourcePath, { existing: false })
112
+ const htmlPath = projectPath(root, `.sable/comparisons/${result.manifest.id}.html`, { existing: false })
113
+ if (existsSync(manifestPath) || existsSync(htmlPath)) throw new Error(`Comparison ${result.manifest.id} already exists. Use a new id for an iteration so previous judgments retain their evidence.`)
114
+ const source = `${JSON.stringify(result.manifest, null, 2)}\n`
115
+ const html = renderComparison(result.manifest, { root, sourcePath, sourceHash: sha256(source) })
116
+ writeFileSync(manifestPath, source, { flag: "wx" })
117
+ writeFileSync(htmlPath, html, { flag: "wx" })
118
+ process.stdout.write(`${JSON.stringify({ type: "explore.comparison", data: { id: result.manifest.id, path: portablePath(root, htmlPath), manifest: sourcePath, labels: comparisonOrder(result.manifest).map(({ label }) => label), notice: "Open this local HTML file. Review evidence and export a judgment with an explicit reviewer type. Nothing is inferred or saved to preference memory until sable prefer --record is run." } }, null, 2)}\n`)
119
+ }
120
+
121
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
122
+ try { main() } catch (error) { process.stderr.write(`${JSON.stringify({ type: "error", code: "ERR_EXPLORATION_INVALID", message: error.message })}\n`); process.exitCode = 1 }
123
+ }
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ /** Offline precedent retrieval and project-owned, evidence-linked learning. */
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+
7
+ const PACKAGE = resolve(dirname(fileURLToPath(import.meta.url)), "..")
8
+ const text = (value) => typeof value === "string" && value.trim().length > 0
9
+ const words = (value) => String(value).toLowerCase().match(/[a-z0-9]+/g) ?? []
10
+ const inside = (root, file) => {
11
+ const rel = relative(root, file)
12
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
13
+ }
14
+
15
+ export function validateLesson(lesson, { root, bundled = false } = {}) {
16
+ const errors = []
17
+ if (!lesson || typeof lesson !== "object" || Array.isArray(lesson)) return ["Lesson must be an object."]
18
+ if (lesson.schemaVersion !== 1) errors.push("schemaVersion must be 1.")
19
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(lesson.id ?? "")) errors.push("id must be a lowercase hyphenated slug.")
20
+ for (const key of ["title", "principle", "appliesWhen", "avoidWhen"]) {
21
+ if (!text(lesson[key])) errors.push(`${key} must describe a concrete decision.`)
22
+ }
23
+ if (!Array.isArray(lesson.tags) || !lesson.tags.length || lesson.tags.some((tag) => !text(tag))) errors.push("tags must contain searchable terms.")
24
+ if (!["page", "workspace", "either"].includes(lesson.register)) errors.push("register must be page, workspace, or either.")
25
+ if (!["persuade", "operate", "read", "experience", "either"].includes(lesson.mode)) errors.push("mode must be persuade, operate, read, experience, or either.")
26
+ if (!text(lesson.provenance?.source) || !text(lesson.provenance?.date)) errors.push("provenance needs a source description and date.")
27
+ if (lesson.provenance?.kind !== (bundled ? "curated-precedent" : "build-review")) errors.push(`provenance.kind must be ${bundled ? "curated-precedent" : "build-review"}.`)
28
+ if (!bundled && (!text(lesson.provenance?.reviewer) || !text(lesson.provenance?.sableVersion))) errors.push("Build provenance needs reviewer and sableVersion.")
29
+ if (!bundled && (!text(lesson.change?.before) || !text(lesson.change?.after))) errors.push("change must describe the observed before state and resulting after state.")
30
+ const evidence = Array.isArray(lesson.evidence) ? lesson.evidence : []
31
+ if (!evidence.length) errors.push("evidence must contain artifact references.")
32
+ if (!bundled) {
33
+ for (const kind of ["before", "after"]) if (!evidence.some((item) => item.kind === kind)) errors.push(`Evidence needs a ${kind} artifact.`)
34
+ }
35
+ for (const [index, item] of evidence.entries()) {
36
+ if (!item || !text(item.path) || !text(item.observation)) {
37
+ errors.push(`evidence[${index}] needs path and observation.`)
38
+ continue
39
+ }
40
+ if (!(bundled ? ["source"] : ["before", "after"]).includes(item.kind)) errors.push(`evidence[${index}] has an invalid kind.`)
41
+ if (!root) continue
42
+ const file = resolve(root, item.path)
43
+ if (!inside(root, file)) errors.push(`evidence[${index}] must stay inside the project.`)
44
+ else if (!existsSync(file) || !statSync(file).isFile() || statSync(file).size === 0) errors.push(`evidence[${index}] is missing or empty: ${item.path}`)
45
+ }
46
+ if (!bundled) {
47
+ const normalize = (path) => root && text(path) ? resolve(root, path) : path
48
+ const before = new Set(evidence.filter((item) => item?.kind === "before").map((item) => normalize(item.path)))
49
+ if (evidence.some((item) => item?.kind === "after" && before.has(normalize(item.path)))) errors.push("Before and after must reference different artifacts.")
50
+ }
51
+ return errors
52
+ }
53
+
54
+ export function readLessons(root = process.cwd()) {
55
+ const catalog = JSON.parse(readFileSync(join(PACKAGE, "llms", "taste", "lessons.json"), "utf8"))
56
+ const lessons = catalog.lessons.map((lesson) => ({ ...lesson, origin: "bundled", confidence: "precedent", retrieve: `sable docs ${lesson.id}` }))
57
+ const errors = []
58
+ for (const lesson of lessons) errors.push(...validateLesson(lesson, { root: PACKAGE, bundled: true }).map((error) => `${lesson.id}: ${error}`))
59
+ const local = join(root, ".sable", "lessons")
60
+ if (existsSync(local)) {
61
+ for (const name of readdirSync(local).filter((name) => name.endsWith(".json")).sort()) {
62
+ try {
63
+ const lesson = JSON.parse(readFileSync(join(local, name), "utf8"))
64
+ const failures = validateLesson(lesson, { root })
65
+ if (failures.length) errors.push(...failures.map((error) => `${name}: ${error}`))
66
+ else lessons.push({ ...lesson, origin: "project", confidence: "provisional" })
67
+ } catch (error) { errors.push(`${name}: ${error.message}`) }
68
+ }
69
+ }
70
+ const ids = lessons.map((lesson) => lesson.id)
71
+ if (new Set(ids).size !== ids.length) errors.push("Lesson ids must be unique across bundled and project memory.")
72
+ return { lessons, errors }
73
+ }
74
+
75
+ export function searchLessons(lessons, query = "") {
76
+ const terms = [...new Set(words(query))]
77
+ if (!terms.length) return lessons
78
+ return lessons.map((lesson) => {
79
+ const tags = new Set(words([lesson.id, lesson.title, ...lesson.tags, lesson.register, lesson.mode].join(" ")))
80
+ const body = new Set(words(`${lesson.appliesWhen} ${lesson.avoidWhen} ${lesson.principle}`))
81
+ const score = terms.reduce((sum, term) => sum + (tags.has(term) ? 4 : body.has(term) ? 1 : 0), 0)
82
+ return { ...lesson, relevance: score }
83
+ }).filter((lesson) => lesson.relevance > 0).sort((a, b) => b.relevance - a.relevance || a.id.localeCompare(b.id))
84
+ }
85
+
86
+ const template = {
87
+ schemaVersion: 1,
88
+ id: "replace-with-specific-lesson",
89
+ title: "",
90
+ tags: [],
91
+ register: "page",
92
+ mode: "operate",
93
+ principle: "",
94
+ appliesWhen: "",
95
+ avoidWhen: "",
96
+ change: { before: "", after: "" },
97
+ provenance: { kind: "build-review", source: "", date: "", reviewer: "", sableVersion: "" },
98
+ evidence: [{ kind: "before", path: "", observation: "" }, { kind: "after", path: "", observation: "" }],
99
+ }
100
+
101
+ function main() {
102
+ const args = process.argv.slice(2)
103
+ const flags = new Map()
104
+ const values = new Set(["--search", "--show", "--record"])
105
+ const switches = new Set(["--list", "--template", "--check", "--json", "--help"])
106
+ const fail = (code, message) => {
107
+ process.stderr.write(`${JSON.stringify({ type: "error", code, message })}\n`)
108
+ process.exitCode = 1
109
+ }
110
+ for (let index = 0; index < args.length; index++) {
111
+ const arg = args[index]
112
+ if (values.has(arg)) {
113
+ if (!args[index + 1] || args[index + 1].startsWith("--")) return fail("ERR_MISSING_ARGUMENT", `${arg} needs a value.`)
114
+ flags.set(arg, args[++index])
115
+ } else if (switches.has(arg)) flags.set(arg, true)
116
+ else return fail("ERR_BAD_FLAG", `Unknown flag: ${arg}`)
117
+ }
118
+ const actions = ["--list", "--search", "--show", "--record", "--template", "--check"].filter((flag) => flags.has(flag))
119
+ if (actions.length > 1) return fail("ERR_BAD_FLAG", "Choose one of --list, --search, --show, --record, --template, --check.")
120
+ if (flags.has("--help")) {
121
+ process.stdout.write("sable learn — retrieve precedents and record project-owned lessons\n\n --list | --search <words> | --show <id> | --template | --record <lesson.json> | --check\n --json structured { type, data } output\n\nRun from the project root. Evidence paths are relative to that root. Records are provisional reviewer observations, never automatic proof of taste.\n")
122
+ return
123
+ }
124
+ if (flags.has("--template")) {
125
+ process.stdout.write(`${JSON.stringify(template, null, 2)}\n`)
126
+ return
127
+ }
128
+ const root = process.cwd()
129
+ const memory = readLessons(root)
130
+ if (memory.errors.length) return fail("ERR_LESSON_INVALID", memory.errors.join("\n"))
131
+ if (flags.has("--record")) {
132
+ const lesson = JSON.parse(readFileSync(resolve(root, flags.get("--record")), "utf8"))
133
+ const errors = validateLesson(lesson, { root })
134
+ if (errors.length) return fail("ERR_LESSON_INVALID", errors.join("\n"))
135
+ if (memory.lessons.some((existing) => existing.id === lesson.id)) return fail("ERR_LESSON_EXISTS", `The id ${lesson.id} already exists. Reuse its lesson, or record a distinct decision under a new id.`)
136
+ const directory = join(root, ".sable", "lessons")
137
+ mkdirSync(directory, { recursive: true })
138
+ const path = join(directory, `${lesson.id}.json`)
139
+ const recorded = { ...lesson, confidence: "provisional", evidence: lesson.evidence.map((item) => ({ ...item, path: relative(root, resolve(root, item.path)).split(sep).join("/") })) }
140
+ writeFileSync(path, `${JSON.stringify(recorded, null, 2)}\n`, { flag: "wx" })
141
+ process.stdout.write(`${JSON.stringify({ type: "learn.recorded", data: { id: lesson.id, path: relative(root, path), confidence: "provisional" } }, null, 2)}\n`)
142
+ return
143
+ }
144
+ let lessons = searchLessons(memory.lessons, flags.get("--search"))
145
+ if (flags.has("--show")) {
146
+ lessons = lessons.filter((lesson) => lesson.id === flags.get("--show"))
147
+ if (!lessons.length) return fail("ERR_UNKNOWN_LESSON", `No lesson ${flags.get("--show")}. Use sable learn --list.`)
148
+ }
149
+ const type = flags.has("--check") ? "learn.check" : flags.has("--show") ? "learn.detail" : "learn.list"
150
+ const data = { count: lessons.length, lessons, notice: "Precedents guide decisions; they are conditional and do not replace visual review. Local records remain provisional." }
151
+ if (flags.has("--json") || flags.has("--check")) process.stdout.write(`${JSON.stringify({ type, data }, null, 2)}\n`)
152
+ else {
153
+ for (const lesson of lessons) {
154
+ process.stdout.write(`${lesson.id} — ${lesson.title} [${lesson.origin}; ${lesson.confidence}]\n Apply: ${lesson.appliesWhen}\n Avoid: ${lesson.avoidWhen}\n`)
155
+ if (flags.has("--show")) process.stdout.write(` Principle: ${lesson.principle}\n Evidence: ${JSON.stringify(lesson.evidence)}\n`)
156
+ }
157
+ process.stdout.write(`${lessons.length} lesson(s). ${data.notice}\n`)
158
+ }
159
+ }
160
+
161
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
162
+ try { main() } catch (error) {
163
+ process.stderr.write(`${JSON.stringify({ type: "error", code: "ERR_LESSON_IO", message: error.message })}\n`)
164
+ process.exitCode = 1
165
+ }
166
+ }