@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.
- package/AGENTS.md +157 -0
- package/CHANGELOG.md +55 -0
- package/CONTRIBUTING.md +221 -0
- package/DESIGN.md +548 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/THIRD_PARTY_NOTICES.md +44 -0
- package/bin/sable-lint.mjs +0 -0
- package/bin/sable.mjs +1223 -0
- package/dist/_contract-CYScOdf5.d.ts +150 -0
- package/dist/chunk-3IWZYBGH.js +398 -0
- package/dist/chunk-5Z5KLOAU.js +167 -0
- package/dist/chunk-7MG4SHYX.js +15 -0
- package/dist/chunk-ADLLDWLW.js +15 -0
- package/dist/chunk-EP55KSYA.js +14 -0
- package/dist/chunk-J23ELQQZ.js +9396 -0
- package/dist/chunk-JI7WJP2L.js +286 -0
- package/dist/chunk-K7RUHUGC.js +14 -0
- package/dist/chunk-KO3NAEBF.js +15 -0
- package/dist/chunk-L4IEIQQA.js +14 -0
- package/dist/chunk-L6Y46C62.js +270 -0
- package/dist/chunk-NEQNXI2G.js +25 -0
- package/dist/chunk-PAOZBZME.js +40 -0
- package/dist/chunk-RWP4XBSK.js +139 -0
- package/dist/chunk-SRQWQUTW.js +63 -0
- package/dist/chunk-YXBOYWBN.js +15 -0
- package/dist/foundations/index.d.ts +88 -0
- package/dist/foundations/index.js +792 -0
- package/dist/icon/index.d.ts +60 -0
- package/dist/icon/index.js +2 -0
- package/dist/icon-sets/brands.d.ts +44 -0
- package/dist/icon-sets/brands.js +147 -0
- package/dist/icon-sets/reicon.d.ts +8 -0
- package/dist/icon-sets/reicon.js +1 -0
- package/dist/index.d.ts +7013 -0
- package/dist/index.js +15462 -0
- package/dist/ios/chrome.css +83 -0
- package/dist/ios/frame.css +5 -0
- package/dist/ios/index.d.ts +70 -0
- package/dist/ios/index.js +313 -0
- package/dist/ios/preview.d.ts +64 -0
- package/dist/ios/preview.js +133 -0
- package/dist/ios/styles.css +77 -0
- package/dist/layout/index.d.ts +280 -0
- package/dist/layout/index.js +2 -0
- package/dist/meta-6dUzgC6M.d.ts +205 -0
- package/dist/patterns/index.d.ts +36 -0
- package/dist/patterns/index.js +934 -0
- package/dist/presets/index.d.ts +37 -0
- package/dist/presets/index.js +117 -0
- package/dist/registry-CyL28OzJ.d.ts +28 -0
- package/dist/styles.css +1893 -0
- package/dist/theme-provider-BWcizhAt.d.ts +105 -0
- package/dist/themes/atelier/atelier.css +213 -0
- package/dist/themes/atelier/index.d.ts +9 -0
- package/dist/themes/atelier/index.js +1 -0
- package/dist/themes/base/base.css +443 -0
- package/dist/themes/base/index.d.ts +9 -0
- package/dist/themes/base/index.js +1 -0
- package/dist/themes/ember/ember.css +270 -0
- package/dist/themes/ember/index.d.ts +9 -0
- package/dist/themes/ember/index.js +1 -0
- package/dist/themes/index.d.ts +10 -0
- package/dist/themes/index.js +8 -0
- package/dist/themes/meridian/index.d.ts +9 -0
- package/dist/themes/meridian/index.js +1 -0
- package/dist/themes/meridian/meridian.css +255 -0
- package/dist/themes/poppy/index.d.ts +6 -0
- package/dist/themes/poppy/index.js +1 -0
- package/dist/themes/poppy/poppy.css +174 -0
- package/dist/themes/prism/index.d.ts +6 -0
- package/dist/themes/prism/index.js +1 -0
- package/dist/themes/prism/prism.css +183 -0
- package/dist/themes/verdant/index.d.ts +9 -0
- package/dist/themes/verdant/index.js +1 -0
- package/dist/themes/verdant/verdant.css +210 -0
- package/dist/themes.css +30 -0
- package/llms/components/accordion.md +93 -0
- package/llms/components/app-header.md +70 -0
- package/llms/components/app-shell.md +117 -0
- package/llms/components/attachment.md +105 -0
- package/llms/components/avatar.md +141 -0
- package/llms/components/badge.md +117 -0
- package/llms/components/banner.md +119 -0
- package/llms/components/bottom-nav.md +80 -0
- package/llms/components/breadcrumbs.md +75 -0
- package/llms/components/browser.md +67 -0
- package/llms/components/bubble.md +119 -0
- package/llms/components/button.md +199 -0
- package/llms/components/calendar.md +75 -0
- package/llms/components/card.md +135 -0
- package/llms/components/carousel.md +87 -0
- package/llms/components/chat-composer.md +126 -0
- package/llms/components/checkbox-card.md +98 -0
- package/llms/components/checkbox.md +90 -0
- package/llms/components/code-block.md +81 -0
- package/llms/components/code.md +62 -0
- package/llms/components/collapsible.md +83 -0
- package/llms/components/color-field.md +100 -0
- package/llms/components/combobox.md +119 -0
- package/llms/components/command-menu.md +158 -0
- package/llms/components/context-card.md +73 -0
- package/llms/components/context-menu.md +79 -0
- package/llms/components/data-table.md +99 -0
- package/llms/components/date-picker.md +97 -0
- package/llms/components/description.md +96 -0
- package/llms/components/destructive-action-modal.md +76 -0
- package/llms/components/dialog.md +127 -0
- package/llms/components/dots-menu.md +64 -0
- package/llms/components/empty-state.md +113 -0
- package/llms/components/entity.md +67 -0
- package/llms/components/feedback.md +63 -0
- package/llms/components/fieldset.md +106 -0
- package/llms/components/file-dropzone.md +103 -0
- package/llms/components/form.md +111 -0
- package/llms/components/gliding-highlight.md +84 -0
- package/llms/components/inline-status.md +62 -0
- package/llms/components/input.md +127 -0
- package/llms/components/kbd.md +57 -0
- package/llms/components/label.md +73 -0
- package/llms/components/marker.md +99 -0
- package/llms/components/marquee.md +66 -0
- package/llms/components/menu.md +122 -0
- package/llms/components/message-scroller.md +110 -0
- package/llms/components/message.md +114 -0
- package/llms/components/meter.md +124 -0
- package/llms/components/middle-truncate.md +62 -0
- package/llms/components/note.md +81 -0
- package/llms/components/notification-badge.md +67 -0
- package/llms/components/number-field.md +70 -0
- package/llms/components/otp-field.md +101 -0
- package/llms/components/page-header.md +76 -0
- package/llms/components/page-loader.md +67 -0
- package/llms/components/pagination.md +92 -0
- package/llms/components/panel-group.md +120 -0
- package/llms/components/panel-section.md +122 -0
- package/llms/components/phone.md +70 -0
- package/llms/components/pill.md +73 -0
- package/llms/components/popover.md +79 -0
- package/llms/components/progress.md +98 -0
- package/llms/components/radio-card.md +90 -0
- package/llms/components/radio.md +86 -0
- package/llms/components/relative-time.md +60 -0
- package/llms/components/scroller.md +88 -0
- package/llms/components/scrub-field.md +97 -0
- package/llms/components/search-input.md +84 -0
- package/llms/components/select.md +106 -0
- package/llms/components/separator.md +70 -0
- package/llms/components/settings-menu.md +139 -0
- package/llms/components/sheet.md +97 -0
- package/llms/components/show-more.md +59 -0
- package/llms/components/side-nav.md +106 -0
- package/llms/components/sidebar.md +87 -0
- package/llms/components/skeleton.md +62 -0
- package/llms/components/slider.md +114 -0
- package/llms/components/spinner.md +87 -0
- package/llms/components/status-bar.md +91 -0
- package/llms/components/status-dot.md +70 -0
- package/llms/components/stepper.md +125 -0
- package/llms/components/streaming-text.md +86 -0
- package/llms/components/switch.md +87 -0
- package/llms/components/table.md +112 -0
- package/llms/components/tabs.md +84 -0
- package/llms/components/textarea.md +88 -0
- package/llms/components/theme-selector.md +95 -0
- package/llms/components/theme-switcher.md +71 -0
- package/llms/components/thinking-orb.md +91 -0
- package/llms/components/toast.md +130 -0
- package/llms/components/toggle.md +95 -0
- package/llms/components/toolbar.md +168 -0
- package/llms/components/tooltip.md +93 -0
- package/llms/components/tree.md +227 -0
- package/llms/components/video.md +66 -0
- package/llms/components/workspace.md +138 -0
- package/llms/foundations/accessibility.md +60 -0
- package/llms/foundations/motion.md +96 -0
- package/llms/foundations/scrolling.md +41 -0
- package/llms/foundations/shape.md +73 -0
- package/llms/foundations/spacing.md +82 -0
- package/llms/foundations/surfaces.md +69 -0
- package/llms/foundations/typography.md +120 -0
- package/llms/layout/aspect-box.md +37 -0
- package/llms/layout/bleed.md +31 -0
- package/llms/layout/center.md +37 -0
- package/llms/layout/columns.md +51 -0
- package/llms/layout/grid-overlay.md +40 -0
- package/llms/layout/grid.md +47 -0
- package/llms/layout/page.md +39 -0
- package/llms/layout/region.md +49 -0
- package/llms/layout/row.md +40 -0
- package/llms/layout/safe-area.md +37 -0
- package/llms/layout/spacer.md +39 -0
- package/llms/layout/split-pane.md +41 -0
- package/llms/layout/stack.md +39 -0
- package/llms/patterns/auth-page.md +68 -0
- package/llms/patterns/brand-landing-page.md +423 -0
- package/llms/patterns/chat-panel.md +210 -0
- package/llms/patterns/dashboard-page.md +170 -0
- package/llms/patterns/feed-page.md +103 -0
- package/llms/patterns/landing-page.md +122 -0
- package/llms/patterns/list-detail-page.md +100 -0
- package/llms/patterns/settings-page.md +121 -0
- package/llms/reference/token-intent.md +324 -0
- package/llms/reference/utilities.md +129 -0
- package/llms/tasks/adding-a-component.md +113 -0
- package/llms/tasks/auditing-an-app.md +216 -0
- package/llms/tasks/building-a-page.md +109 -0
- package/llms/tasks/building-a-tool.md +141 -0
- package/llms/tasks/building-an-ios-prototype.md +105 -0
- package/llms/tasks/building-with-sable.md +73 -0
- package/llms/tasks/choosing-a-component.md +109 -0
- package/llms/tasks/converting-an-app.md +139 -0
- package/llms/tasks/creating-a-preset.md +43 -0
- package/llms/tasks/creating-design-assets.md +96 -0
- package/llms/tasks/designing-a-surface.md +115 -0
- package/llms/tasks/developing-taste.md +157 -0
- package/llms/tasks/getting-started.md +63 -0
- package/llms/tasks/learning-from-builds.md +74 -0
- package/llms/tasks/reviewing-a-design.md +114 -0
- package/llms/tasks/reviewing-a-journey.md +84 -0
- package/llms/tasks/reviewing-motion.md +92 -0
- package/llms/tasks/using-presets.md +87 -0
- package/llms/taste/asset-plan.template.json +53 -0
- package/llms/taste/composition-follows-content.md +20 -0
- package/llms/taste/hierarchy-is-a-relationship.md +22 -0
- package/llms/taste/lessons.json +57 -0
- package/llms/taste/motion-needs-a-reason.md +21 -0
- package/llms/taste/surface-states-travel-together.md +20 -0
- package/llms-full.txt +14906 -0
- package/llms.txt +405 -0
- package/package.json +185 -3
- package/registry.json +18812 -0
- package/scripts/agent-setup.mjs +54 -0
- package/scripts/design-atlas.mjs +221 -0
- package/scripts/design-directions.mjs +123 -0
- package/scripts/design-memory.mjs +166 -0
- package/scripts/design-preferences.mjs +227 -0
- package/scripts/design-presets.mjs +48 -0
- package/scripts/grade.mjs +509 -0
- package/scripts/lint-rules.mjs +1065 -0
- package/scripts/review-evidence.mjs +120 -0
- package/scripts/skeleton.mjs +248 -0
- package/skills/entry/SKILL.md +34 -0
- package/skills/sable/SKILL.md +187 -0
- package/skills/sable/agents/openai.yaml +6 -0
- package/targets/ios.json +982 -0
- package/third-party/reicon-LICENSE.txt +20 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Contextual judgments, deliberately separate from causal design lessons. */
|
|
3
|
+
import { createHash } from "node:crypto"
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs"
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
export const hasText = (value) => typeof value === "string" && value.trim().length > 0
|
|
9
|
+
export const PREFERENCE_DIMENSIONS = ["composition", "typography", "density", "imagery", "story", "motion", "color", "spacing", "interaction"]
|
|
10
|
+
export const sha256 = (value) => createHash("sha256").update(value).digest("hex")
|
|
11
|
+
export const validId = (value) => typeof value === "string" && value.length <= 80 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value) && !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(value)
|
|
12
|
+
const inside = (root, path) => { const rel = relative(root, path); return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) }
|
|
13
|
+
export const portablePath = (root, path) => relative(resolve(root), resolve(root, path)).split(sep).join("/")
|
|
14
|
+
|
|
15
|
+
/** Check real ancestors as well as lexical paths, including not-yet-created output directories. */
|
|
16
|
+
export function projectPath(root, path, { existing = true } = {}) {
|
|
17
|
+
if (!hasText(path) || isAbsolute(path) || win32.isAbsolute(path) || path.includes("\\") || /^[a-z][a-z0-9+.-]*:/i.test(path)) throw new Error("Paths must be relative to the project, using forward slashes.")
|
|
18
|
+
const base = realpathSync(root)
|
|
19
|
+
const target = resolve(root, path)
|
|
20
|
+
if (!inside(resolve(root), target)) throw new Error("Paths must stay inside the project.")
|
|
21
|
+
let ancestor = target
|
|
22
|
+
while (!existsSync(ancestor) && ancestor !== dirname(ancestor)) ancestor = dirname(ancestor)
|
|
23
|
+
if (!inside(base, realpathSync(ancestor))) throw new Error("A symlink resolves outside the project.")
|
|
24
|
+
if (existing && (!existsSync(target) || !statSync(target).isFile() || statSync(target).size === 0)) throw new Error(`Artifact is missing or empty: ${path}`)
|
|
25
|
+
return target
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function validateScope(scope) {
|
|
29
|
+
const errors = []
|
|
30
|
+
if (!["personal", "project", "brand", "task-type"].includes(scope?.kind)) errors.push("scope.kind must be personal, project, brand, or task-type.")
|
|
31
|
+
if (!hasText(scope?.value)) errors.push("scope.value must identify the person, project, brand, or task type; no universal preference is inferred.")
|
|
32
|
+
return errors
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function artifactMime(buffer) {
|
|
36
|
+
if (buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png"
|
|
37
|
+
if (buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255) return "image/jpeg"
|
|
38
|
+
if (["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString())) return "image/gif"
|
|
39
|
+
if (buffer.subarray(0, 4).toString() === "RIFF" && buffer.subarray(8, 12).toString() === "WEBP") return "image/webp"
|
|
40
|
+
if (buffer.subarray(4, 8).toString() === "ftyp") return "video/mp4"
|
|
41
|
+
if (buffer.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]))) return "video/webm"
|
|
42
|
+
throw new Error("Evidence must be a rendered PNG, JPEG, GIF, WebP, MP4, or WebM artifact; SVG, HTML, and prose are not rendered evidence.")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function inspectArtifacts(artifacts, { root, requireHashes = true, seen = new Set(), label = "artifacts" } = {}) {
|
|
46
|
+
const errors = []
|
|
47
|
+
const sealed = []
|
|
48
|
+
if (!Array.isArray(artifacts) || !artifacts.length) return { errors: [`${label} must include rendered evidence.`], artifacts: sealed }
|
|
49
|
+
for (const [index, item] of artifacts.entries()) {
|
|
50
|
+
const key = `${label}[${index}]`
|
|
51
|
+
if (!item || typeof item !== "object") { errors.push(`${key} must be an artifact object.`); continue }
|
|
52
|
+
if (!["desktop", "mobile", "interaction"].includes(item.kind)) errors.push(`${key}.kind must be desktop, mobile, or interaction.`)
|
|
53
|
+
if (!hasText(item.description)) errors.push(`${key}.description must identify the observed view or interaction state.`)
|
|
54
|
+
if (requireHashes && !/^[a-f0-9]{64}$/.test(item.sha256 ?? "")) errors.push(`${key}.sha256 must pin the captured bytes.`)
|
|
55
|
+
try {
|
|
56
|
+
const path = projectPath(root, item.path)
|
|
57
|
+
if (statSync(path).size > 25 * 1024 * 1024) throw new Error("Each artifact must be at most 25 MiB.")
|
|
58
|
+
const bytes = readFileSync(path)
|
|
59
|
+
artifactMime(bytes)
|
|
60
|
+
const hash = sha256(bytes)
|
|
61
|
+
if (item.sha256 && item.sha256 !== hash) errors.push(`${key}: evidence changed since capture (SHA-256 mismatch).`)
|
|
62
|
+
if (seen.has(hash)) errors.push(`${key}: comparisons need different rendered artifacts; duplicate bytes or an alias of the same physical artifact cannot prove an alternative.`)
|
|
63
|
+
seen.add(hash)
|
|
64
|
+
sealed.push({ kind: item.kind, path: portablePath(root, path), sha256: hash, description: item.description })
|
|
65
|
+
} catch (error) { errors.push(`${key}: ${error.message}`) }
|
|
66
|
+
}
|
|
67
|
+
return { errors, artifacts: sealed }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const preferenceTemplate = {
|
|
71
|
+
schemaVersion: 1, id: "replace-with-specific-judgment",
|
|
72
|
+
scope: { kind: "project", value: "" }, brief: "",
|
|
73
|
+
options: ["direction-a", "direction-b"].map((id) => ({ id, artifacts: [{ kind: "desktop", path: "", description: "" }] })),
|
|
74
|
+
choice: "", reviewer: { kind: "", name: "" }, rationale: "", date: "",
|
|
75
|
+
source: { kind: "manual", reference: "" },
|
|
76
|
+
dimensions: [], regions: [],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function validatePreference(record, { root = process.cwd(), requireHashes = true } = {}) {
|
|
80
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) return { errors: ["Preference must be an object."], record: null }
|
|
81
|
+
const errors = []
|
|
82
|
+
if (record.schemaVersion !== 1) errors.push("schemaVersion must be 1.")
|
|
83
|
+
if (!validId(record.id)) errors.push("id must be a lowercase hyphenated slug of at most 80 characters.")
|
|
84
|
+
errors.push(...validateScope(record.scope))
|
|
85
|
+
for (const key of ["brief", "rationale"]) if (!hasText(record[key])) errors.push(`${key} must describe the specific comparison and reason.`)
|
|
86
|
+
if (!["human", "agent"].includes(record.reviewer?.kind) || !hasText(record.reviewer?.name)) errors.push("reviewer must explicitly declare kind human or agent and a name. Do not infer a human preference from an agent decision.")
|
|
87
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(record.date ?? "") || Number.isNaN(Date.parse(record.date)) || new Date(record.date).toISOString().slice(0, 10) !== record.date) errors.push("date must be a real YYYY-MM-DD date.")
|
|
88
|
+
if (!["manual", "comparison"].includes(record.source?.kind) || !hasText(record.source?.reference)) errors.push("source needs kind manual or comparison and a reference to the originating review.")
|
|
89
|
+
if (record.source?.kind === "comparison") {
|
|
90
|
+
try {
|
|
91
|
+
const source = projectPath(root, record.source.reference)
|
|
92
|
+
const bytes = readFileSync(source)
|
|
93
|
+
if (!/^[a-f0-9]{64}$/.test(record.source.sha256 ?? "") || sha256(bytes) !== record.source.sha256) errors.push("Comparison source is missing its captured SHA-256 or has changed.")
|
|
94
|
+
const manifest = JSON.parse(bytes.toString("utf8"))
|
|
95
|
+
if (record.brief !== manifest.brief?.prompt || record.scope?.kind !== manifest.scope?.kind || record.scope?.value !== manifest.scope?.value) errors.push("Preference brief and scope must match the source comparison.")
|
|
96
|
+
const expected = new Map((manifest.directions ?? []).map((direction) => [direction.id, direction.artifacts]))
|
|
97
|
+
const canonicalArtifacts = (items) => Array.isArray(items) ? items.map((item) => ({ kind: item?.kind, path: item?.path, sha256: item?.sha256, description: item?.description })) : null
|
|
98
|
+
if (!Array.isArray(record.options) || record.options.length !== expected.size || record.options.some((option) => JSON.stringify(canonicalArtifacts(option?.artifacts)) !== JSON.stringify(canonicalArtifacts(expected.get(option?.id))))) errors.push("Preference alternatives must match the captured source comparison; regenerate a comparison when evidence changes.")
|
|
99
|
+
} catch (error) { errors.push(`Comparison source: ${error.message}`) }
|
|
100
|
+
}
|
|
101
|
+
const options = Array.isArray(record.options) ? record.options : []
|
|
102
|
+
if (options.length < 2 || options.length > 12) errors.push("options must contain between 2 and 12 rendered alternatives.")
|
|
103
|
+
const ids = options.map((option) => option?.id)
|
|
104
|
+
if (ids.some((id) => !validId(id) || id === "tie") || new Set(ids).size !== ids.length) errors.push("Option ids must be unique slugs and cannot be tie.")
|
|
105
|
+
if (record.choice !== "tie" && !ids.includes(record.choice)) errors.push("choice must name an option id or tie.")
|
|
106
|
+
const seen = new Set()
|
|
107
|
+
const sealedOptions = options.map((option, index) => {
|
|
108
|
+
const result = inspectArtifacts(option?.artifacts, { root, requireHashes, seen, label: `options[${index}].artifacts` })
|
|
109
|
+
errors.push(...result.errors)
|
|
110
|
+
return { id: option?.id, artifacts: result.artifacts }
|
|
111
|
+
})
|
|
112
|
+
const dimensions = []
|
|
113
|
+
if (record.dimensions !== undefined) {
|
|
114
|
+
if (!Array.isArray(record.dimensions) || record.dimensions.length > PREFERENCE_DIMENSIONS.length) errors.push("dimensions must be an array of at most nine specific judgments.")
|
|
115
|
+
else for (const [index, item] of record.dimensions.entries()) {
|
|
116
|
+
if (!PREFERENCE_DIMENSIONS.includes(item?.dimension) || dimensions.some((existing) => existing.dimension === item.dimension)) errors.push(`dimensions[${index}].dimension must name a supported, unique dimension.`)
|
|
117
|
+
if (item?.choice !== "tie" && !ids.includes(item?.choice)) errors.push(`dimensions[${index}].choice must name a rendered option or tie.`)
|
|
118
|
+
if (!hasText(item?.rationale) || item.rationale.length > 5000) errors.push(`dimensions[${index}].rationale must state the reviewer's reason in at most 5000 characters.`)
|
|
119
|
+
dimensions.push({ dimension: item?.dimension, choice: item?.choice, rationale: item?.rationale })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const regions = []
|
|
123
|
+
if (record.regions !== undefined) {
|
|
124
|
+
if (!Array.isArray(record.regions) || record.regions.length > 50) errors.push("regions must be an array of at most 50 evidence-bound notes.")
|
|
125
|
+
else for (const [index, item] of record.regions.entries()) {
|
|
126
|
+
if (!validId(item?.id) || regions.some((existing) => existing.id === item.id)) errors.push(`regions[${index}].id must be a unique slug.`)
|
|
127
|
+
const option = sealedOptions.find((option) => option.id === item?.optionId)
|
|
128
|
+
const artifact = option?.artifacts.find((artifact) => artifact.path === item?.artifact?.path && artifact.sha256 === item?.artifact?.sha256)
|
|
129
|
+
if (!artifact) errors.push(`regions[${index}].artifact must match a captured path and SHA-256 belonging to its optionId.`)
|
|
130
|
+
else if (!artifactMime(readFileSync(projectPath(root, artifact.path))).startsWith("image/")) errors.push(`regions[${index}]: region notes require a still image; a video frame is not pinned by a rectangle alone.`)
|
|
131
|
+
const rect = item?.rect
|
|
132
|
+
if (!rect || !["x", "y", "width", "height"].every((key) => Number.isFinite(rect[key])) || rect.x < 0 || rect.y < 0 || rect.width <= 0 || rect.height <= 0 || rect.x + rect.width > 1 || rect.y + rect.height > 1) errors.push(`regions[${index}].rect must be a positive rectangle within normalized image coordinates 0..1.`)
|
|
133
|
+
if (!hasText(item?.note) || item.note.length > 3000) errors.push(`regions[${index}].note must describe the observed image region in at most 3000 characters.`)
|
|
134
|
+
regions.push({ id: item?.id, optionId: item?.optionId, artifact: item?.artifact, rect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null, note: item?.note })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { errors, record: { schemaVersion: 1, id: record.id, scope: record.scope, brief: record.brief, options: sealedOptions, choice: record.choice, reviewer: record.reviewer, rationale: record.rationale, date: record.date, source: record.source, ...(record.dimensions !== undefined ? { dimensions } : {}), ...(record.regions !== undefined ? { regions } : {}), confidence: "contextual-judgment", attribution: record.reviewer?.kind === "human" ? "self-reported-human" : "agent-observation" } }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Shared exclusive writer: the CLI and local studio preserve the same immutable record contract. */
|
|
141
|
+
export function recordPreference(input, { root = process.cwd(), requireHashes = false } = {}) {
|
|
142
|
+
const memory = readPreferences(root)
|
|
143
|
+
if (memory.errors.length) throw new Error(memory.errors.join("\n"))
|
|
144
|
+
const result = validatePreference(input, { root, requireHashes })
|
|
145
|
+
if (result.errors.length) throw new Error(result.errors.join("\n"))
|
|
146
|
+
if (memory.records.some((record) => record.id === result.record.id)) throw new Error(`Preference ${result.record.id} already exists; records are not overwritten.`)
|
|
147
|
+
const directory = projectPath(root, ".sable/preferences", { existing: false })
|
|
148
|
+
mkdirSync(directory, { recursive: true })
|
|
149
|
+
const path = projectPath(root, `.sable/preferences/${result.record.id}.json`, { existing: false })
|
|
150
|
+
writeFileSync(path, `${JSON.stringify(result.record, null, 2)}\n`, { flag: "wx" })
|
|
151
|
+
return { record: result.record, path: portablePath(root, path) }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function readPreferences(root = process.cwd()) {
|
|
155
|
+
const records = [], errors = []
|
|
156
|
+
let directory
|
|
157
|
+
try { directory = projectPath(root, ".sable/preferences", { existing: false }) } catch (error) { return { records, errors: [error.message] } }
|
|
158
|
+
if (!existsSync(directory)) return { records, errors }
|
|
159
|
+
for (const file of readdirSync(directory).filter((name) => name.endsWith(".json")).sort()) {
|
|
160
|
+
try {
|
|
161
|
+
const path = projectPath(root, `.sable/preferences/${file}`)
|
|
162
|
+
const result = validatePreference(JSON.parse(readFileSync(path, "utf8")), { root })
|
|
163
|
+
errors.push(...result.errors.map((error) => `${file}: ${error}`))
|
|
164
|
+
if (!result.errors.length) {
|
|
165
|
+
if (file !== `${result.record.id}.json`) { errors.push(`${file}: filename must match the immutable record id.`); continue }
|
|
166
|
+
records.push(result.record)
|
|
167
|
+
}
|
|
168
|
+
} catch (error) { errors.push(`${file}: ${error.message}`) }
|
|
169
|
+
}
|
|
170
|
+
if (new Set(records.map((record) => record.id)).size !== records.length) errors.push("Preference ids must be unique.")
|
|
171
|
+
return { records, errors }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function searchPreferences(records, query = "", { scope, reviewer = "human" } = {}) {
|
|
175
|
+
const terms = [...new Set(String(query).toLowerCase().match(/[a-z0-9]+/g) ?? [])]
|
|
176
|
+
return records.filter((record) => (reviewer === "all" || record.reviewer.kind === reviewer) && (!scope || (record.scope.kind === scope.kind && record.scope.value.toLowerCase() === scope.value.toLowerCase())))
|
|
177
|
+
.map((record) => ({ ...record, relevance: terms.reduce((score, term) => score + Number(`${record.brief} ${record.rationale} ${record.scope.value} ${(record.dimensions ?? []).map((item) => `${item.dimension} ${item.rationale}`).join(" ")} ${(record.regions ?? []).map((item) => item.note).join(" ")}`.toLowerCase().includes(term)), 0) }))
|
|
178
|
+
.filter((record) => !terms.length || record.relevance > 0).sort((a, b) => b.relevance - a.relevance || b.date.localeCompare(a.date) || a.id.localeCompare(b.id))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function parseFlags(args, values, switches) {
|
|
182
|
+
const flags = new Map()
|
|
183
|
+
for (let index = 0; index < args.length; index++) {
|
|
184
|
+
const arg = args[index]
|
|
185
|
+
if (flags.has(arg)) throw new Error(`Duplicate flag: ${arg}`)
|
|
186
|
+
if (values.includes(arg)) {
|
|
187
|
+
if (!args[index + 1] || args[index + 1].startsWith("--")) throw new Error(`${arg} needs a value.`)
|
|
188
|
+
flags.set(arg, args[++index])
|
|
189
|
+
} else if (switches.includes(arg)) flags.set(arg, true)
|
|
190
|
+
else throw new Error(`Unknown flag: ${arg}`)
|
|
191
|
+
}
|
|
192
|
+
return flags
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function main() {
|
|
196
|
+
const flags = parseFlags(process.argv.slice(2), ["--record", "--search", "--scope", "--reviewer"], ["--template", "--list", "--check", "--json", "--help"])
|
|
197
|
+
if (["--record", "--search", "--template", "--list", "--check"].filter((key) => flags.has(key)).length > 1) throw new Error("Choose one action: --record, --search, --template, --list, or --check.")
|
|
198
|
+
if (flags.has("--help")) { process.stdout.write("sable prefer — contextual preference memory, separate from design lessons\n --template | --record <file.json> | --list | --search <words> | --check\n --scope <kind:value> exact contextual filter, required for search\n --reviewer human|agent|all default human; agent observations are never human preferences\n --json\nRun from the project root. Paths stay inside it. Recording pins evidence hashes; later reads detect changes. Attribution is declared by the reviewer, not authenticated by this offline tool. List is an inventory, not permission to apply preferences to other contexts.\n"); return }
|
|
199
|
+
if (flags.has("--template")) { process.stdout.write(`${JSON.stringify(flags.has("--json") ? { type: "prefer.template", data: preferenceTemplate } : preferenceTemplate, null, 2)}\n`); return }
|
|
200
|
+
const root = process.cwd()
|
|
201
|
+
const memory = readPreferences(root)
|
|
202
|
+
if (memory.errors.length) throw new Error(memory.errors.join("\n"))
|
|
203
|
+
if (flags.has("--record")) {
|
|
204
|
+
const input = projectPath(root, flags.get("--record"))
|
|
205
|
+
const result = recordPreference(JSON.parse(readFileSync(input, "utf8")), { root })
|
|
206
|
+
process.stdout.write(`${JSON.stringify({ type: "prefer.recorded", data: { id: result.record.id, path: result.path, attribution: result.record.attribution, scope: result.record.scope } }, null, 2)}\n`)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
let scope
|
|
210
|
+
if (flags.has("--scope")) {
|
|
211
|
+
const raw = flags.get("--scope"), split = raw.indexOf(":")
|
|
212
|
+
scope = { kind: raw.slice(0, split), value: raw.slice(split + 1) }
|
|
213
|
+
const errors = validateScope(scope)
|
|
214
|
+
if (split < 0 || errors.length) throw new Error("--scope must be kind:value, with a supported kind and a nonempty value.")
|
|
215
|
+
}
|
|
216
|
+
if (flags.has("--search") && !scope) throw new Error("--search requires --scope kind:value so one context is never treated as a universal preference.")
|
|
217
|
+
const reviewer = flags.get("--reviewer") ?? "human"
|
|
218
|
+
if (!["human", "agent", "all"].includes(reviewer)) throw new Error("--reviewer must be human, agent, or all.")
|
|
219
|
+
const records = searchPreferences(memory.records, flags.get("--search"), { scope, reviewer })
|
|
220
|
+
const result = { type: flags.has("--check") ? "prefer.check" : "prefer.list", data: { count: records.length, checkedCount: memory.records.length, records, notice: "Contextual, self-reported judgments. Agent choices are observations and never establish the user's taste. An inventory is not a cross-context recommendation." } }
|
|
221
|
+
if (flags.has("--json") || flags.has("--check")) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
|
222
|
+
else { for (const record of records) process.stdout.write(`${record.id} [${record.scope.kind}:${record.scope.value}; ${record.attribution}]\n Choice: ${record.choice}. ${record.rationale}\n`); process.stdout.write(`${records.length} judgment(s). ${result.data.notice}\n`) }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
226
|
+
try { main() } catch (error) { process.stderr.write(`${JSON.stringify({ type: "error", code: "ERR_PREFERENCE_INVALID", message: error.message })}\n`); process.exitCode = 1 }
|
|
227
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
import { parsePreset, presetCss } from "../dist/presets/index.js"
|
|
5
|
+
import { parseFlags } from "./design-preferences.mjs"
|
|
6
|
+
|
|
7
|
+
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
|
|
8
|
+
const args = process.argv.slice(2)
|
|
9
|
+
const json = args.includes("--json")
|
|
10
|
+
function emit(type, data, output) { process.stdout.write(json ? JSON.stringify({ type, data }) + "\n" : output + "\n") }
|
|
11
|
+
try {
|
|
12
|
+
const flags = parseFlags(args, ["--check", "--css"], ["--template", "--json"])
|
|
13
|
+
const modes = ["--template", "--check", "--css"].filter(arg => flags.has(arg))
|
|
14
|
+
if (modes.length !== 1) throw new Error("Choose --template, --check <file.json>, or --css <file.json>.")
|
|
15
|
+
const action = modes[0]
|
|
16
|
+
if (action === "--template") {
|
|
17
|
+
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"))
|
|
18
|
+
const css = readFileSync(resolve(packageRoot, "dist/themes/base/base.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "")
|
|
19
|
+
const groups = { light: {}, dark: {} }
|
|
20
|
+
for (const match of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
21
|
+
const target = match[1].includes(".dark") ? groups.dark : groups.light
|
|
22
|
+
for (const declaration of match[2].matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)) target[declaration[1]] = declaration[2].trim()
|
|
23
|
+
}
|
|
24
|
+
const { PRESET_TOKEN_NAMES } = await import("../dist/presets/index.js")
|
|
25
|
+
function tokens(mode) {
|
|
26
|
+
const values = { ...groups.light, ...(mode === "dark" ? groups.dark : {}) }
|
|
27
|
+
function resolveValue(value, chain = []) {
|
|
28
|
+
return value.replace(/var\(--([\w-]+)\)/g, (_, name) => {
|
|
29
|
+
if (name === "motion-scale") return "var(--motion-scale)"
|
|
30
|
+
if (chain.includes(name) || !values[name]) throw new Error(`Cannot resolve template variable --${name}.`)
|
|
31
|
+
return resolveValue(values[name], [...chain, name])
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
return Object.fromEntries(PRESET_TOKEN_NAMES.filter(name => values[name]).map(name => [name, resolveValue(values[name])]))
|
|
35
|
+
}
|
|
36
|
+
const preset = parsePreset({ schemaVersion: 1, id: "my-preset", label: "My preset", revision: 1, sableVersion: manifest.version, defaultMode: "dark", fonts: { sans: groups.light["font-sans"], mono: groups.light["font-mono"] }, direction: "Content-first composition with a clear primary action. Replace this guidance with the direction for your product.", tokens: { shared: {}, light: tokens("light"), dark: tokens("dark") } })
|
|
37
|
+
emit("preset.template", preset, JSON.stringify(preset, null, 2))
|
|
38
|
+
} else {
|
|
39
|
+
const file = flags.get(action)
|
|
40
|
+
if (!file || file.startsWith("--")) throw new Error(`${action} requires a preset JSON path.`)
|
|
41
|
+
const preset = parsePreset(JSON.parse(readFileSync(resolve(file), "utf8")))
|
|
42
|
+
if (action === "--check") emit("preset.check", { valid: true, id: preset.id, revision: preset.revision, authoredWith: preset.sableVersion }, `Valid preset ${preset.id}, revision ${preset.revision}. Review rendered contrast and brief fit separately.`)
|
|
43
|
+
else { const css = presetCss(preset); emit("preset.css", { id: preset.id, revision: preset.revision, css }, css) }
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
process.stderr.write(JSON.stringify({ type: "error", code: "ERR_PRESET_INVALID", message: error.message, suggestions: ["sable docs using-presets", "sable preset --template"] }) + "\n")
|
|
47
|
+
process.exitCode = 1
|
|
48
|
+
}
|