@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.
Files changed (247) hide show
  1. package/AGENTS.md +157 -0
  2. package/CHANGELOG.md +50 -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,1065 @@
1
+ /**
2
+ * lint-rules — the rule table, shared by both linters.
3
+ *
4
+ * There are two callers and they see the SAME rules, which is the whole point of
5
+ * this file existing:
6
+ *
7
+ * scripts/design-lint.mjs lints Sable's own src/, contract from themes/_contract.ts
8
+ * bin/sable-lint.mjs lints a CONSUMER's app, contract from registry.json
9
+ *
10
+ * The second one exists because of a real failure. Three task docs told agents to
11
+ * run `pnpm design-lint` under the heading "These are not a formality" — and the
12
+ * package shipped no bin and no scripts/ directory, so no consumer could run any
13
+ * of it. A converting agent hand-grepped instead and found `border-orange-500`
14
+ * that a previous Sable conversion had shipped unlinted for weeks. Ten of the
15
+ * eleven rules below are about consumer code; only `self-import` is internal.
16
+ * We were enforcing the contract on the one codebase least likely to break it.
17
+ *
18
+ * Rules carry `scope`:
19
+ * "both" — applies to any code using Sable (the default, and most of them)
20
+ * "package" — only meaningful inside this package
21
+ * "consumer" — only meaningful in apps built with Sable (design-lint skips it)
22
+ *
23
+ * A rule is a lexical matcher, not an AST pass: `regex`, or `find(line)` returning
24
+ * [{ match }]. That is enough for the class-string patterns we police and keeps
25
+ * both linters zero-dependency.
26
+ */
27
+
28
+ /** Utility prefixes that take a colour token. */
29
+ export const COLOR_PREFIX =
30
+ "(?:bg|text|border|fill|stroke|ring|ring-offset|from|to|via|decoration|outline|divide|caret|accent|shadow)"
31
+
32
+ /**
33
+ * Yield every JSX opening tag whose name matches `tagPattern`, as
34
+ * `{ tag, attrs, index }`.
35
+ *
36
+ * This is a scanner rather than a regex because the obvious regex is wrong in a
37
+ * way that hides: `<button ... />` with an `onClick={() => {}}` on it contains a
38
+ * `>` INSIDE the attribute list, so `<tag[^<>]*>` stops at the arrow and reports
39
+ * a tag whose attributes are truncated one line in. The arrow-function handler is
40
+ * the single most common thing on an interactive element, which means the naive
41
+ * version fails exactly on the elements the motion rule exists to find. So: walk
42
+ * forward from the tag name, tracking string and brace nesting, and stop at the
43
+ * first `>` that is genuinely at depth zero.
44
+ *
45
+ * Still lexical — it does not parse JSX, and it does not need to. It only has to
46
+ * answer "which text belongs to this tag".
47
+ */
48
+ function* openingTags(text, tagPattern) {
49
+ const nameRe = /<([A-Za-z][\w.]*)(?=[\s/>])/g
50
+ for (const m of text.matchAll(nameRe)) {
51
+ if (!tagPattern.test(m[1])) continue
52
+ let i = m.index + m[0].length
53
+ let depth = 0
54
+ let quote = null
55
+ for (; i < text.length; i++) {
56
+ const ch = text[i]
57
+ if (quote) {
58
+ if (ch === "\\") i++
59
+ else if (ch === quote) quote = null
60
+ continue
61
+ }
62
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch
63
+ else if (ch === "{") depth++
64
+ else if (ch === "}") depth--
65
+ else if (ch === ">" && depth === 0) break
66
+ }
67
+ if (i >= text.length) continue // unterminated — malformed source, not our problem
68
+ yield { tag: m[1], attrs: text.slice(m.index + m[0].length, i), index: m.index }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The text of a tag's `className` expression — `"…"`, `{"…"}`, `{cn("…", x && "…")}`
74
+ * — or "" when there is none. Brace-balanced, so a `cn(...)` call spanning lines
75
+ * comes through whole. Callers then look at the CLASSES inside it, which is the
76
+ * only part of the expression these rules judge.
77
+ */
78
+ function classNameOf(attrs) {
79
+ const m = /\bclassName\s*=\s*/.exec(attrs)
80
+ if (!m) return ""
81
+ let i = m.index + m[0].length
82
+ const open = attrs[i]
83
+ if (open === '"' || open === "'" || open === "`") {
84
+ const end = attrs.indexOf(open, i + 1)
85
+ // Quotes kept: callers read the class strings out of the expression.
86
+ return end === -1 ? "" : attrs.slice(i, end + 1)
87
+ }
88
+ if (open !== "{") return ""
89
+ let depth = 0
90
+ const start = i
91
+ for (; i < attrs.length; i++) {
92
+ if (attrs[i] === "{") depth++
93
+ else if (attrs[i] === "}" && --depth === 0) return attrs.slice(start + 1, i)
94
+ }
95
+ return ""
96
+ }
97
+
98
+ /** Every class token in a className expression, with its variant prefixes (`hover:`, `md:`) split off. */
99
+ function classTokens(expr) {
100
+ const out = []
101
+ for (const s of expr.matchAll(/(["'`])((?:(?!\1).)*)\1/gs)) {
102
+ for (const raw of s[2].split(/\s+/)) {
103
+ if (!raw || raw.includes("${")) continue
104
+ // The base utility is what follows the last `:` outside brackets.
105
+ let depth = 0
106
+ let cut = 0
107
+ for (let k = 0; k < raw.length; k++) {
108
+ if (raw[k] === "[" || raw[k] === "(") depth++
109
+ else if (raw[k] === "]" || raw[k] === ")") depth--
110
+ else if (raw[k] === ":" && depth === 0) cut = k + 1
111
+ }
112
+ out.push({ raw, base: raw.slice(cut).replace(/^!/, "") })
113
+ }
114
+ }
115
+ return out
116
+ }
117
+
118
+ /**
119
+ * Build the rule table against a token contract.
120
+ *
121
+ * @param {{ semanticTokens: Set<string>, motionTokens: Set<string> }} contract
122
+ */
123
+ export function makeRules({ semanticTokens, motionTokens }) {
124
+ // Easing utility names = the theme's motion-ease-* tokens + Tailwind built-ins.
125
+ const easeNames = new Set([
126
+ ...[...motionTokens]
127
+ .filter((t) => t.startsWith("motion-ease-"))
128
+ .map((t) => t.replace("motion-ease-", "")),
129
+ "in",
130
+ "out",
131
+ "in-out",
132
+ "linear",
133
+ "initial",
134
+ ])
135
+
136
+ // A tone/overlay/highlight colour utility (with optional /opacity), capturing
137
+ // the token name to validate. Compiled once (matchAll clones the regex per
138
+ // call, so reusing a /g regex is safe). `g` is required by matchAll.
139
+ const toneUtility = new RegExp(
140
+ `\\b${COLOR_PREFIX}-((?:tone-[a-z-]+|highlight|overlay)[a-z-]*)(?:/\\d+)?\\b`,
141
+ "g",
142
+ )
143
+
144
+ /* A physical direction utility, with the VALUE shape spelled out rather than a
145
+ * loose `[\w-]+` tail. That is the whole difference between a rule people obey
146
+ * and a rule people disable: prose is full of "left-aligned", "right-hand",
147
+ * "left-to-right" and `right-click`, and every one of them matches the loose
148
+ * version. Requiring a Tailwind value — a number, a fraction, `auto`, `px`,
149
+ * `full`, an arbitrary [..] or a var (..) — leaves only real class strings.
150
+ * The leading char is captured too so a variant prefix (`md:`, `hover:`,
151
+ * `data-[side=left]:`) and a negative (`-ml-2.5`) both come through intact. */
152
+ const physicalDirection =
153
+ /(?:^|[\s"'`{(:[])(-?(?:ml|mr|pl|pr|left|right)-(?:\[[^\]]*\]|\((?:--)?[\w-]+\)|auto|px|full|\d+(?:\.\d+)?(?:\/\d+)?)|text-(?:left|right))(?![\w-])/g
154
+
155
+ /* surface-hover: an interaction-state variant setting a background to a
156
+ * fixed token. `accent` is included: sable-surface-interactive's own docs
157
+ * name `hover:bg-accent` as the thing it replaces — a fixed hover that is
158
+ * right on one surface and invisible or loud on the next. */
159
+ const surfaceState =
160
+ /\b(hover|focus-visible|sable-highlighted|sable-selected|data-\[highlighted\]|data-\[selected\]|data-\[checked\]|aria-selected|aria-\[selected=true\]):bg-(background|card|popover|muted|secondary|accent|panel-raised|panel|canvas)\b(?:\/(?:\d+|\[[^\]]+\]))?(?![\w-])/g
161
+
162
+ return [
163
+ {
164
+ id: "inline-color",
165
+ scope: "both",
166
+ why: "Inline oklch()/hex color. Use a semantic token (bg-card, text-tone-edition-foreground, bg-overlay…). Literal colors belong only in a theme file.",
167
+ regex: /oklch\s*\(|#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/g,
168
+ },
169
+ {
170
+ id: "palette-ref",
171
+ scope: "both",
172
+ why: "Raw palette-step reference (e.g. --zinc-950, text-(--purple-heart-700), border-orange-500). Palette steps are theme-internal — name a semantic token instead. After the tone-foreground + overlay tokens, this rule has ZERO sanctioned exceptions.",
173
+ // Two shapes, and the second is the one that got away. A CSS var ending in
174
+ // a ramp step catches `--zinc-950`; a bare Tailwind palette utility catches
175
+ // `border-orange-500`, which is what actually shipped in a consumer app for
176
+ // weeks. Semantic/layout/motion tokens never end in a numeric step, so both
177
+ // halves are theme-agnostic.
178
+ regex: new RegExp(
179
+ "--[a-zA-Z][\\w-]*-(?:50|100|200|300|400|500|600|700|800|900|950)\\b" +
180
+ `|\\b${COLOR_PREFIX}-(?:slate|gray|grey|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950)\\b`,
181
+ "g",
182
+ ),
183
+ },
184
+ {
185
+ id: "raw-control-height",
186
+ scope: "both",
187
+ code: true,
188
+ why:
189
+ "A control-ladder height hardcoded in px. Read the ladder instead — h-(--control-md), h-(--control-xs), … — so a data-density scope can retune every control at once (DESIGN.md → Registers). " +
190
+ "Only the ladder's own values are flagged, so a genuine non-control measure (a gauge diameter, a WCAG touch floor) is a real exception: disable with a reason.",
191
+ // Deliberately narrow: matching every h-[Npx] would fire on badge dots,
192
+ // device frames and panel widths, and a rule that mostly cries wolf just
193
+ // teaches people to sprinkle disables.
194
+ regex: /\b(?:min-)?h-\[(?:24|28|32|36|40|44|48)px\]/g,
195
+ },
196
+ {
197
+ id: "raw-black-white",
198
+ scope: "both",
199
+ code: true,
200
+ why: "bg-black/bg-white/text-white/etc. The anti-pattern is pure #000/#fff. For scrims & media surfaces use the `overlay` / `overlay-foreground` tokens; for theme surfaces use semantic tokens.",
201
+ regex: new RegExp(`\\b${COLOR_PREFIX}-(?:black|white)\\b`, "g"),
202
+ },
203
+ {
204
+ id: "self-import",
205
+ scope: "package",
206
+ why: "Importing the package's own public barrel (@sableui/react) from inside src/ risks circular imports and export-order surprises, and weakens internal module boundaries. Deep-import the specific module with a relative path (e.g. ../../icon, ../button).",
207
+ regex: /from\s+["']@sableui\/react\b[^"']*["']/g,
208
+ },
209
+ {
210
+ id: "tailwind-color-alias",
211
+ scope: "both",
212
+ since: "0.1.0",
213
+ // Comment-stripped: the docs below discuss this exact string.
214
+ code: true,
215
+ why: "`var(--color-*)` is TAILWIND's generated alias, not a Sable token, and it is NOT theme-aware. `@theme inline` makes the UTILITIES follow the active theme, but the emitted `--color-*` custom property is frozen to whatever the DEFAULT theme resolved at build time — so hand-written CSS inside a `data-sable-theme` subtree silently paints the default theme's colour. Read the semantic token instead: `var(--primary)`, `var(--background)`, `var(--border-strong)`. This shipped twice in one build: a hero gradient that computed to near-white instead of the brand accent, and a whole page rendering in the default theme's typeface. Nothing errors — it just renders the wrong theme.",
216
+ regex: /var\(\s*--color-[\w-]+/g,
217
+ },
218
+ {
219
+ id: "unmanaged-overflow",
220
+ scope: "both",
221
+ // Checked against the comment-stripped line: doc comments discuss
222
+ // `overflow-y-auto` constantly and style nothing.
223
+ code: true,
224
+ why: "A scroll container with no scrollbar policy. Pair the overflow utility with `scrollbar-quiet` (a quiet bar that appears while scrolling) or `scrollbar-hidden` (no bar — only when something else carries the affordance, e.g. Scroller's horizontal edge fades). Left bare, the platform draws its own: stepper arrows on Windows, hard against the padding edge, poking out of any rounded corner. See DESIGN.md → Scrolling.",
225
+ find(line) {
226
+ const overflow = line.match(/\boverflow(?:-[xy])?-(?:auto|scroll)\b/)
227
+ if (!overflow) return []
228
+ // The policy may sit anywhere on the same line — before the overflow
229
+ // utility, after it, or in an adjacent class in the same string.
230
+ if (/\bscrollbar-(?:quiet|hidden)\b/.test(line)) return []
231
+ return [{ match: overflow[0] }]
232
+ },
233
+ },
234
+ {
235
+ id: "transition-colors",
236
+ scope: "both",
237
+ code: true,
238
+ why: "Bare `transition-colors` bypasses the motion kill-switch. Use the `motion-interactive` recipe (the single transition declarer for interactive chrome).",
239
+ regex: /\btransition-colors\b/g,
240
+ },
241
+ {
242
+ id: "transition-all",
243
+ scope: "both",
244
+ since: "0.1.0",
245
+ code: true,
246
+ why: "`transition: all` watches every animatable property, so anything that changes for an unrelated reason — a padding step, a shadow swap, a width recalculated by the layout — animates too, and the browser cannot pre-compute what it is transitioning. Use `motion-interactive`, or name the properties you actually animate.",
247
+ // Both spellings: Tailwind's utility (with any variant prefix in front of
248
+ // it) and the raw declaration in a stylesheet or a style object.
249
+ regex: /\btransition-all\b|\btransition(?:Property)?\s*:\s*["']?all\b/g,
250
+ },
251
+ {
252
+ id: "builtin-animate",
253
+ scope: "both",
254
+ code: true,
255
+ why: "Tailwind's built-in animate-{spin,pulse,bounce,ping} ignore --motion-scale, so the reduced-motion / MotionProvider-off policy can't freeze them. Use the scale-aware motion-{spin,pulse,shimmer,dot-bounce,ping} loader recipes.",
256
+ regex: /\banimate-(?:spin|pulse|bounce|ping)\b/g,
257
+ },
258
+ {
259
+ id: "raw-duration",
260
+ scope: "both",
261
+ code: true,
262
+ why: "Raw duration-<n> (e.g. duration-150) bypasses --motion-scale. Use duration-(--motion-duration-{instant|fast|base|slow|slower|enter|exit}).",
263
+ regex: /\bduration-\d+\b/g,
264
+ },
265
+ {
266
+ id: "faded-focus-ring",
267
+ scope: "both",
268
+ code: true,
269
+ why: "Opacity on semantic focus ink weakens the theme's tested contrast. Use ring-ring or ring-sidebar-ring at full opacity; tune the semantic token in the theme instead.",
270
+ find(line) {
271
+ return [...line.matchAll(/\b(?:ring|outline)-(?:sidebar-)?ring\/(\d+(?:\.\d+)?|\[[^\]]+\])/g)]
272
+ .filter((match) => match[1] !== "100")
273
+ .map((match) => ({ match: match[0] }))
274
+ },
275
+ },
276
+ {
277
+ id: "text-opacity",
278
+ scope: "both",
279
+ code: true,
280
+ since: "0.1.0",
281
+ why: "Opacity on a surface text ink (text-foreground/60). An alpha step over an unknown surface has unknown contrast and shifts per theme. Use text-muted-foreground — it is contrast-tested on every surface. See `sable docs typography`.",
282
+ // Only the inks that HAVE a tested secondary step. overlay-foreground and
283
+ // the tone inks sit on imagery or a tone fill with no muted counterpart,
284
+ // so an alpha there is a legitimate (and reviewed) choice.
285
+ regex: /\btext-(?:muted-|card-|popover-)?foreground\/(?:\d+(?:\.\d+)?|\[[^\]]+\])/g,
286
+ },
287
+ {
288
+ id: "highlight-as-ink",
289
+ scope: "both",
290
+ code: true,
291
+ since: "0.1.0",
292
+ why: "`highlight` is a SURFACE, always paired with `highlight-foreground` (bg-highlight text-highlight-foreground). Presets disagree on what it looks like (a vivid accent in some, a quiet wash in Atelier and Verdant), so as a bare text or icon ink it vanishes in half of them. For an accented mark put the icon in a bg-highlight chip; for links and hovers use text-foreground.",
293
+ regex: /\btext-highlight(?!-foreground)\b/g,
294
+ },
295
+ {
296
+ id: "decomposed-type",
297
+ scope: "both",
298
+ code: true,
299
+ why: "Hand-rolled typography: a bare text-{size} together with a font-{weight}. Use a paired utility (text-heading-1, text-body-md, text-label-lg…) which bundles size+weight+line-height+tracking.",
300
+ find(line) {
301
+ const hasSize = /\btext-(?:xs|sm|base|lg|xl|[2-9]xl)\b/.test(line)
302
+ const weight = line.match(
303
+ /\bfont-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)\b/,
304
+ )
305
+ return hasSize && weight ? [{ match: weight[0] }] : []
306
+ },
307
+ },
308
+ {
309
+ id: "unknown-token",
310
+ scope: "both",
311
+ why: "Unknown tone/overlay/highlight token — Tailwind silently no-ops a typo'd token (no compile error). Check the spelling against the token list in registry.json.",
312
+ find(line) {
313
+ const out = []
314
+ for (const m of line.matchAll(toneUtility)) {
315
+ if (!semanticTokens.has(m[1])) out.push({ match: m[0] })
316
+ }
317
+ return out
318
+ },
319
+ },
320
+ {
321
+ id: "raw-z-index",
322
+ scope: "both",
323
+ code: true,
324
+ why: "Raw z-index utility. Name a step on the layering scale — z-(--z-sticky|nav|overlay|toast|skip-link) — so stacking is one ordered system rather than a bidding war. See DESIGN.md → Layering.",
325
+ // The rule DESIGN.md has stated since 0.17.0, unenforced until 0.44.0 —
326
+ // and broken by fourteen of Sable's own portaled surfaces, every one of
327
+ // them writing `z-50` while --z-overlay sat declared and unused. A rule
328
+ // nothing checks is a rule the components drift away from first.
329
+ //
330
+ // Small positive z-indexes are left alone: z-0/z-10/z-20 are local
331
+ // stacking inside a component's own box (a carousel arrow over its
332
+ // slide), not participation in the global layer order. The scale starts
333
+ // at 30 for exactly that reason.
334
+ find(line) {
335
+ const out = []
336
+ for (const m of line.matchAll(/(?:^|[\s"'`])-?z-(\d+)\b/g)) {
337
+ if (Number(m[1]) >= 30) out.push({ match: m[0].trim() })
338
+ }
339
+ for (const m of line.matchAll(/(?:^|[\s"'`])-?z-\[[^\]]+\]/g)) {
340
+ out.push({ match: m[0].trim() })
341
+ }
342
+ return out
343
+ },
344
+ },
345
+ {
346
+ id: "static-interactive",
347
+ scope: "both",
348
+ /* When this rule started failing builds. A consuming app upgrading across
349
+ * several releases needs to know which findings are NEW contract, not
350
+ * rot it has been ignoring — `sable-lint --since <their old version>`
351
+ * answers that. Rules predating this field carry no `since`; they are
352
+ * simply "older than the tracking". */
353
+ since: "0.1.0",
354
+ // A whole-file rule: a JSX opening tag routinely spans a dozen lines, and
355
+ // the class string that says "I react to a pointer" is almost never on the
356
+ // same line as the one that would say how.
357
+ file: true,
358
+ code: true,
359
+ why:
360
+ "An element styled for pointer states (hover:/active:/focus-visible:) with no motion recipe on it. Add `motion-interactive` (colour/opacity chrome) or `motion-press` (it also moves under the press). " +
361
+ "Every other motion rule here is PROHIBITIVE — they fire on motion that is present and wrong, and none of them can fire on motion that is absent, so a component that simply snaps between states passes the whole contract. This is the rule that asks for the motion in the first place.",
362
+ scan(text) {
363
+ const out = []
364
+ // Intrinsic elements only. `<Button className="hover:…">` brings its own
365
+ // motion from the component, and flagging it would teach people that the
366
+ // fix is to bolt a recipe onto a Sable component — which is how you end
367
+ // up with two transitions fighting over the same property.
368
+ for (const m of openingTags(text, /^[a-z][a-z0-9]*$/)) {
369
+ const attrs = m.attrs
370
+ if (!/\bclassName\s*=/.test(attrs)) continue
371
+ if (!/\b(?:hover|active|focus-visible):/.test(attrs)) continue
372
+ if (/\bmotion-[a-z-]+/.test(attrs)) continue
373
+ // `transition-colors` is already caught by its own rule and its fix is
374
+ // the same recipe; reporting both on one element is noise.
375
+ if (/\btransition-colors\b/.test(attrs)) continue
376
+ out.push({
377
+ index: m.index,
378
+ match: `<${m.tag} …${/\b(?:hover|active|focus-visible):[\w[\]/.-]+/.exec(attrs)?.[0] ?? ""}`,
379
+ })
380
+ }
381
+ return out
382
+ },
383
+ },
384
+ {
385
+ id: "native-ui-import",
386
+ /* CONSUMER-scoped: an iOS prototype importing Konsta directly. Konsta is
387
+ * there for the system chrome Sable has no equivalent for, and the
388
+ * adapter (@sableui/react/ios) wraps exactly that set. A direct import
389
+ * is how a Konsta Toggle, ListInput or Searchbar ends up where Sable's
390
+ * Switch, Input or SearchInput belongs — which the first iOS prototypes
391
+ * did, until the rule existed. */
392
+ scope: "consumer",
393
+ since: "0.1.0",
394
+ file: true,
395
+ code: true,
396
+ why:
397
+ "A direct import from konsta. In an iOS prototype, Sable components build every screen; Konsta supplies only system chrome, and that comes through @sableui/react/ios (NativeApp, IosPage, IosNavbar, IosBackLink, IosTabbar, IosTab, IosSheet, IosIconButton, NavigationStack). For a row, input, switch, slider, search, button, list or toast, use the Sable component — `sable target ios --component <Name>` says which.",
398
+ scan(text) {
399
+ const out = []
400
+ for (const m of text.matchAll(/from\s+["'](konsta(?:\/[\w-]+)?)["']/g)) out.push({ index: m.index, match: `import from "${m[1]}" → @sableui/react/ios or a Sable component` })
401
+ return out
402
+ },
403
+ },
404
+ {
405
+ id: "hand-rolled-control",
406
+ /* CONSUMER-scoped: apps built WITH Sable. The package's own components are
407
+ * where these roles are allowed to be implemented; everywhere else, a
408
+ * hand-written radio group or range input is a Sable component re-built
409
+ * worse. Sable's own docs site shipped exactly that — a Customize panel of
410
+ * role="radio" buttons with no arrow keys, a native range, a native colour
411
+ * picker — because nothing here could say "use the component". */
412
+ scope: "consumer",
413
+ since: "0.1.0",
414
+ file: true,
415
+ code: true,
416
+ why:
417
+ "A hand-built control where Sable ships the component. Sable's version carries the keyboard model, focus handling, ARIA wiring and theming (it is built on Base UI); a hand-rolled one usually misses some of it — a role=\"radio\" button row without arrow keys is the common case. Use the named component. If none genuinely fits (a 2D pad, a bespoke strip), keep it keyboard-complete and add the escape hatch with the reason.",
418
+ scan(text) {
419
+ const ROLE = {
420
+ radio: "RadioGroup / ToggleGroup",
421
+ radiogroup: "RadioGroup / ToggleGroup",
422
+ slider: "Slider",
423
+ switch: "Switch",
424
+ checkbox: "Checkbox",
425
+ tab: "Tabs",
426
+ tablist: "Tabs",
427
+ menu: "Menu",
428
+ menuitem: "Menu",
429
+ listbox: "Select / Combobox",
430
+ option: "Select / Combobox",
431
+ combobox: "Combobox",
432
+ dialog: "Dialog / Sheet",
433
+ alertdialog: "AlertDialog",
434
+ tooltip: "Tooltip",
435
+ toolbar: "Toolbar",
436
+ progressbar: "Progress",
437
+ meter: "Meter",
438
+ separator: "Separator",
439
+ }
440
+ const INPUT = {
441
+ range: "Slider",
442
+ color: "ColorField",
443
+ checkbox: "Checkbox / Switch",
444
+ radio: "RadioGroup",
445
+ number: "NumberField",
446
+ date: "DatePicker",
447
+ "datetime-local": "DatePicker granularity=\"minute\"",
448
+ }
449
+ const NATIVE = { select: "Select", dialog: "Dialog", progress: "Progress", meter: "Meter", hr: "Separator" }
450
+ const out = []
451
+ // Intrinsic elements only: <Radio role=…> is a Sable component already.
452
+ for (const m of openingTags(text, /^[a-z][a-z0-9]*$/)) {
453
+ // `role="x"`, `role={"x"}`, `role={`x`}`; a computed role is not judged.
454
+ const role = /\brole\s*=\s*\{?\s*["'`]([a-z]+)["'`]/.exec(m.attrs)?.[1]
455
+ const type =
456
+ m.tag === "input" ? /\btype\s*=\s*\{?\s*["'`]([a-z-]+)["'`]/.exec(m.attrs)?.[1] : undefined
457
+ const use = (role && ROLE[role]) || (type && INPUT[type]) || NATIVE[m.tag]
458
+ if (!use) continue
459
+ const what = role ? `role="${role}"` : type ? `<input type="${type}">` : `<${m.tag}>`
460
+ out.push({ index: m.index, match: `${what} → use ${use}` })
461
+ }
462
+ return out
463
+ },
464
+ },
465
+ {
466
+ id: "floating-surface",
467
+ scope: "both",
468
+ since: "0.1.0",
469
+ code: true,
470
+ why:
471
+ "A floating surface with a resting surface's fill or edge. `shadow-md` and above mean the element floats (menus, popovers, selects, tooltips, dialogs, sheets, toasts, a floating panel), and a floating surface is `bg-popover text-popover-foreground` with `border-border-floating` (or `ring-border-floating`). " +
472
+ "FILL: `background` is the bottom of the stack and `card` sits on it — background < card < popover in every dark mode — so a floating panel on either reads as sunk beneath what it hovers over (Sable's Customize inspector shipped on `bg-background`; Dialog, Sheet and Toast shipped on `bg-card`). " +
473
+ "EDGE: the shadow carries the elevation; a full `border-border` hairline on top of it stacks into a heavy outline. `border-floating` is the lighter, alpha edge made for this. A device screen or other genuine page ground that carries a shadow takes the escape hatch with the reason.",
474
+ find(line) {
475
+ const out = []
476
+ // One class string at a time: the classes must share an element.
477
+ for (const m of line.matchAll(/(["'`])((?:(?!\1).)*)\1/g)) {
478
+ const cls = m[2]
479
+ // Bare utilities only: `hover:shadow-md` on an interactive card is a
480
+ // state, not a floating layer.
481
+ if (!/(?:^|\s)shadow-(?:md|lg|xl|2xl)(?=\s|$)/.test(cls)) continue
482
+ const fill = /(?:^|\s)bg-(background|card)(?=[\s/]|$)/.exec(cls)?.[1]
483
+ if (fill) out.push({ match: `bg-${fill} + floating shadow → bg-popover` })
484
+ const edge = /(?:^|\s)(border|ring)-border(?=[\s/]|$)/.exec(cls)?.[1]
485
+ if (edge) out.push({ match: `${edge}-border + floating shadow → ${edge}-border-floating` })
486
+ }
487
+ return out
488
+ },
489
+ },
490
+ {
491
+ id: "lookalike-control",
492
+ /* CONSUMER-scoped, the sibling of hand-rolled-control. That rule sees a
493
+ * control by its ROLE (role="radio", <input type="range">); this one sees
494
+ * it by its CLOTHES — a raw element wearing a Sable component's shell.
495
+ * The docs header shipped a <button> dressed as a search field for
496
+ * months, and every styling rule passed it: the classes were all
497
+ * semantic tokens. Correct styling is how a reinvention hides. */
498
+ scope: "consumer",
499
+ since: "0.1.0",
500
+ file: true,
501
+ code: true,
502
+ why:
503
+ "A raw element dressed as a Sable component. `border-input` is the field shell (Input, Textarea, SearchInput, Select…); a `bg-primary` / `bg-secondary` / `bg-destructive` fill or a `--control-*` height on a <button> or <a> is a Button. Hand-built, they drift from the component — no density, no loading or disabled states, no focus contract — and every token on them still passes lint. Use the component: Button (a link takes `render={<a href=… />}`), Input / SearchInput, or CommandMenuTrigger for a search-shaped button that opens a palette. If none fits, that is a library gap: say so, then take the escape hatch with the reason.",
504
+ scan(text) {
505
+ const out = []
506
+ for (const m of openingTags(text, /^[a-z][a-z0-9]*$/)) {
507
+ // Native fields are hand-rolled-control's job; don't double-report.
508
+ if (m.tag === "input" || m.tag === "textarea" || m.tag === "select") continue
509
+ const cls = /\bclassName\s*=\s*\{?\s*(?:cn\()?\s*["'`]([^"'`]*)["'`]/.exec(m.attrs)?.[1] ?? ""
510
+ if (!cls) continue
511
+ if (/(?:^|\s)border-input(?=\s|$)/.test(cls)) {
512
+ out.push({ index: m.index, match: `<${m.tag}> with the field shell (border-input) → use Input / SearchInput / CommandMenuTrigger` })
513
+ continue
514
+ }
515
+ if (m.tag !== "button" && m.tag !== "a") continue
516
+ const fill = /(?:^|\s)bg-(primary|secondary|destructive)(?=\s|$)/.exec(cls)?.[1]
517
+ if (fill) {
518
+ out.push({ index: m.index, match: `<${m.tag}> with a button fill (bg-${fill}) → use Button` })
519
+ continue
520
+ }
521
+ if (/(?:^|\s)h-\(--control-[a-z]+\)/.test(cls)) {
522
+ out.push({ index: m.index, match: `<${m.tag}> sized as a control (h-(--control-*)) → use Button` })
523
+ }
524
+ }
525
+ return out
526
+ },
527
+ },
528
+ {
529
+ id: "hand-derived-surface",
530
+ /* CONSUMER-scoped. Mixing ink into a surface by hand is what the
531
+ * relative-surface utilities compute — and the tell that the library
532
+ * was skipped. Twice in one day Sable's own docs hand-derived a fill
533
+ * (a fixed token, then an outline to rescue it) while sable-surface*
534
+ * sat in styles.css, unlisted. The utilities are catalogued now; this
535
+ * points at them from the place the workaround gets written. */
536
+ scope: "consumer",
537
+ since: "0.1.0",
538
+ code: true,
539
+ why:
540
+ "A colour hand-mixed from the foreground ink — `color-mix(… var(--foreground) …)`. Sable already derives these from the surface underneath, in every theme and mode: `sable-well` (a recessed track, pad or well), `sable-surface` + `sable-surface-interactive` (hover and selected fills on any surface), `bg-hairline` / `border-hairline` (a faint ink edge or fill). `npx sable search` finds them; `registry.json → utilities` explains each. If none fits, it is a gap — report it, then take the escape hatch with the reason.",
541
+ find(line) {
542
+ const out = []
543
+ for (const m of line.matchAll(/color-mix\([^;"'`]*?var\(--foreground\)/g)) {
544
+ out.push({ match: m[0].length > 60 ? `${m[0].slice(0, 57)}…` : m[0] })
545
+ }
546
+ return out
547
+ },
548
+ },
549
+ {
550
+ id: "surface-hover",
551
+ /* A RATCHET in the package, a gate everywhere else. Sable's own
552
+ * components predate sable-surface-interactive and carry a handful of
553
+ * these; design-lint grandfathers them in lint-baseline.json and fails
554
+ * on any new one. sable-lint (apps, consumers) has no baseline, so there
555
+ * it simply fails — the point is that new code uses the recipe. */
556
+ scope: "both",
557
+ ratchet: true,
558
+ ratchetHint: "Use sable-surface-interactive for the state, or disable the rule at the site with a reason if the surface underneath is genuinely fixed.",
559
+ code: true,
560
+ since: "0.1.0",
561
+ why:
562
+ "A hover, highlight or selected state painted with a fixed token (hover:bg-muted, hover:bg-accent, hover:bg-popover, data-[selected]:bg-card…). It only reads on the surface it was designed for: on a well, a muted row or a popover it can match the parent, and the state turns into a hole. Use `sable-surface-interactive`, which steps hover and selected off whatever surface the element sits on — or a Sable component whose states are built in. " +
563
+ "Exempt: an opacity step on the element's own surface (bg-popover/90 hover:bg-popover, bg-card hover:bg-card/85), which is not a surface swap.",
564
+ find(line, ctx) {
565
+ // The utility catalogue's `insteadOf` prose names these patterns as
566
+ // the thing to avoid; it is the one file whose job is to spell them out.
567
+ if (/(?:^|\/)utility-docs\.ts$/.test(ctx?.file ?? "")) return []
568
+ const out = []
569
+ for (const m of line.matchAll(surfaceState)) {
570
+ const surface = m[2]
571
+ // The same surface, unprefixed, as the base on the same class string:
572
+ // an opacity step (bg-popover/90 → hover:bg-popover, bg-card →
573
+ // hover:bg-card/85), which reads on any ground — not a surface swap.
574
+ if (new RegExp(`(?:^|[\\s"'\`])bg-${surface}(?:/|(?![\\w-]))`).test(line)) continue
575
+ out.push({ match: m[0] })
576
+ }
577
+ return out
578
+ },
579
+ },
580
+ {
581
+ id: "control-height-override",
582
+ /* Fields and buttons own their height through `size` (the control
583
+ * ladder). Squeezing one with a height class gives a control that looks
584
+ * like the ladder but isn't on it: ColorField shrank an Input this way and
585
+ * the docs Playground did the same to a SelectTrigger, at a height no
586
+ * component offers. */
587
+ scope: "both",
588
+ code: true,
589
+ since: "0.1.0",
590
+ why:
591
+ "A control-height class passed to a sized component (h-(--control-*) on Input, Button, SelectTrigger or ComboboxInput). These components take their height from `size` — Button: xs | sm | md | cta | icon-*; Input, SelectTrigger, ComboboxInput: md | xs. Use the size prop; if no size fits, that is a gap in the component, not a className.",
592
+ find(line) {
593
+ const out = []
594
+ for (const m of line.matchAll(/<(Input|Button|SelectTrigger|ComboboxInput)\b[^>]*?\bh-\(--control-[a-z]+\)/g)) {
595
+ out.push({ match: `<${m[1]} … h-(--control-…)` })
596
+ }
597
+ return out
598
+ },
599
+ },
600
+ {
601
+ id: "clickable-static",
602
+ /* A click handler on an element that is not a control. It works for a
603
+ * mouse and nothing else: no tab stop, no Enter/Space, no role, no focus
604
+ * ring. hand-rolled-control sees a control by its role and
605
+ * lookalike-control by its clothes; this sees it by its handler, which
606
+ * is the one thing a reinvented button always has. */
607
+ scope: "both",
608
+ since: "0.1.0",
609
+ file: true,
610
+ code: true,
611
+ why:
612
+ "An `onClick` on a non-interactive element (div, span, li, img, td, tr, section, article, p). It reaches mouse users only: no focus, no Enter/Space, no role. Use Button (variant=\"ghost\" for a quiet hit area; `render={<a href=… />}` for a link), or a Sable component whose rows are interactive (Entity, TreeRow, a Menu/Select item). A handler that only stops propagation, or a backdrop whose keyboard path is elsewhere, takes the escape hatch with the reason.",
613
+ scan(text) {
614
+ const STATIC = /^(?:div|span|li|img|td|tr|section|article|p)$/
615
+ const out = []
616
+ for (const m of openingTags(text, STATIC)) {
617
+ if (!/\bonClick\s*=/.test(m.attrs)) continue
618
+ // A declared role (option, treeitem, row…) means a container owns the
619
+ // keyboard model — aria-activedescendant or roving focus. A computed
620
+ // role is not judged.
621
+ if (/\brole\s*=/.test(m.attrs)) continue
622
+ out.push({ index: m.index, match: `<${m.tag} onClick> → use Button or an interactive Sable row` })
623
+ }
624
+ return out
625
+ },
626
+ },
627
+ {
628
+ id: "hand-written-fade",
629
+ /* An edge fade masked by hand onto a scroll region. Scroller owns the
630
+ * edge fade: it shows only on the side that has more, follows the
631
+ * scroll position, and stays off the scrollbar. A static mask fades
632
+ * content that is already fully in view. */
633
+ scope: "both",
634
+ since: "0.1.0",
635
+ code: true,
636
+ why:
637
+ "A hand-written `mask-image` gradient. For a scroll region, Scroller draws the edge fade — only on the side that has more, following the scroll. For streaming text, StreamingText. For clamped text that expands, ShowMore. If none of these fit, take the escape hatch with the reason.",
638
+ find(line, ctx) {
639
+ const file = ctx?.file ?? ""
640
+ // The owners, and the catalogue that names the pattern in prose.
641
+ if (/(?:^|\/)(?:scroller|streaming-text|show-more)\/[^/]+\.tsx?$/.test(file)) return []
642
+ if (/(?:^|\/)utility-docs\.ts$/.test(file)) return []
643
+ if (/(?:^|\/)styles\.css$/.test(file)) return []
644
+ return [...line.matchAll(/mask(?:-image|Image)?[\s:=\[("'`]+[^;"'`]*?linear-gradient|mask-linear-|\[mask-image:/g)].map((m) => ({
645
+ match: m[0].length > 50 ? `${m[0].slice(0, 47)}…` : m[0],
646
+ }))
647
+ },
648
+ },
649
+ {
650
+ id: "raw-button",
651
+ /* PACKAGE-scoped RATCHET. CopyButton and SplitButton both drifted the same
652
+ * way: a component hand-built its own <button> with its own size, icon
653
+ * scale and hover, so it never matched the Button beside it. The existing
654
+ * occurrences (tree rows, chips, select triggers…) are recorded in
655
+ * lint-baseline.json; a NEW raw <button> in a component fails until it is
656
+ * either a composed `Button` or carries a disable with its reason. */
657
+ scope: "package",
658
+ ratchet: true,
659
+ ratchetHint: "Compose Sable's Button (variant + size) instead, or — if this control is genuinely not a button-shaped action (a row, a chip, a trigger with its own anatomy) — disable the rule at the site with the reason.",
660
+ code: true,
661
+ since: "0.1.0",
662
+ why:
663
+ "A component renders a raw <button> instead of composing Sable's Button. A hand-built button picks its own height, padding, icon size, radius, hover and focus ring, so it drifts from every other button (CopyButton was 28px with a 14px glyph beside 32px icon buttons).",
664
+ find(line, ctx) {
665
+ // Button is where the element is allowed to live.
666
+ if (/(?:^|\/)button\/button\.tsx$/.test(ctx?.file ?? "")) return []
667
+ if (/(?:^|\/)[^/]+\.test\.tsx$/.test(ctx?.file ?? "")) return []
668
+ // The utility catalogue names the raw element in prose.
669
+ if (/(?:^|\/)utility-docs\.ts$/.test(ctx?.file ?? "")) return []
670
+ // `<button` may end the line when its attributes follow on the next ones.
671
+ return [...line.matchAll(/<button(?=[\s>]|$)/g)].map(() => ({ match: "<button" }))
672
+ },
673
+ },
674
+ {
675
+ id: "destructive-item-ink",
676
+ /* A destructive menu row painted by hand. MenuItem and ContextMenuItem
677
+ * take `variant="destructive"`, which keeps the destructive ink when the
678
+ * row is highlighted and washes the highlight in the destructive tone. A
679
+ * `className="text-destructive"` loses the ink the moment the default
680
+ * highlight repaints the row — which is the state someone is in when they
681
+ * are about to click it. */
682
+ scope: "both",
683
+ since: "0.1.0",
684
+ file: true,
685
+ code: true,
686
+ why:
687
+ "A menu row coloured by className (text-destructive, a tone ink, or a destructive highlight). MenuItem and ContextMenuItem take `variant=\"destructive\"`, which keeps the ink while highlighted and tints the highlight to match; a class override loses it on hover. Use the variant.",
688
+ scan(text) {
689
+ const out = []
690
+ for (const m of openingTags(text, /^(?:MenuItem|ContextMenuItem)$/)) {
691
+ const hit = classTokens(classNameOf(m.attrs)).find(({ base }) =>
692
+ /^(?:text-(?:destructive|warning|success|info|tone-[a-z-]+)|bg-destructive)(?:\/[\w.[\]]+)?$/.test(base),
693
+ )
694
+ if (hit) out.push({ index: m.index, match: `<${m.tag} className="${hit.raw}"> → variant="destructive"` })
695
+ }
696
+ return out
697
+ },
698
+ },
699
+ {
700
+ id: "component-restyle",
701
+ /* A library component re-skinned from the outside. The look of a Button,
702
+ * Pill or Badge — its fill, edge, corner, ring and shadow — is what its
703
+ * variants are for; an override class gives one call site a look no other
704
+ * site can ask for, and it silently fights the variant's own states
705
+ * (hover, selected, focus) underneath. The fix is a real variant: use the
706
+ * one that exists (Button `variant="floating"` for a control over content,
707
+ * Pill `appearance="outline"` for a quiet chip, Badge `appearance`/`tone`)
708
+ * or add one to the component. Layout classes stay allowed: width, margin,
709
+ * flex/grid placement and positioning are the caller's business. */
710
+ scope: "both",
711
+ since: "0.1.0",
712
+ file: true,
713
+ code: true,
714
+ why:
715
+ "A look-changing class on a Sable component (border*, bg-*, rounded*, ring-*, shadow-* on Button, Pill or Badge). Their appearance is set by variants — Button `variant` (floating for a control over content), Pill `appearance`/`size`, Badge `appearance`/`tone` — and an override fights the variant's own hover, selected and focus states. Use a real variant, or add one to the component. Layout classes (width, margin, flex, position) are fine.",
716
+ scan(text) {
717
+ const out = []
718
+ for (const m of openingTags(text, /^(?:Button|Pill|Badge)$/)) {
719
+ const hit = classTokens(classNameOf(m.attrs)).find(({ base }) =>
720
+ /^-?(?:border|bg|rounded|ring|shadow)(?:-|$)/.test(base),
721
+ )
722
+ if (hit) out.push({ index: m.index, match: `<${m.tag} className="… ${hit.raw} …"> → use a variant` })
723
+ }
724
+ return out
725
+ },
726
+ },
727
+ {
728
+ id: "table-edge-padding",
729
+ /* A table's outer columns inset by hand. Table reads `--table-edge-px`
730
+ * for the first and last column, and CardContent `flush` sets it to the
731
+ * card's own padding (`--card-p`), so a full-bleed table lines up with
732
+ * the CardHeader at every density. A hand-set `pl-6` matches one density
733
+ * and drifts at the other. */
734
+ scope: "both",
735
+ since: "0.1.0",
736
+ file: true,
737
+ code: true,
738
+ why:
739
+ "Inline-edge padding hand-set on a TableHead or TableCell (pl-*, pr-*, ps-*, pe-*). Table insets its first and last columns by `--table-edge-px`; put the table in `<CardContent flush>`, which sets it to the card's padding at every density — or set `[--table-edge-px:…]` on a wrapper. Never pad the cells by hand.",
740
+ scan(text) {
741
+ const out = []
742
+ for (const m of openingTags(text, /^(?:TableHead|TableCell)$/)) {
743
+ const hit = classTokens(classNameOf(m.attrs)).find(({ base }) => /^(?:pl|pr|ps|pe)-/.test(base))
744
+ if (hit) out.push({ index: m.index, match: `<${m.tag} className="${hit.raw}"> → CardContent flush` })
745
+ }
746
+ return out
747
+ },
748
+ },
749
+ {
750
+ id: "popup-separator-bleed",
751
+ /* A separator bled to a popup's edges by a fixed amount. The popup's
752
+ * padding is `popup-inset`, which grows with the radius and the density;
753
+ * a fixed `-mx-1` stops short of the edges once a rounder preset or a
754
+ * roomier density grows the inset. `popup-bleed` bleeds by exactly
755
+ * popup-inset. */
756
+ scope: "both",
757
+ since: "0.1.0",
758
+ file: true,
759
+ code: true,
760
+ why:
761
+ "A separator bled to the popup edge with a fixed negative margin (-mx-1, -mx-[…]). Popup padding is `popup-inset`, which follows the radius and the density; use `popup-bleed`, which bleeds by exactly that amount, or the popup's own *Separator part (MenuSeparator, ContextMenuSeparator, SelectSeparator) which already does.",
762
+ scan(text) {
763
+ const out = []
764
+ const seen = new Set()
765
+ const negX = /(?:^|\s|:)-mx-[\w.[\]()-]+/
766
+ for (const m of openingTags(text, /Separator$/)) {
767
+ const hit = classTokens(classNameOf(m.attrs)).find(({ base }) => /^-mx-/.test(base))
768
+ if (!hit) continue
769
+ seen.add(m.index)
770
+ out.push({ index: m.index, match: `<${m.tag} className="${hit.raw}"> → popup-bleed` })
771
+ }
772
+ // A hand-drawn rule: a 1px-high class string with a negative inline margin.
773
+ for (const s of text.matchAll(/(["'`])((?:(?!\1)[^\n])*)\1/g)) {
774
+ const cls = s[2]
775
+ if (!/(?:^|\s)h-(?:px|\[1px\])(?=\s|$)/.test(cls)) continue
776
+ const hit = negX.exec(cls)
777
+ if (!hit) continue
778
+ // Skip a string already reported through its *Separator tag.
779
+ if ([...seen].some((i) => s.index > i && s.index - i < 400)) continue
780
+ out.push({ index: s.index, match: `${hit[0].trim()} on a 1px rule → popup-bleed` })
781
+ }
782
+ return out
783
+ },
784
+ },
785
+ {
786
+ id: "fixed-spacing",
787
+ /* PACKAGE-scoped. Padding, gaps and insets in Sable's components sit on
788
+ * the spacing scale (`p-4`, `--spacing(4)`, `calc(var(--spacing) * 4)`)
789
+ * because the density ("Character") control works by retuning
790
+ * `--spacing`. A literal `px-[12px]` or `padding: "1.5rem"` is the one
791
+ * measure that does not move, so the component comes out half-dense.
792
+ * Hairlines (≤2px) and em-sized values (which follow the type, by design)
793
+ * are not spacing and are left alone; positional offsets (top-[-6px] on
794
+ * an arrow) are geometry, not spacing, and are not matched. */
795
+ scope: "package",
796
+ since: "0.1.0",
797
+ code: true,
798
+ why:
799
+ "A fixed rem/px padding, margin, gap or inset in a component (p-[1.5rem], px-[12px], gap-[0.5rem], padding: \"12px\", \"--card-p\": \"1.5rem\"). Spacing goes on the scale — a step (p-3, gap-2) or `--spacing(n)` / `calc(var(--spacing) * n)` in an arbitrary value — so the density control moves it. Keep fixed units only for what must not scale (a 1px hairline, a ring width), and disable the rule at the site with that reason.",
800
+ find(line) {
801
+ const out = []
802
+ const tooSmall = (n, unit) => Math.abs(Number(n)) <= (unit === "px" ? 2 : 0.125)
803
+ for (const m of line.matchAll(
804
+ /(?:^|[\s"'`:(])(-?(?:p[xytbrlse]?|m[xytbrlse]?|gap(?:-[xy])?|space-[xy]|inset(?:-[xy])?|scroll-[pm][xytbrlse]?)-\[(-?\d*\.?\d+)(px|rem)\])/g,
805
+ )) {
806
+ if (!tooSmall(m[2], m[3])) out.push({ match: m[1] })
807
+ }
808
+ for (const m of line.matchAll(
809
+ /\b((?:padding|margin|gap|rowGap|columnGap|inset)(?:Inline|Block)?(?:Top|Bottom|Left|Right|Start|End)?\s*:\s*["'`](-?\d*\.?\d+)(px|rem)\b)/g,
810
+ )) {
811
+ if (!tooSmall(m[2], m[3])) out.push({ match: m[1] })
812
+ }
813
+ for (const m of line.matchAll(
814
+ /(["'`]--[\w-]*(?:-p|-px|-py|-pad|-padding|-gap|-inset)["'`]\s*:\s*["'`](-?\d*\.?\d+)(px|rem)["'`])/g,
815
+ )) {
816
+ if (!tooSmall(m[2], m[3])) out.push({ match: m[1] })
817
+ }
818
+ return out
819
+ },
820
+ },
821
+ {
822
+ id: "physical-direction",
823
+ /* PACKAGE-scoped on purpose, and it is a RATCHET rather than a gate.
824
+ *
825
+ * ~56 of Sable's own files carry a physical direction utility today. A rule
826
+ * that fails on all of them the day it lands is a rule someone turns off,
827
+ * so `design-lint` holds a per-file baseline (scripts/physical-direction-
828
+ * baseline.json) and fails only where a file's count goes UP — the same
829
+ * shape as build-registry's guidance floor: set the bar at what the code
830
+ * already clears, then lower the baseline as the debt is paid.
831
+ *
832
+ * It stays out of `sable-lint` (which drops `scope: "package"`) until the
833
+ * backfill lands, because a consumer gets no baseline file and would meet
834
+ * this rule as several hundred unratcheted failures on first upgrade.
835
+ * Promote to `scope: "both"` when Sable's own baseline reaches zero. */
836
+ scope: "package",
837
+ ratchet: true,
838
+ ratchetHint: "Write the new code with logical properties, or — if this file's geometry is genuinely physical — disable the rule at the site with a reason.",
839
+ code: true,
840
+ why:
841
+ "Direction-dependent spacing/position written as PHYSICAL geometry. Use the logical property — ms-*/me-* (margin), ps-*/pe-* (padding), text-start/text-end, and inset-inline-start/end — so an RTL locale mirrors for free instead of needing a parallel stylesheet. " +
842
+ "Exempt, because the geometry really is physical: a device safe-area edge (the notch does not mirror), anything positioned against a transform/translate (the `left-1/2 -translate-x-1/2` centring idiom is symmetric), and a `data-[side=…]:` guard (the positioner already resolved the side for the writing mode).",
843
+ find(line, ctx) {
844
+ // The safe-area layer: `env(safe-area-inset-left)` names a real edge of
845
+ // a real device. Exempted by file as well as by value, because the
846
+ // helper that builds those insets is keyed on the physical edge names.
847
+ if (/(?:^|\/)_?[Ss]afe[Aa]rea\.tsx?$/.test(ctx?.file ?? "")) return []
848
+ // A transform on the same class string means the offset is being used
849
+ // to centre or nudge, not to pick a side. `left-1/2 -translate-x-1/2`
850
+ // mirrors correctly as written; rewriting it logically would be wrong.
851
+ if (/\b(?:translate|rotate|skew|transform)\b/.test(line)) return []
852
+ const out = []
853
+ for (const m of line.matchAll(physicalDirection)) {
854
+ const util = m[1]
855
+ if (util.includes("safe-area-inset")) continue
856
+ // `data-[side=left]:right-[-6px]` — the anchor positioner emits the
857
+ // side it actually resolved to, so the utility is already correct in
858
+ // both writing modes.
859
+ const before = line.slice(0, m.index + m[0].length - util.length)
860
+ if (/data-\[side=[a-z]+\]:$/.test(before)) continue
861
+ out.push({ match: util })
862
+ }
863
+ return out
864
+ },
865
+ },
866
+ {
867
+ id: "unknown-motion",
868
+ scope: "both",
869
+ why: "Unknown motion token. duration-(--…) must reference a --motion-duration-* token, and ease-* must be a real easing.",
870
+ find(line) {
871
+ const out = []
872
+ for (const m of line.matchAll(/\bduration-\(--([a-z0-9-]+)\)/g)) {
873
+ if (!motionTokens.has(m[1])) out.push({ match: m[0] })
874
+ }
875
+ for (const m of line.matchAll(/\bease-([a-z][a-z-]*)\b/g)) {
876
+ if (!easeNames.has(m[1])) out.push({ match: m[0] })
877
+ }
878
+ return out
879
+ },
880
+ },
881
+ ]
882
+ }
883
+
884
+ /* ── Disable-directive parser (with mandatory reason). ─────────────────────────
885
+ A contract still needs a rare, AUDITABLE exception:
886
+ // design-lint-disable-next-line <ruleId> -- <why>
887
+ ...code... // design-lint-disable-line <ruleId> -- <why>
888
+ // design-lint-disable <ruleId> -- <why> (block; until design-lint-enable)
889
+ // design-lint-enable <ruleId>
890
+ A disable with no reason is itself an error — the audit trail is mandatory. */
891
+ export const DIRECTIVE =
892
+ /design-lint-(disable-next-line|disable-line|disable|enable)\s+([a-z-]+)\s*(?:--\s*(.*))?$/
893
+
894
+ export function parseDirectives(lines) {
895
+ const suppress = lines.map(() => new Set())
896
+ const missingReason = []
897
+ const blockOpen = new Map() // ruleId -> startLineIndex (inclusive)
898
+
899
+ lines.forEach((line, i) => {
900
+ const m = line.match(DIRECTIVE)
901
+ if (!m) return
902
+ const [, kind, ruleId, reason] = m
903
+ const hasReason = reason && reason.trim().length > 0
904
+ if (kind !== "enable" && !hasReason) {
905
+ missingReason.push({ line: i + 1, ruleId })
906
+ return // no reason → directive is void, violation will surface
907
+ }
908
+ if (kind === "disable-line") suppress[i].add(ruleId)
909
+ else if (kind === "disable-next-line") {
910
+ if (i + 1 < suppress.length) suppress[i + 1].add(ruleId)
911
+ } else if (kind === "disable") blockOpen.set(ruleId, i)
912
+ else if (kind === "enable") {
913
+ const start = blockOpen.get(ruleId)
914
+ if (start != null) {
915
+ for (let j = start; j <= i; j++) suppress[j].add(ruleId)
916
+ blockOpen.delete(ruleId)
917
+ }
918
+ }
919
+ })
920
+ // An unterminated block disables to EOF.
921
+ for (const [ruleId, start] of blockOpen) {
922
+ for (let j = start; j < suppress.length; j++) suppress[j].add(ruleId)
923
+ }
924
+ return { suppress, missingReason }
925
+ }
926
+
927
+ /**
928
+ * Lint one file's text. Returns [{ line, ruleId, match, why }].
929
+ *
930
+ * Shared so the two linters cannot disagree about what a violation is — including
931
+ * the subtle part: a directive comment's own reason text usually names the very
932
+ * pattern it excepts ("#000 is mask machinery"), so the reason must not itself be
933
+ * scanned.
934
+ */
935
+ /**
936
+ * Blank out comment CONTENT while preserving every newline, so line numbers in
937
+ * the stripped copy still match the original.
938
+ *
939
+ * Rules that opt in with `code: true` are checked against this instead of the
940
+ * raw line. The case that forced it: a doc comment explaining *why* a container
941
+ * uses `overflow-y-auto` is prose, styles nothing, and had no business tripping
942
+ * a rule about scroll containers — and a comment-prefix heuristic cannot see
943
+ * that a continuation line inside a block comment is still a comment.
944
+ *
945
+ * Every rule that matches a CLASS STRING opts in, because the same
946
+ * failure kept recurring and it is worse than mere noise. A codebase that fixed
947
+ * an anti-pattern properly tends to leave a comment SAYING SO — and the comment
948
+ * names the thing it removed. Two independent hits in one afternoon: a Sable doc
949
+ * comment describing the `transition-colors` rule tripped that rule, and a
950
+ * consuming app's comment recording that a stray `z-[60]` had been measured and
951
+ * deleted was reported as a live `raw-z-index`. In both, the linter was flagging
952
+ * the evidence that the work was done. That is precisely how a team concludes
953
+ * the tool cries wolf and stops reading it.
954
+ *
955
+ * Deliberately NOT opted in: `inline-color`, `palette-ref` and the token-name
956
+ * checks. A hex in prose is usually a real reference to a real value, and those
957
+ * two rules are the ones with the strongest "zero sanctioned exceptions" stance
958
+ * — better they over-report into a visible disable than quietly skip a literal
959
+ * that a comment happens to sit beside.
960
+ */
961
+ function stripComments(src) {
962
+ return src
963
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
964
+ .replace(/(^|[^:])\/\/[^\n]*/gm, (m, p) => p + " ".repeat(m.length - p.length))
965
+ }
966
+
967
+ export function lintText(text, rules, ctx) {
968
+ const lines = text.split(/\r?\n/)
969
+ const codeLines = stripComments(text).split(/\r?\n/)
970
+ const { suppress, missingReason } = parseDirectives(lines)
971
+ const out = []
972
+
973
+ for (const { line, ruleId } of missingReason) {
974
+ out.push({
975
+ line,
976
+ ruleId: "directive-reason",
977
+ match: `design-lint-disable ${ruleId}`,
978
+ why: "A design-lint-disable directive MUST state a reason after `--`. The exception has to be auditable.",
979
+ })
980
+ }
981
+
982
+ lines.forEach((line, i) => {
983
+ const di = line.indexOf("design-lint-")
984
+ let scanned = di === -1 ? line : line.slice(0, di)
985
+
986
+ /* In a stylesheet, a custom-property DECLARATION is theme authoring, and
987
+ * blanking it exempts both halves at once: the name (`--brand-500` is a
988
+ * legitimate token to define, even though the same string as a *reference*
989
+ * is a palette leak) and the value (`oklch(…)` is how a colour token is
990
+ * declared at all). A REFERENCE survives, because `var(--zinc-700)` does not
991
+ * match `--zinc-700:` — so borrowing someone's palette step still fires,
992
+ * which is the actual rule. Without this, the linter is unusable on the one
993
+ * file type where colour literals belong. */
994
+ if (ctx?.isStylesheet) scanned = scanned.replace(/--[\w-]+\s*:\s*[^;}]*/g, " ")
995
+ // The same slicing applied to the comment-stripped mirror, for `code` rules.
996
+ const codeLine = codeLines[i] ?? ""
997
+ const scannedCode = di === -1 ? codeLine : codeLine.slice(0, di)
998
+
999
+ for (const rule of rules) {
1000
+ if (rule.file) continue
1001
+ if (suppress[i].has(rule.id)) continue
1002
+ const subject = rule.code ? scannedCode : scanned
1003
+ const hits = rule.find
1004
+ ? rule.find(subject, ctx)
1005
+ : [...subject.matchAll(rule.regex)].map((m) => ({ match: m[0] }))
1006
+ for (const h of hits) {
1007
+ out.push({ line: i + 1, ruleId: rule.id, match: h.match, why: rule.why })
1008
+ }
1009
+ }
1010
+ })
1011
+
1012
+ /* Whole-file rules. A line-at-a-time matcher can only ask questions about one
1013
+ * line, and the useful question about a JSX element ("it reacts to a pointer —
1014
+ * does it do so with motion?") spans the whole tag, which is routinely a dozen
1015
+ * lines of attributes. These get the full text and report a character offset,
1016
+ * which is converted to a line here so `design-lint-disable-next-line` keeps
1017
+ * working exactly as it does for every other rule. */
1018
+ const fileRules = rules.filter((r) => r.file)
1019
+ if (fileRules.length) {
1020
+ // Offset → line, by counting newlines before it. Built once per file.
1021
+ const lineStarts = [0]
1022
+ for (let i = 0; i < text.length; i++) if (text[i] === "\n") lineStarts.push(i + 1)
1023
+ const lineOf = (index) => {
1024
+ let lo = 0
1025
+ let hi = lineStarts.length - 1
1026
+ while (lo < hi) {
1027
+ const mid = (lo + hi + 1) >> 1
1028
+ if (lineStarts[mid] <= index) lo = mid
1029
+ else hi = mid - 1
1030
+ }
1031
+ return lo
1032
+ }
1033
+ const strippedText = stripComments(text)
1034
+ for (const rule of fileRules) {
1035
+ for (const h of rule.scan(rule.code ? strippedText : text, ctx)) {
1036
+ const i = h.line != null ? h.line - 1 : lineOf(h.index ?? 0)
1037
+ if (suppress[i]?.has(rule.id)) continue
1038
+ out.push({ line: i + 1, ruleId: rule.id, match: h.match, why: rule.why })
1039
+ }
1040
+ }
1041
+ }
1042
+
1043
+ return out
1044
+ }
1045
+
1046
+ /** Group + print violations. Shared so both linters read identically. */
1047
+ export function report(label, violations, filesScanned, footer) {
1048
+ if (violations.length === 0) {
1049
+ console.log(`[${label}] ✓ ${filesScanned} files clean — the contract holds.`)
1050
+ return 0
1051
+ }
1052
+ console.error(`[${label}] ✗ ${violations.length} violation(s) in ${filesScanned} files:\n`)
1053
+ const byRule = new Map()
1054
+ for (const v of violations) {
1055
+ if (!byRule.has(v.ruleId)) byRule.set(v.ruleId, [])
1056
+ byRule.get(v.ruleId).push(v)
1057
+ }
1058
+ for (const [ruleId, list] of byRule) {
1059
+ console.error(` ● ${ruleId} — ${list[0].why}`)
1060
+ for (const v of list) console.error(` ${v.file}:${v.line} → ${v.match}`)
1061
+ console.error("")
1062
+ }
1063
+ console.error(footer)
1064
+ return 1
1065
+ }