@sableui/react 0.0.0-stage → 0.1.0
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 +50 -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,509 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* grade — score a folder of generated code against the Sable contract.
|
|
4
|
+
*
|
|
5
|
+
* node scripts/grade.mjs ../some-app/src
|
|
6
|
+
* node scripts/grade.mjs ./out --json
|
|
7
|
+
* node scripts/grade.mjs ./out --cli-log run.jsonl
|
|
8
|
+
*
|
|
9
|
+
* Why this exists: we have shipped a large amount of machinery aimed at making
|
|
10
|
+
* agents write correct Sable code — the registry, llms.txt, the task docs, the
|
|
11
|
+
* patterns tier, and now a retrieval CLI — and measured none of it. "Did that
|
|
12
|
+
* help?" has been answered by reading diffs and forming an impression. This
|
|
13
|
+
* turns the question into a number, with no model calls, so the same code can be
|
|
14
|
+
* scored repeatedly and compared across conditions.
|
|
15
|
+
*
|
|
16
|
+
* It is deliberately NOT a typechecker. `tsc` already catches unknown imports
|
|
17
|
+
* and bad prop types far better than any lexical pass, and a grader that
|
|
18
|
+
* duplicated it would mostly measure whether the sample compiles. What it
|
|
19
|
+
* measures instead is the stuff tsc structurally cannot see:
|
|
20
|
+
*
|
|
21
|
+
* - class-string drift, which Tailwind silently no-ops (the design contract)
|
|
22
|
+
* - Radix idioms hallucinated into Base UI code (`asChild`)
|
|
23
|
+
* - the density ladder bypassed with a hardcoded pixel height
|
|
24
|
+
* - icons named outside the contract
|
|
25
|
+
* - pattern similarity as a diagnostic, never as a reward for copying a shell
|
|
26
|
+
*
|
|
27
|
+
* Every check is lexical and dependency-free. Scores are comparative
|
|
28
|
+
* instruments, not grades in an absolute sense — a 78 means nothing on its own
|
|
29
|
+
* and everything next to the same task scored 61.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
|
|
33
|
+
import { createHash } from "node:crypto"
|
|
34
|
+
import { dirname, extname, join, relative, resolve } from "node:path"
|
|
35
|
+
import { fileURLToPath } from "node:url"
|
|
36
|
+
import { lintText, makeRules } from "./lint-rules.mjs"
|
|
37
|
+
import { skeletonize } from "./skeleton.mjs"
|
|
38
|
+
import { assessReview, reviewTemplate } from "./review-evidence.mjs"
|
|
39
|
+
|
|
40
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
41
|
+
const PKG_ROOT = join(HERE, "..")
|
|
42
|
+
const CWD = process.cwd()
|
|
43
|
+
|
|
44
|
+
const argv = process.argv.slice(2)
|
|
45
|
+
const flags = new Map()
|
|
46
|
+
const targets = []
|
|
47
|
+
const BOOLEAN_FLAGS = new Set(["json", "help", "template", "strict"])
|
|
48
|
+
const VALUE_FLAGS = new Set(["cli-log", "review"])
|
|
49
|
+
const JSON_OUT = argv.includes("--json")
|
|
50
|
+
function abort(message) {
|
|
51
|
+
const error = { type: "error", code: "ERR_GRADE_INPUT", message, suggestions: ["sable grade --help", "sable grade --template"] }
|
|
52
|
+
process.stderr.write(JSON_OUT ? `${JSON.stringify(error)}\n` : `[grade] ${message}\n`)
|
|
53
|
+
process.exit(1)
|
|
54
|
+
}
|
|
55
|
+
for (let i = 0; i < argv.length; i++) {
|
|
56
|
+
const a = argv[i]
|
|
57
|
+
if (!a.startsWith("--")) targets.push(a)
|
|
58
|
+
else if (BOOLEAN_FLAGS.has(a.slice(2))) flags.set(a.slice(2), true)
|
|
59
|
+
else if (VALUE_FLAGS.has(a.slice(2))) {
|
|
60
|
+
if (!argv[i + 1] || argv[i + 1].startsWith("--")) abort(`${a} requires a file path.`)
|
|
61
|
+
flags.set(a.slice(2), argv[++i])
|
|
62
|
+
} else abort(`Unknown flag ${a}.`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (flags.has("template")) {
|
|
66
|
+
process.stdout.write(`${JSON.stringify(reviewTemplate(), null, 2)}\n`)
|
|
67
|
+
process.exit(0)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (flags.has("help") || !targets.length) {
|
|
71
|
+
process.stdout.write(
|
|
72
|
+
[
|
|
73
|
+
"grade — check the static contract and recorded review evidence. No model calls.",
|
|
74
|
+
"",
|
|
75
|
+
" sable grade <dir...> [--json] [--review <review.json>] [--strict]",
|
|
76
|
+
"",
|
|
77
|
+
" --json machine-readable scorecard",
|
|
78
|
+
" --cli-log a SABLE_CLI_LOG jsonl, to report whether the CLI was used at all",
|
|
79
|
+
" --template print a review JSON template; fill after inspecting the rendered build",
|
|
80
|
+
" --review validate evidence files and observations; paths are relative to this JSON",
|
|
81
|
+
" --strict exit 1 for static findings or a missing/incomplete/unresolved review",
|
|
82
|
+
"",
|
|
83
|
+
"Checks: design-contract violations · hallucinated components and props ·",
|
|
84
|
+
"Radix idioms · hardcoded control heights · off-contract icon names ·",
|
|
85
|
+
"pattern similarity (informational only; original compositions carry no penalty).",
|
|
86
|
+
"A clean contract is not visual approval. This tool does not inspect screenshots.",
|
|
87
|
+
"",
|
|
88
|
+
].join("\n"),
|
|
89
|
+
)
|
|
90
|
+
process.exit(flags.has("help") ? 0 : 1)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ── The contract. ─────────────────────────────────────────────────────────── */
|
|
94
|
+
|
|
95
|
+
const REGISTRY = JSON.parse(readFileSync(join(PKG_ROOT, "registry.json"), "utf8"))
|
|
96
|
+
const semanticTokens = new Set((REGISTRY.tokens?.semantic ?? []).map((t) => t.name))
|
|
97
|
+
const motionTokens = new Set(
|
|
98
|
+
[
|
|
99
|
+
...(REGISTRY.tokens?.motion?.durations ?? []).map((d) => `motion-duration-${d}`),
|
|
100
|
+
...(REGISTRY.tokens?.motion?.easings ?? []).map((e) => `motion-ease-${e}`),
|
|
101
|
+
REGISTRY.tokens?.motion?.scale,
|
|
102
|
+
].filter(Boolean),
|
|
103
|
+
)
|
|
104
|
+
const RULES = makeRules({ semanticTokens, motionTokens }).filter((r) => r.scope !== "package")
|
|
105
|
+
const ICONS = new Set(REGISTRY.iconContract?.names ?? [])
|
|
106
|
+
const PATTERNS = REGISTRY.patterns?.items ?? []
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Everything importable from the package. Read from the real barrel where it is
|
|
110
|
+
* available, because the registry describes components and misses the hooks,
|
|
111
|
+
* providers and helpers a page legitimately imports (`cn`, `useSableTheme`,
|
|
112
|
+
* `Presence`, …) — grading those as hallucinations would be worse than useless.
|
|
113
|
+
*/
|
|
114
|
+
function exportSurface() {
|
|
115
|
+
const names = new Set()
|
|
116
|
+
for (const c of REGISTRY.components ?? []) {
|
|
117
|
+
names.add(c.name)
|
|
118
|
+
for (const s of c.subcomponents ?? []) names.add(typeof s === "string" ? s : s.name)
|
|
119
|
+
}
|
|
120
|
+
for (const m of REGISTRY.layoutPrimitives?.primitives ?? []) names.add(m.name)
|
|
121
|
+
for (const p of PATTERNS) names.add(p.name)
|
|
122
|
+
for (const n of REGISTRY.motionLayer?.policy ?? []) names.add(n)
|
|
123
|
+
for (const n of REGISTRY.motionLayer?.primitives ?? []) names.add(n)
|
|
124
|
+
|
|
125
|
+
for (const rel of ["dist/index.d.ts", "src/index.ts"]) {
|
|
126
|
+
const p = join(PKG_ROOT, rel)
|
|
127
|
+
if (!existsSync(p)) continue
|
|
128
|
+
const src = readFileSync(p, "utf8")
|
|
129
|
+
for (const m of src.matchAll(/export\s+(?:type\s+)?\{([^}]+)\}/g)) {
|
|
130
|
+
for (const raw of m[1].split(",")) {
|
|
131
|
+
const name = raw.trim().split(/\s+as\s+/).pop()?.trim()
|
|
132
|
+
if (name && /^[A-Za-z_$][\w$]*$/.test(name)) names.add(name)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const m of src.matchAll(/export\s+(?:declare\s+)?(?:const|function|class)\s+([\w$]+)/g)) names.add(m[1])
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
return names
|
|
139
|
+
}
|
|
140
|
+
const EXPORTS = exportSurface()
|
|
141
|
+
|
|
142
|
+
/** Props any element legitimately takes, so they never read as hallucinated. */
|
|
143
|
+
const UNIVERSAL_PROPS = new Set([
|
|
144
|
+
"key", "ref", "className", "style", "id", "role", "tabIndex", "title", "hidden", "slot", "children",
|
|
145
|
+
"render", "asChild", "dir", "lang", "translate", "draggable", "contentEditable", "spellCheck",
|
|
146
|
+
"inert", "popover", "form", "name", "value", "defaultValue", "checked", "defaultChecked",
|
|
147
|
+
"disabled", "required", "readOnly", "placeholder", "type", "href", "target", "rel", "src", "alt",
|
|
148
|
+
"width", "height", "colSpan", "rowSpan", "htmlFor", "autoFocus", "autoComplete", "maxLength",
|
|
149
|
+
"minLength", "min", "max", "step", "rows", "cols", "open", "selected", "multiple", "loading",
|
|
150
|
+
])
|
|
151
|
+
const isEventProp = (p) => /^on[A-Z]/.test(p)
|
|
152
|
+
const isPassthrough = (p) => p.startsWith("data-") || p.startsWith("aria-") || p.startsWith("--")
|
|
153
|
+
|
|
154
|
+
/* ── Walk. ─────────────────────────────────────────────────────────────────── */
|
|
155
|
+
|
|
156
|
+
const SKIP = new Set(["node_modules", ".next", ".nuxt", "dist", "build", "out", "coverage", ".git", ".turbo"])
|
|
157
|
+
const CODE = /\.(tsx|ts|jsx|js|css|scss|html)$/
|
|
158
|
+
function* walk(dir, includeAssets = false) {
|
|
159
|
+
for (const name of readdirSync(dir)) {
|
|
160
|
+
if (SKIP.has(name)) continue
|
|
161
|
+
const full = join(dir, name)
|
|
162
|
+
const st = statSync(full)
|
|
163
|
+
if (st.isDirectory()) yield* walk(full, includeAssets)
|
|
164
|
+
else if ((includeAssets || CODE.test(name)) && !/\.(test|spec)\./.test(name)) yield full
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const snapshotFiles = []
|
|
169
|
+
for (const t of targets) {
|
|
170
|
+
const abs = resolve(CWD, t)
|
|
171
|
+
if (!existsSync(abs)) abort(`Not found: ${t}`)
|
|
172
|
+
if (statSync(abs).isDirectory()) snapshotFiles.push(...walk(abs, true))
|
|
173
|
+
else snapshotFiles.push(abs)
|
|
174
|
+
}
|
|
175
|
+
snapshotFiles.splice(0, snapshotFiles.length, ...new Set(snapshotFiles.sort()))
|
|
176
|
+
const files = snapshotFiles.filter((file) => CODE.test(file))
|
|
177
|
+
if (!files.length) abort("No source files found.")
|
|
178
|
+
if (!files.some((file) => readFileSync(file, "utf8").trim().length > 0)) abort("No non-empty source content found.")
|
|
179
|
+
// Git checkouts may use CRLF on Windows and LF in CI. Normalize only known text
|
|
180
|
+
// formats for reproducible source identity; binary assets retain byte identity.
|
|
181
|
+
const TEXT_FILE = /\.(?:[cm]?[jt]sx?|css|scss|html?|json|md|mdx|txt|ya?ml|svg|xml|csv|tsv|map)$/i
|
|
182
|
+
function fingerprintBytes(file) {
|
|
183
|
+
const bytes = readFileSync(file)
|
|
184
|
+
return TEXT_FILE.test(file) ? bytes.toString("utf8").replace(/\r\n/g, "\n") : bytes
|
|
185
|
+
}
|
|
186
|
+
// Include the actual library implementation, not just its version string: a
|
|
187
|
+
// workspace library can change before its version is bumped. Installed consumers
|
|
188
|
+
// fingerprint dist; contributors fingerprint src (which their bundler uses).
|
|
189
|
+
const runtimeRoot = join(PKG_ROOT, existsSync(join(PKG_ROOT, "src")) ? "src" : "dist")
|
|
190
|
+
const packageFingerprint = createHash("sha256")
|
|
191
|
+
const packageFiles = [join(PKG_ROOT, "registry.json"), join(HERE, "lint-rules.mjs"), ...(existsSync(runtimeRoot) ? walk(runtimeRoot) : [])].sort()
|
|
192
|
+
for (const file of packageFiles) packageFingerprint.update(relative(PKG_ROOT, file).replaceAll("\\", "/")).update("\0").update(fingerprintBytes(file)).update("\0")
|
|
193
|
+
const packageHash = packageFingerprint.digest("hex")
|
|
194
|
+
const fingerprint = createHash("sha256")
|
|
195
|
+
fingerprint.update(packageHash).update("\0")
|
|
196
|
+
// Asset bytes inside supplied targets matter too. Pass public/ alongside src/
|
|
197
|
+
// when images or fonts live there; remote resources cannot be fingerprinted.
|
|
198
|
+
for (const file of snapshotFiles) fingerprint.update(relative(CWD, file).replaceAll("\\", "/")).update("\0").update(fingerprintBytes(file)).update("\0")
|
|
199
|
+
const sourceHash = fingerprint.digest("hex")
|
|
200
|
+
|
|
201
|
+
/* ── Checks. ───────────────────────────────────────────────────────────────── */
|
|
202
|
+
|
|
203
|
+
const findings = { contract: [], hallucinatedComponent: [], hallucinatedProp: [], radixIdiom: [], hardcodedControl: [], offContractIcon: [] }
|
|
204
|
+
let loc = 0
|
|
205
|
+
const routeSkeletons = []
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Attribute names on one JSX open tag, stopping at the tag's own `>`.
|
|
209
|
+
*
|
|
210
|
+
* Comments are skipped explicitly. A `//` line comment is legal *inside* a JSX
|
|
211
|
+
* open tag and Sable's own patterns use them to explain a prop — without this,
|
|
212
|
+
* every word of the prose scans as a prop name and the component appears to be
|
|
213
|
+
* carrying five hallucinated props.
|
|
214
|
+
*/
|
|
215
|
+
function attrsOf(source, from) {
|
|
216
|
+
const out = []
|
|
217
|
+
let depth = 0
|
|
218
|
+
let quote = null
|
|
219
|
+
for (let i = from; i < source.length; i++) {
|
|
220
|
+
const c = source[i]
|
|
221
|
+
if (quote) {
|
|
222
|
+
if (c === quote) quote = null
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
if (c === "/" && source[i + 1] === "/") {
|
|
226
|
+
const nl = source.indexOf("\n", i)
|
|
227
|
+
if (nl === -1) break
|
|
228
|
+
i = nl
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
if (c === "/" && source[i + 1] === "*") {
|
|
232
|
+
const end = source.indexOf("*/", i + 2)
|
|
233
|
+
if (end === -1) break
|
|
234
|
+
i = end + 1
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
if (c === '"' || c === "'" || c === "`") { quote = c; continue }
|
|
238
|
+
if (c === "{") depth++
|
|
239
|
+
else if (c === "}") depth--
|
|
240
|
+
else if (c === ">" && depth === 0) break
|
|
241
|
+
else if (depth === 0 && /[A-Za-z_]/.test(c)) {
|
|
242
|
+
const m = source.slice(i).match(/^([A-Za-z_][\w:-]*)\s*(=)?/)
|
|
243
|
+
if (m) {
|
|
244
|
+
out.push(m[1])
|
|
245
|
+
i += m[1].length - 1
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return out
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const file of files) {
|
|
253
|
+
const rel = relative(CWD, file).split(/[\\/]/).join("/")
|
|
254
|
+
const text = readFileSync(file, "utf8")
|
|
255
|
+
loc += text.split("\n").length
|
|
256
|
+
|
|
257
|
+
/* 1. Same contract as sable-lint. An illustration exception uses the shared
|
|
258
|
+
reasoned disable syntax; the presence of a gradient cannot exempt an
|
|
259
|
+
unrelated inline color elsewhere on that source line. */
|
|
260
|
+
const ctx = { isStylesheet: /\.(css|scss)$/.test(rel) }
|
|
261
|
+
for (const v of lintText(text, RULES, ctx)) {
|
|
262
|
+
findings.contract.push({ ...v, file: rel })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!/\.(tsx|jsx|ts|js)$/.test(extname(file) ? file : "")) continue
|
|
266
|
+
|
|
267
|
+
/* 2. Imports that name something the package does not export. */
|
|
268
|
+
for (const m of text.matchAll(/import\s*(?:type\s*)?\{([^}]+)\}\s*from\s*["']@sableui\/react[^"']*["']/g)) {
|
|
269
|
+
for (const raw of m[1].split(",")) {
|
|
270
|
+
const name = raw.trim().split(/\s+as\s+/)[0]?.trim()
|
|
271
|
+
if (!name || !/^[A-Z]/.test(name)) continue
|
|
272
|
+
if (!EXPORTS.has(name)) {
|
|
273
|
+
findings.hallucinatedComponent.push({ file: rel, name, line: lineOf(text, m.index) })
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* 3. Props set on a Sable component that its contract does not declare. */
|
|
279
|
+
for (const m of text.matchAll(/<([A-Z][\w]*)[\s/>]/g)) {
|
|
280
|
+
const comp = REGISTRY.components?.find((c) => c.name === m[1])
|
|
281
|
+
if (!comp?.props?.length) continue
|
|
282
|
+
const declared = new Set(comp.props.map((p) => p.name))
|
|
283
|
+
for (const sub of comp.subcomponents ?? []) {
|
|
284
|
+
if (typeof sub !== "string") for (const p of sub.props ?? []) declared.add(p.name)
|
|
285
|
+
}
|
|
286
|
+
for (const attr of attrsOf(text, m.index + m[0].length - 1)) {
|
|
287
|
+
if (declared.has(attr) || UNIVERSAL_PROPS.has(attr) || isEventProp(attr) || isPassthrough(attr)) continue
|
|
288
|
+
findings.hallucinatedProp.push({ file: rel, component: m[1], prop: attr, line: lineOf(text, m.index) })
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* 4. Radix idioms. `asChild` is the single most-documented hallucination
|
|
293
|
+
against Base UI code — Sable composes with `render`. */
|
|
294
|
+
for (const m of text.matchAll(/\basChild\b/g)) findings.radixIdiom.push({ file: rel, what: "asChild", line: lineOf(text, m.index) })
|
|
295
|
+
for (const m of text.matchAll(/from\s*["'](@radix-ui\/[^"']+|@base-ui\/react[^"']*)["']/g)) {
|
|
296
|
+
findings.radixIdiom.push({ file: rel, what: `direct import ${m[1]}`, line: lineOf(text, m.index) })
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* 5. Hardcoded control heights — bypassing the density ladder means the
|
|
300
|
+
component cannot follow a `data-density` scope, which stays invisible
|
|
301
|
+
until someone opens the workspace register.
|
|
302
|
+
Bounded to the ladder's actual range (24-44px, widened to 20-56 for
|
|
303
|
+
slack). An unbounded `h-[\d+px]` flags every media box and collage strip
|
|
304
|
+
on the page: `h-[380px]` is an illustration, not a control. */
|
|
305
|
+
for (const m of text.matchAll(/\bh-\[(\d+)px\]/g)) {
|
|
306
|
+
const px = Number(m[1])
|
|
307
|
+
if (px >= 20 && px <= 56) findings.hardcodedControl.push({ file: rel, match: m[0], line: lineOf(text, m.index) })
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* 6. Icon names outside the contract render nothing at all. */
|
|
311
|
+
for (const m of text.matchAll(/<Icon\s+[^>]*name=["']([^"']+)["']/g)) {
|
|
312
|
+
if (!ICONS.has(m[1])) findings.offContractIcon.push({ file: rel, name: m[1], line: lineOf(text, m.index) })
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* 7. Composition. Anything exporting a component that returns a big tree is
|
|
316
|
+
treated as a route worth comparing against the canonical shells. */
|
|
317
|
+
if (/\.(tsx|jsx)$/.test(file) && /export\s+(default\s+)?function\s+[A-Z]/.test(text)) {
|
|
318
|
+
const { tree } = skeletonize(text)
|
|
319
|
+
if (tree.split("\n").length >= 6) routeSkeletons.push({ file: rel, tree })
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function lineOf(text, index) {
|
|
324
|
+
return text.slice(0, index).split("\n").length
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* ── Pattern conformance. ──────────────────────────────────────────────────── */
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* How close a page's composition is to the nearest canonical shell.
|
|
331
|
+
*
|
|
332
|
+
* This is the measurement nothing else in the ecosystem takes. Every other
|
|
333
|
+
* signal here asks "is this line legal"; this one asks "is this page SHAPED
|
|
334
|
+
* like the system's own answer to this problem" — the documented gap where an
|
|
335
|
+
* agent produces a pile of individually-correct components in the wrong
|
|
336
|
+
* arrangement. Half structural vocabulary (which primitives appear at all),
|
|
337
|
+
* half order (do they nest and sequence the same way).
|
|
338
|
+
*/
|
|
339
|
+
/**
|
|
340
|
+
* Only SABLE primitives count toward composition. Including raw html would score
|
|
341
|
+
* two pages as similar for both containing many `div`s — the question is whether
|
|
342
|
+
* the page reaches for the system's vocabulary in the system's arrangement, and
|
|
343
|
+
* `div`/`span`/`p` carry no signal about that either way.
|
|
344
|
+
*/
|
|
345
|
+
const tagsOf = (tree) =>
|
|
346
|
+
tree
|
|
347
|
+
.split("\n")
|
|
348
|
+
.map((l) => l.trim().match(/^<([A-Za-z][\w.]*)/)?.[1])
|
|
349
|
+
.filter((t) => t && EXPORTS.has(t))
|
|
350
|
+
|
|
351
|
+
function lcs(a, b) {
|
|
352
|
+
const prev = new Array(b.length + 1).fill(0)
|
|
353
|
+
for (let i = 1; i <= a.length; i++) {
|
|
354
|
+
let diag = 0
|
|
355
|
+
for (let j = 1; j <= b.length; j++) {
|
|
356
|
+
const tmp = prev[j]
|
|
357
|
+
prev[j] = a[i - 1] === b[j - 1] ? diag + 1 : Math.max(prev[j], prev[j - 1])
|
|
358
|
+
diag = tmp
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return prev[b.length]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function similarity(a, b) {
|
|
365
|
+
if (!a.length || !b.length) return 0
|
|
366
|
+
const sa = new Set(a)
|
|
367
|
+
const sb = new Set(b)
|
|
368
|
+
const inter = [...sa].filter((x) => sb.has(x)).length
|
|
369
|
+
const jaccard = inter / (sa.size + sb.size - inter)
|
|
370
|
+
const order = (2 * lcs(a, b)) / (a.length + b.length)
|
|
371
|
+
return 0.5 * jaccard + 0.5 * order
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const patternTags = PATTERNS.map((p) => ({ name: p.name, tags: tagsOf(skeletonize(p.code).tree) }))
|
|
375
|
+
const conformance = routeSkeletons.map((r) => {
|
|
376
|
+
const tags = tagsOf(r.tree)
|
|
377
|
+
let best = { name: null, score: 0 }
|
|
378
|
+
for (const p of patternTags) {
|
|
379
|
+
const s = similarity(tags, p.tags)
|
|
380
|
+
if (s > best.score) best = { name: p.name, score: s }
|
|
381
|
+
}
|
|
382
|
+
return { file: r.file, nearest: best.name, score: +best.score.toFixed(3), depth: r.tree.split("\n").length }
|
|
383
|
+
})
|
|
384
|
+
const meanConformance = conformance.length
|
|
385
|
+
? +(conformance.reduce((s, c) => s + c.score, 0) / conformance.length).toFixed(3)
|
|
386
|
+
: null
|
|
387
|
+
|
|
388
|
+
/* ── Did the agent use the retrieval surface at all? ───────────────────────── */
|
|
389
|
+
|
|
390
|
+
let cliUsage = null
|
|
391
|
+
const logPath = flags.get("cli-log")
|
|
392
|
+
if (typeof logPath === "string" && existsSync(resolve(CWD, logPath))) {
|
|
393
|
+
const lines = readFileSync(resolve(CWD, logPath), "utf8").split("\n").filter(Boolean)
|
|
394
|
+
const byCommand = {}
|
|
395
|
+
for (const l of lines) {
|
|
396
|
+
try {
|
|
397
|
+
const e = JSON.parse(l)
|
|
398
|
+
byCommand[e.command ?? "?"] = (byCommand[e.command ?? "?"] ?? 0) + 1
|
|
399
|
+
} catch {}
|
|
400
|
+
}
|
|
401
|
+
cliUsage = { invocations: lines.length, byCommand }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* ── Score. ────────────────────────────────────────────────────────────────── */
|
|
405
|
+
|
|
406
|
+
const per100 = (n) => (loc ? +((n / loc) * 100).toFixed(2) : 0)
|
|
407
|
+
const counts = Object.fromEntries(Object.entries(findings).map(([k, v]) => [k, v.length]))
|
|
408
|
+
|
|
409
|
+
/*
|
|
410
|
+
* Weights are a judgment, stated here rather than buried: a hallucinated
|
|
411
|
+
* component or a Radix idiom does not compile or does not render, so it costs
|
|
412
|
+
* most; a contract violation renders but wrong. Shell similarity is diagnostic
|
|
413
|
+
* only: copying a pattern cannot compensate for a defect, and original
|
|
414
|
+
* compositions must be able to pass the contract without resembling fixtures.
|
|
415
|
+
*/
|
|
416
|
+
const penalties = {
|
|
417
|
+
hallucinatedComponent: counts.hallucinatedComponent * 10, // does not compile
|
|
418
|
+
radixIdiom: counts.radixIdiom * 10, // compiles, silently does nothing
|
|
419
|
+
offContractIcon: counts.offContractIcon * 6, // renders nothing at all
|
|
420
|
+
hallucinatedProp: counts.hallucinatedProp * 3, // ignored prop
|
|
421
|
+
hardcodedControl: counts.hardcodedControl * 2, // breaks the register
|
|
422
|
+
contract: counts.contract * 1.5, // renders, but off-token
|
|
423
|
+
}
|
|
424
|
+
const deduction = Math.min(60, Object.values(penalties).reduce((a, b) => a + b, 0))
|
|
425
|
+
const score = Math.max(0, Math.min(100, Math.round(100 - deduction)))
|
|
426
|
+
let reviewDocument = null
|
|
427
|
+
const reviewPath = flags.get("review")
|
|
428
|
+
if (reviewPath) {
|
|
429
|
+
try { reviewDocument = JSON.parse(readFileSync(resolve(CWD, reviewPath), "utf8")) }
|
|
430
|
+
catch (error) { abort(`Cannot read review ${reviewPath}: ${error.message}`) }
|
|
431
|
+
}
|
|
432
|
+
const review = assessReview(reviewDocument, { baseDir: reviewPath ? dirname(resolve(CWD, reviewPath)) : CWD, sourceHash })
|
|
433
|
+
const clean = !Object.values(counts).some(Boolean)
|
|
434
|
+
const exitCode = flags.has("strict") && (!clean || review.status !== "review-recorded") ? 1 : 0
|
|
435
|
+
|
|
436
|
+
const result = {
|
|
437
|
+
version: REGISTRY.version,
|
|
438
|
+
sourceHash,
|
|
439
|
+
fingerprint: { algorithm: "sha256", files: snapshotFiles.length, packageHash, coverage: "All non-test files in supplied targets, plus Sable runtime sources, registry, and lint contract. Known text formats normalize CRLF to LF; binary assets retain their bytes. Pass public/ for external asset directories; remote resources are not covered." },
|
|
440
|
+
files: files.length,
|
|
441
|
+
loc,
|
|
442
|
+
score,
|
|
443
|
+
scoreMeaning: "Static contract only. Pattern similarity does not affect this score; visual quality is not scored.",
|
|
444
|
+
contract: { status: clean ? "pass" : "fail", score },
|
|
445
|
+
review,
|
|
446
|
+
counts,
|
|
447
|
+
per100loc: Object.fromEntries(Object.entries(counts).map(([k, v]) => [k, per100(v)])),
|
|
448
|
+
conformance: { informationalOnly: true, mean: meanConformance, routes: conformance },
|
|
449
|
+
cliUsage,
|
|
450
|
+
findings,
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (JSON_OUT) {
|
|
454
|
+
process.stdout.write(`${JSON.stringify(result, null, 1)}\n`)
|
|
455
|
+
process.exit(exitCode)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/* ── Report. ───────────────────────────────────────────────────────────────── */
|
|
459
|
+
|
|
460
|
+
const L = (s = "") => process.stdout.write(`${s}\n`)
|
|
461
|
+
L()
|
|
462
|
+
L(` grade — ${files.length} files, ${loc} lines, against @sableui/react ${REGISTRY.version}`)
|
|
463
|
+
L(` ${"─".repeat(66)}`)
|
|
464
|
+
const row = (label, n, note = "") =>
|
|
465
|
+
L(` ${label.padEnd(26)}${String(n).padStart(4)}${note ? ` ${note}` : ""}`)
|
|
466
|
+
row("contract violations", counts.contract, counts.contract ? `${per100(counts.contract)}/100 loc` : "clean")
|
|
467
|
+
row("hallucinated components", counts.hallucinatedComponent)
|
|
468
|
+
row("hallucinated props", counts.hallucinatedProp)
|
|
469
|
+
row("Radix idioms", counts.radixIdiom, counts.radixIdiom ? "asChild / direct primitive imports" : "")
|
|
470
|
+
row("hardcoded control heights", counts.hardcodedControl)
|
|
471
|
+
row("off-contract icon names", counts.offContractIcon)
|
|
472
|
+
L()
|
|
473
|
+
if (conformance.length) {
|
|
474
|
+
L(` pattern similarity (informational) — ${conformance.length} route-like file(s), mean ${meanConformance}`)
|
|
475
|
+
for (const c of conformance) {
|
|
476
|
+
const bar = "█".repeat(Math.round(c.score * 20)).padEnd(20, "·")
|
|
477
|
+
L(` ${bar} ${String(c.score).padEnd(6)} ${c.nearest ?? "—"} ${c.file}`)
|
|
478
|
+
}
|
|
479
|
+
} else {
|
|
480
|
+
L(" composition no route-like files found")
|
|
481
|
+
}
|
|
482
|
+
if (cliUsage) {
|
|
483
|
+
L()
|
|
484
|
+
L(` sable CLI ${cliUsage.invocations} invocation(s)`)
|
|
485
|
+
for (const [c, n] of Object.entries(cliUsage.byCommand)) L(` ${String(n).padStart(3)}× ${c}`)
|
|
486
|
+
}
|
|
487
|
+
L()
|
|
488
|
+
L(` static contract ${score}/100 · ${clean ? "pass" : "fail"}`)
|
|
489
|
+
L(` visual review: ${review.status}`)
|
|
490
|
+
for (const issue of review.issues) L(` ${issue}`)
|
|
491
|
+
for (const issue of review.openIssues ?? []) L(` open: ${typeof issue === "string" ? issue : JSON.stringify(issue)}`)
|
|
492
|
+
L(` sourceHash: ${sourceHash}`)
|
|
493
|
+
L(` ${"─".repeat(66)}`)
|
|
494
|
+
if (!JSON_OUT && Object.values(counts).some(Boolean)) {
|
|
495
|
+
L()
|
|
496
|
+
for (const [kind, list] of Object.entries(findings)) {
|
|
497
|
+
if (!list.length) continue
|
|
498
|
+
L(` ${kind}:`)
|
|
499
|
+
for (const f of list.slice(0, 8)) {
|
|
500
|
+
const what = f.name ?? f.prop ?? f.what ?? f.match ?? f.ruleId
|
|
501
|
+
L(` ${f.file}:${f.line ?? "?"} ${f.component ? `<${f.component}> ` : ""}${what}`)
|
|
502
|
+
}
|
|
503
|
+
if (list.length > 8) L(` … ${list.length - 8} more`)
|
|
504
|
+
L()
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
L(" Static compliance is not visual approval. This tool does not inspect screenshots.")
|
|
508
|
+
L()
|
|
509
|
+
process.exit(exitCode)
|