@sableui/react 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +83 -0
- package/README.md +2 -0
- package/bin/sable.mjs +45 -2
- package/dist/components/breadcrumbs/breadcrumbs.js +10 -9
- package/dist/components/breadcrumbs/breadcrumbs.meta.js +2 -2
- package/dist/components/button/button.meta.js +4 -0
- package/dist/components/chat-composer/chat-composer.js +4 -2
- package/dist/components/chat-composer/chat-composer.meta.js +8 -2
- package/dist/components/command-menu/command-menu.js +9 -2
- package/dist/components/command-menu/command-menu.meta.js +3 -3
- package/dist/components/data-table/data-table.js +117 -64
- package/dist/components/data-table/data-table.meta.js +27 -1
- package/dist/components/form/form.js +30 -9
- package/dist/components/form/form.meta.js +17 -3
- package/dist/components/message-scroller/message-scroller.meta.js +1 -1
- package/dist/components/page-header/page-header.js +1 -0
- package/dist/components/page-header/page-header.meta.js +1 -1
- package/dist/components/settings-menu/settings-menu.js +10 -1
- package/dist/components/settings-menu/settings-menu.meta.js +1 -1
- package/dist/components/sidebar/sidebar.js +1 -1
- package/dist/components/sidebar/sidebar.meta.js +5 -0
- package/dist/components/switch/switch.js +1 -1
- package/dist/components/switch/switch.meta.js +2 -2
- package/dist/components/toggle/toggle.js +2 -1
- package/dist/file-hashes.json +603 -0
- package/dist/icon-sets/reicon.js +13 -1
- package/dist/index.d.ts +80 -13
- package/dist/index.js +1 -1
- package/dist/lib/heading-row.js +3 -2
- package/dist/lib/trim-label.js +17 -4
- package/dist/styles.css +27 -2
- package/llms/components/breadcrumbs.md +1 -1
- package/llms/components/button.md +1 -0
- package/llms/components/chat-composer.md +3 -2
- package/llms/components/command-menu.md +2 -2
- package/llms/components/data-table.md +33 -1
- package/llms/components/form.md +19 -2
- package/llms/components/message-scroller.md +1 -1
- package/llms/components/page-header.md +1 -1
- package/llms/components/settings-menu.md +1 -1
- package/llms/components/sidebar.md +1 -0
- package/llms/components/switch.md +2 -2
- package/llms/tasks/getting-started.md +5 -2
- package/llms/tasks/patching-a-component.md +48 -0
- package/llms/tasks/reviewing-a-design.md +5 -0
- package/llms-full.txt +126 -15
- package/llms.txt +1 -1
- package/package.json +4 -2
- package/registry.json +98 -20
- package/scripts/design-memory.mjs +7 -2
- package/scripts/lint-rules.mjs +32 -1
- package/scripts/patches.mjs +219 -0
- package/scripts/sources.mjs +247 -0
- package/skills/sable/SKILL.md +9 -4
package/registry.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://sable.design/registry.schema.json",
|
|
3
3
|
"name": "@sableui/react",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.1",
|
|
5
5
|
"description": "Themeable React components, semantic tokens, layout patterns, presets, and package-owned agent skills. Default theme: Sable.",
|
|
6
6
|
"engine": {
|
|
7
7
|
"behavior": "@base-ui/react",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"@import \"@sableui/react/styles.css\";",
|
|
29
29
|
"@source \"<node_modules>/@sableui/react/dist/**/*.{js,mjs}\";"
|
|
30
30
|
],
|
|
31
|
+
"cssSources": "That @source ships every component's classes (about 32 KB gzipped). Once the app is built, `sable sources --write src/sable-sources.css` lists just the modules it imports; @import that file in place of the @source. sable check fails while it is stale.",
|
|
31
32
|
"fonts": {
|
|
32
33
|
"sans": "Figtree",
|
|
33
34
|
"mono": "DM Mono",
|
|
@@ -1585,6 +1586,10 @@
|
|
|
1585
1586
|
{
|
|
1586
1587
|
"id": "lookalike-media-frame",
|
|
1587
1588
|
"fails-on": "A cover or media frame built by hand — an `aspect-*` ratio + `overflow-hidden` wrapped around an <img>, <video>, <picture> or GradientArt. That is `AspectBox`: `ratio` as a number reserves the box before the media arrives (no layout shift while a cover loads or a shader starts), it clips and positions what it holds (`relative w-full overflow-hidden`), and it is the one frame the media components share, so a change to how frames behave reaches every cover. Put the radius on it as a className. For a cover with its title beneath (an album, an episode, a product), `MediaTile` composes this frame with fallback art for a missing or broken image and an action revealed on hover. Not flagged: a ratio box with no media in it (a pad, a canvas well), a frame without overflow clipping, an <img> sized by its own aspect class, or a round fixed-size frame (lookalike-avatar's)."
|
|
1589
|
+
},
|
|
1590
|
+
{
|
|
1591
|
+
"id": "form-required-error",
|
|
1592
|
+
"fails-on": "A `required` Field with an `error` prop, inside a `Form` that validates for you. On submit Form checks every Field first, so an empty required one stops the submit with the browser's message (\"Please fill out this field.\") and your onSubmit, which would set `error`, never runs. Either let your code own validation, `<Form noValidate>` (onSubmit always runs and the Field shows your `error`), or let Form validate and word the message on the Field: `messages={{ valueMissing: \"Enter a subject.\" }}`."
|
|
1588
1593
|
}
|
|
1589
1594
|
],
|
|
1590
1595
|
"escapeHatch": {
|
|
@@ -1678,7 +1683,12 @@
|
|
|
1678
1683
|
"volume-low",
|
|
1679
1684
|
"volume-off",
|
|
1680
1685
|
"queue",
|
|
1681
|
-
"music"
|
|
1686
|
+
"music",
|
|
1687
|
+
"tag",
|
|
1688
|
+
"paperclip",
|
|
1689
|
+
"chart",
|
|
1690
|
+
"flag",
|
|
1691
|
+
"at-sign"
|
|
1682
1692
|
]
|
|
1683
1693
|
},
|
|
1684
1694
|
"layoutPrimitives": {
|
|
@@ -4482,8 +4492,8 @@
|
|
|
4482
4492
|
},
|
|
4483
4493
|
{
|
|
4484
4494
|
"name": "BreadcrumbLink",
|
|
4485
|
-
"type": "ComponentPropsWithRef<'a'>",
|
|
4486
|
-
"description": "An interactive ancestor crumb (<a>). Muted by default, brightens to foreground on hover/focus."
|
|
4495
|
+
"type": "ComponentPropsWithRef<'a'> & { render?: useRender.RenderProp }",
|
|
4496
|
+
"description": "An interactive ancestor crumb (<a>). Muted by default, brightens to foreground on hover/focus. render composes another element with the crumb's styling, e.g. a router link: render={<Link to=\"/library\" />}, so navigating stays in the app."
|
|
4487
4497
|
},
|
|
4488
4498
|
{
|
|
4489
4499
|
"name": "BreadcrumbPage",
|
|
@@ -4808,6 +4818,10 @@
|
|
|
4808
4818
|
"guidance": "do",
|
|
4809
4819
|
"description": "Use a single primary button in a view or dense panel so the action hierarchy stays obvious."
|
|
4810
4820
|
},
|
|
4821
|
+
{
|
|
4822
|
+
"guidance": "do",
|
|
4823
|
+
"description": "For a copy control, pair an icon Button with `useCopy(text, { timeout? })`. It takes the text when the hook is called and returns `{ copy, copied }`; `copy()` takes no argument, so pass the value the button copies to the hook, not to `copy`."
|
|
4824
|
+
},
|
|
4811
4825
|
{
|
|
4812
4826
|
"guidance": "do",
|
|
4813
4827
|
"description": "Write labels as actions, such as \"Save changes\" or \"Delete draft\", instead of vague labels like \"OK\"."
|
|
@@ -5647,7 +5661,7 @@
|
|
|
5647
5661
|
"group": "Chat & AI",
|
|
5648
5662
|
"register": "either",
|
|
5649
5663
|
"path": "components/chat-composer",
|
|
5650
|
-
"summary": "The input half of a conversation: one surface holding attachments, an auto-growing field, and its controls. Enter sends, Shift+Enter breaks the line, and send becomes stop while a reply streams.",
|
|
5664
|
+
"summary": "The input half of a conversation: one surface holding attachments, an auto-growing field, and its controls. Enter sends, Shift+Enter breaks the line (sendOn=\"mod-enter\" swaps them for long-form replies), and send becomes stop while a reply streams.",
|
|
5651
5665
|
"keywords": [
|
|
5652
5666
|
"chat input",
|
|
5653
5667
|
"composer",
|
|
@@ -5691,7 +5705,7 @@
|
|
|
5691
5705
|
},
|
|
5692
5706
|
{
|
|
5693
5707
|
"guidance": "dont",
|
|
5694
|
-
"description": "Do not use it as a general multi-line input. That is `Textarea
|
|
5708
|
+
"description": "Do not use it as a general multi-line input inside a larger form. That is `Textarea`. A standalone reply box that sends multi-paragraph messages (email, support replies) is this one with sendOn=\"mod-enter\"."
|
|
5695
5709
|
},
|
|
5696
5710
|
{
|
|
5697
5711
|
"guidance": "dont",
|
|
@@ -5765,6 +5779,12 @@
|
|
|
5765
5779
|
"default": "\"Send a message…\"",
|
|
5766
5780
|
"description": "Placeholder for the field."
|
|
5767
5781
|
},
|
|
5782
|
+
{
|
|
5783
|
+
"name": "sendOn",
|
|
5784
|
+
"type": "\"enter\" | \"mod-enter\"",
|
|
5785
|
+
"default": "\"enter\"",
|
|
5786
|
+
"description": "Which key sends. enter for chat (Shift+Enter breaks the line). mod-enter for long-form replies such as an email or a support answer: Enter breaks the line and Cmd/Ctrl+Enter sends, so a paragraph break never mails a draft. Say so in footer (\"Cmd+Enter to send\")."
|
|
5787
|
+
},
|
|
5768
5788
|
{
|
|
5769
5789
|
"name": "busy",
|
|
5770
5790
|
"type": "boolean",
|
|
@@ -7019,7 +7039,7 @@
|
|
|
7019
7039
|
{
|
|
7020
7040
|
"name": "items",
|
|
7021
7041
|
"type": "CommandItem[]",
|
|
7022
|
-
"description": "Data-driven commands ({ group, label, onSelect, id?, icon?, detail?, hint?, disabled? }). Ignored when children are provided. id is optional identity: filter is handed these same objects, so it is how a caller that ranks over its own richer model maps the array back onto it — and it becomes the row's DOM id (namespaced to the palette) and React key, so two rows may share a label."
|
|
7042
|
+
"description": "Data-driven commands ({ group, label, onSelect, id?, icon?, detail?, stacked?, hint?, disabled? }). stacked puts detail on its own line under the label, for results only their detail tells apart (two tickets with one subject); beside a long label an inline detail truncates first. Ignored when children are provided. id is optional identity: filter is handed these same objects, so it is how a caller that ranks over its own richer model maps the array back onto it — and it becomes the row's DOM id (namespaced to the palette) and React key, so two rows may share a label."
|
|
7023
7043
|
},
|
|
7024
7044
|
{
|
|
7025
7045
|
"name": "query",
|
|
@@ -7100,8 +7120,8 @@
|
|
|
7100
7120
|
},
|
|
7101
7121
|
{
|
|
7102
7122
|
"name": "CommandMenuItem",
|
|
7103
|
-
"type": "{ onSelect?: () => void; icon?: string; detail?: ReactNode; hint?: ReactNode; disabled?: boolean; id?: string; children }",
|
|
7104
|
-
"description": "A selectable row. Self-filters by its text (unless the root was given a filter), runs onSelect, then closes the palette. detail is secondary text after the label; hint is the end-aligned trailing slot — a shortcut, a count, or a disabled row's reason. A disabled row stays listed, stays announced, and stays reachable by arrow key so its reason can be read, but is inert to Enter and to a click."
|
|
7123
|
+
"type": "{ onSelect?: () => void; icon?: string; detail?: ReactNode; stacked?: boolean; hint?: ReactNode; disabled?: boolean; id?: string; children }",
|
|
7124
|
+
"description": "A selectable row. Self-filters by its text (unless the root was given a filter), runs onSelect, then closes the palette. detail is secondary text after the label, or under it with stacked; hint is the end-aligned trailing slot — a shortcut, a count, or a disabled row's reason. A disabled row stays listed, stays announced, and stays reachable by arrow key so its reason can be read, but is inert to Enter and to a click."
|
|
7105
7125
|
}
|
|
7106
7126
|
],
|
|
7107
7127
|
"tokensUsed": [
|
|
@@ -7520,7 +7540,29 @@
|
|
|
7520
7540
|
{
|
|
7521
7541
|
"name": "isRowSelected",
|
|
7522
7542
|
"type": "(row) => boolean",
|
|
7523
|
-
"description": "Mark a row visually selected (data-selected). Derive it from a stable id (row.id === selectedId), not the row index, so selection survives re-sorts."
|
|
7543
|
+
"description": "Mark a row visually selected (data-selected), e.g. the one open in a detail pane. Derive it from a stable id (row.id === selectedId), not the row index, so selection survives re-sorts. For checking several rows, use selectedKeys."
|
|
7544
|
+
},
|
|
7545
|
+
{
|
|
7546
|
+
"name": "selectedKeys",
|
|
7547
|
+
"type": "ReadonlySet<string | number>",
|
|
7548
|
+
"description": "The checked rows, by rowKey, for bulk actions. With onSelectedKeysChange it adds a checkbox column (each box named by rowSelectionLabel) and a select-all box in the header that acts on the rows shown and goes mixed when some are checked. Checked rows get the selected fill. Controlled: when the rows shown change (a filter, another view), set the keys to keep, usually none. Needs rowKey."
|
|
7549
|
+
},
|
|
7550
|
+
{
|
|
7551
|
+
"name": "onSelectedKeysChange",
|
|
7552
|
+
"type": "(keys: Set<string | number>) => void",
|
|
7553
|
+
"description": "Called with the new set of checked keys. Its presence turns selection on. A click on a box never fires onRowClick."
|
|
7554
|
+
},
|
|
7555
|
+
{
|
|
7556
|
+
"name": "rowSelectionLabel",
|
|
7557
|
+
"type": "(row) => string",
|
|
7558
|
+
"default": "() => \"Select row\"",
|
|
7559
|
+
"description": "A row checkbox's accessible name. Name the row, e.g. (t) => `Select ${t.subject}`, so a screen reader can tell the boxes apart."
|
|
7560
|
+
},
|
|
7561
|
+
{
|
|
7562
|
+
"name": "selectAllLabel",
|
|
7563
|
+
"type": "(count: number) => string",
|
|
7564
|
+
"default": "(count) => `Select all ${count} shown`",
|
|
7565
|
+
"description": "The select-all checkbox's accessible name; say what it acts on (\"Select all 7 tickets shown\")."
|
|
7524
7566
|
},
|
|
7525
7567
|
{
|
|
7526
7568
|
"name": "defaultSort",
|
|
@@ -7550,6 +7592,11 @@
|
|
|
7550
7592
|
"Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure."
|
|
7551
7593
|
],
|
|
7552
7594
|
"examples": [
|
|
7595
|
+
{
|
|
7596
|
+
"label": "Checkbox selection with a bulk-action bar",
|
|
7597
|
+
"code": "const [selected, setSelected] = useState<Set<string | number>>(new Set())\n\n<Stack gap={2}>\n\t{/* Mounted from the start, so a screen reader hears each change. */}\n\t<span role=\"status\" className=\"sr-only\">{selected.size ? `${selected.size} of ${tickets.length} selected` : \"\"}</span>\n\t{selected.size ? (\n\t\t<Toolbar aria-label=\"Bulk actions\">\n\t\t\t<span className=\"text-label-md\">{selected.size} of {tickets.length} selected</span>\n\t\t\t<Button size=\"sm\" variant=\"secondary\" onClick={() => resolve(selected)}>Resolve</Button>\n\t\t</Toolbar>\n\t) : null}\n\t<DataTable\n\t\tdata={tickets}\n\t\trowKey={(t) => t.id}\n\t\tcolumns={columns}\n\t\tselectedKeys={selected}\n\t\tonSelectedKeysChange={setSelected}\n\t\trowSelectionLabel={(t) => `Select ${t.subject}`}\n\t\tselectAllLabel={(n) => `Select all ${n} tickets shown`}\n\t/>\n</Stack>",
|
|
7598
|
+
"imports": "import { Button, DataTable, Stack, Toolbar } from \"@sableui/react\""
|
|
7599
|
+
},
|
|
7553
7600
|
{
|
|
7554
7601
|
"label": "Sortable columns",
|
|
7555
7602
|
"code": "<DataTable data={users} columns={[{ key: \"name\", header: \"Name\", sortable: true }, { key: \"role\", header: \"Role\" }, { key: \"joined\", header: \"Joined\", sortable: true, sortValue: (u) => u.joinedAt, cell: (u) => formatDate(u.joinedAt) }]} />",
|
|
@@ -9523,7 +9570,7 @@
|
|
|
9523
9570
|
{
|
|
9524
9571
|
"name": "Form",
|
|
9525
9572
|
"type": "{ validationMode?: \"onSubmit\" | \"onBlur\" | \"onChange\"; errors?: Record<string, string | string[]>; onFormSubmit?: (values, details) => void; actionsRef?: RefObject<Form.Actions> } + ComponentPropsWithRef<\"form\">",
|
|
9526
|
-
"description": "A <form> laid out as a vertical column with comfortable field spacing (override with className, e.g. gap-4). On submit it validates every Field (native constraints plus Field validate), shows each message under its field, focuses the first invalid control, and only then calls onSubmit. validationMode defaults to \"onSubmit\" (then live re-validation on change). errors shows external (e.g. server) messages keyed by Field name and clears one when its field is edited. onFormSubmit receives the named Field values and prevents the native submit. A control outside any Field is still checked by the browser before onSubmit runs
|
|
9573
|
+
"description": "A <form> laid out as a vertical column with comfortable field spacing (override with className, e.g. gap-4). On submit it validates every Field (native constraints plus Field validate), shows each message under its field, focuses the first invalid control, and only then calls onSubmit. validationMode defaults to \"onSubmit\" (then live re-validation on change). errors shows external (e.g. server) messages keyed by Field name and clears one when its field is edited. onFormSubmit receives the named Field values and prevents the native submit. A control outside any Field is still checked by the browser before onSubmit runs. Validating in your own code (react-hook-form, a schema, state)? Pass noValidate: onSubmit always runs and each Field shows the error you set; without it an empty required Field stops the submit before your handler can set that error."
|
|
9527
9574
|
},
|
|
9528
9575
|
{
|
|
9529
9576
|
"name": "validationMode",
|
|
@@ -9541,6 +9588,12 @@
|
|
|
9541
9588
|
"type": "(values: Record<string, unknown>, details) => void",
|
|
9542
9589
|
"description": "Called with the named Field values after validation passes; prevents the native submit."
|
|
9543
9590
|
},
|
|
9591
|
+
{
|
|
9592
|
+
"name": "noValidate",
|
|
9593
|
+
"type": "boolean",
|
|
9594
|
+
"default": "false",
|
|
9595
|
+
"description": "Your code owns validation: nothing is checked before onSubmit (neither the Fields nor the browser), so it always runs and each Field shows the error you pass. required Fields keep their asterisk and aria-required. validationMode, errors, onFormSubmit and actionsRef do nothing with it."
|
|
9596
|
+
},
|
|
9544
9597
|
{
|
|
9545
9598
|
"name": "actionsRef",
|
|
9546
9599
|
"type": "RefObject<{ validate(name?: string): void }>",
|
|
@@ -9550,8 +9603,8 @@
|
|
|
9550
9603
|
"subcomponents": [
|
|
9551
9604
|
{
|
|
9552
9605
|
"name": "Field",
|
|
9553
|
-
"type": "{ label?: ReactNode; description?: ReactNode; error?: ReactNode; required?: boolean; disabled?: boolean; name?: string; validate?: (value, formValues) => string | string[] | null | Promise<…>; validationMode?: \"onSubmit\" | \"onBlur\" | \"onChange\"; validationDebounceTime?: number; orientation?: \"vertical\" | \"horizontal\"; className?: string; children: ReactElement }",
|
|
9554
|
-
"description": "Wraps a single control element (e.g. <Input />) on Base UI Field and wires label (htmlFor + aria-labelledby), helper text and error (aria-describedby), aria-invalid, and required onto it automatically. Base UI-backed controls (Input, Textarea, Select, Combobox, NumberField, Checkbox, Switch, Slider, OtpField…) register through context; the child is also cloned so plain elements keep the same wiring. error renders a destructive message and takes precedence; without it the slot shows Base UI's validation message
|
|
9606
|
+
"type": "{ label?: ReactNode; description?: ReactNode; error?: ReactNode; required?: boolean; messages?: Partial<Record<\"valueMissing\" | \"typeMismatch\" | \"patternMismatch\" | \"tooShort\" | \"tooLong\" | \"rangeUnderflow\" | \"rangeOverflow\" | \"stepMismatch\" | \"badInput\", ReactNode>>; disabled?: boolean; name?: string; validate?: (value, formValues) => string | string[] | null | Promise<…>; validationMode?: \"onSubmit\" | \"onBlur\" | \"onChange\"; validationDebounceTime?: number; orientation?: \"vertical\" | \"horizontal\"; className?: string; children: ReactElement }",
|
|
9607
|
+
"description": "Wraps a single control element (e.g. <Input />) on Base UI Field and wires label (htmlFor + aria-labelledby), helper text and error (aria-describedby), aria-invalid, and required onto it automatically. Base UI-backed controls (Input, Textarea, Select, Combobox, NumberField, Checkbox, Switch, Slider, OtpField…) register through context; the child is also cloned so plain elements keep the same wiring. error renders a destructive message and takes precedence; without it the slot shows the field's messages entry for the native constraint that failed (messages={{ valueMissing: \"Enter a subject.\" }}), else Base UI's validation message (the browser's wording for a native constraint, validate, or Form errors). required adds the native attribute plus a visual asterisk (defaults to false). disabled disables the control and dims the label. name keys the field in Form errors / onFormSubmit. validationMode overrides the Form's. orientation defaults to \"vertical\" (label → control); \"horizontal\" places control then label inline for a single checkbox / switch / radio — the control centres on the label's first line (it stays there when the label wraps) and description/error sit in the label's column. The root, label, description, error and control carry data-invalid / data-valid, data-touched, data-dirty, data-filled, data-focused and data-disabled for state styling."
|
|
9555
9608
|
}
|
|
9556
9609
|
],
|
|
9557
9610
|
"tokensUsed": [
|
|
@@ -9575,6 +9628,16 @@
|
|
|
9575
9628
|
"code": "<Field label=\"Password\" error=\"Must be at least 8 characters.\"><Input type=\"password\" /></Field>",
|
|
9576
9629
|
"imports": "import { Field, Input } from \"@sableui/react\""
|
|
9577
9630
|
},
|
|
9631
|
+
{
|
|
9632
|
+
"label": "Your own wording for a missing value",
|
|
9633
|
+
"code": "<Form onSubmit={handleSubmit}><Field label=\"Subject\" required messages={{ valueMissing: \"Enter a subject.\" }}><Input name=\"subject\" /></Field><Button type=\"submit\">Send</Button></Form>",
|
|
9634
|
+
"imports": "import { Button, Field, Form, Input } from \"@sableui/react\""
|
|
9635
|
+
},
|
|
9636
|
+
{
|
|
9637
|
+
"label": "Validation in your own code",
|
|
9638
|
+
"code": "<Form noValidate onSubmit={handleSubmit}><Field label=\"Subject\" required error={errors.subject}><Input name=\"subject\" /></Field><Button type=\"submit\">Send</Button></Form>",
|
|
9639
|
+
"imports": "import { Button, Field, Form, Input } from \"@sableui/react\""
|
|
9640
|
+
},
|
|
9578
9641
|
{
|
|
9579
9642
|
"label": "Validated on submit",
|
|
9580
9643
|
"code": "<Form onSubmit={handleSubmit}><Field label=\"Handle\" required validate={(value) => String(value).length < 3 ? \"At least 3 characters.\" : null}><Input name=\"handle\" /></Field><Button type=\"submit\">Claim</Button></Form>",
|
|
@@ -11736,7 +11799,7 @@
|
|
|
11736
11799
|
{
|
|
11737
11800
|
"name": "ref",
|
|
11738
11801
|
"type": "Ref<MessageScrollerHandle>",
|
|
11739
|
-
"description": "Imperative commands, not the DOM node: `scrollToBottom(opts)`, `scrollToTop(opts)`, `scrollToMessage(id, opts)` (returns false when the message is not rendered), `isFollowing()`, and `element` for the scrolling node itself."
|
|
11802
|
+
"description": "Imperative commands, not the DOM node: `scrollToBottom(opts)`, `scrollToTop(opts)`, `scrollToMessage(id, opts)` (returns false when the message is not rendered), `isFollowing()`, and `element` for the scrolling node itself. `opts` is `ScrollCommandOptions`: `{ animate?: boolean }` (default true, and off whenever motion is), not the DOM's `behavior`."
|
|
11740
11803
|
},
|
|
11741
11804
|
{
|
|
11742
11805
|
"name": "autoFollow",
|
|
@@ -12885,7 +12948,7 @@
|
|
|
12885
12948
|
{
|
|
12886
12949
|
"name": "actions",
|
|
12887
12950
|
"type": "ReactNode",
|
|
12888
|
-
"description": "Trailing actions aligned to the title row's end. They drop under the title when the
|
|
12951
|
+
"description": "Trailing actions aligned to the title row's end. They drop under the title when the title would have less than 20rem beside them (on a phone, nearly always), and the title wraps rather than truncating. A ghost or link Button at either end of the group bleeds by its own padding, so its label meets the content edge (the end on the title row, the start once wrapped); filled buttons keep their edge on the line. Direct children only."
|
|
12889
12952
|
},
|
|
12890
12953
|
{
|
|
12891
12954
|
"name": "titleAs",
|
|
@@ -15967,7 +16030,7 @@
|
|
|
15967
16030
|
"group": "Overlays",
|
|
15968
16031
|
"register": "either",
|
|
15969
16032
|
"path": "components/settings-menu",
|
|
15970
|
-
"summary": "A compact trigger that states the current settings, over a menu of rows that change them. Each row shows its own value and opens a submenu.",
|
|
16033
|
+
"summary": "A compact trigger that states the current settings, over a menu of rows that change them. Each row shows its own value and opens a submenu; with one setting, the menu opens straight to its options under the row's label.",
|
|
15971
16034
|
"keywords": [
|
|
15972
16035
|
"settings",
|
|
15973
16036
|
"model picker",
|
|
@@ -16492,6 +16555,11 @@
|
|
|
16492
16555
|
}
|
|
16493
16556
|
],
|
|
16494
16557
|
"subcomponents": [
|
|
16558
|
+
{
|
|
16559
|
+
"name": "SidebarHeader",
|
|
16560
|
+
"type": "div props",
|
|
16561
|
+
"description": "The top of the rail, at least 64px tall. It holds SidebarBrand and may take a second row under it, such as a search field or a notifications button: give that row basis-full and the header grows to hold it instead of clipping."
|
|
16562
|
+
},
|
|
16495
16563
|
{
|
|
16496
16564
|
"name": "SidebarBrand",
|
|
16497
16565
|
"type": "{ mark: ReactNode; label: ReactNode; render?: useRender.RenderProp } + div props",
|
|
@@ -17883,7 +17951,7 @@
|
|
|
17883
17951
|
"group": "Selection",
|
|
17884
17952
|
"register": "either",
|
|
17885
17953
|
"path": "components/switch",
|
|
17886
|
-
"summary": "Binary on/off toggle for immediate-effect settings. Track fills with the primary tone when checked; the thumb slides on a spring-eased transform (mirrored in RTL).",
|
|
17954
|
+
"summary": "Binary on/off toggle for immediate-effect settings. Track fills with the primary tone when checked; the thumb slides on a spring-eased transform (mirrored in RTL). The thumb is white in both states, like every physical toggle, and turns dark only on a near-white track (a dark mode's light primary). Themes can set --theme-switch-thumb-on.",
|
|
17887
17955
|
"keywords": [
|
|
17888
17956
|
"switch",
|
|
17889
17957
|
"toggle setting",
|
|
@@ -17966,7 +18034,6 @@
|
|
|
17966
18034
|
"tokensUsed": [
|
|
17967
18035
|
"input",
|
|
17968
18036
|
"primary",
|
|
17969
|
-
"primary-foreground",
|
|
17970
18037
|
"ring"
|
|
17971
18038
|
],
|
|
17972
18039
|
"motion": [
|
|
@@ -23773,6 +23840,9 @@
|
|
|
23773
23840
|
"render",
|
|
23774
23841
|
"showLabel"
|
|
23775
23842
|
],
|
|
23843
|
+
"BreadcrumbLink": [
|
|
23844
|
+
"render"
|
|
23845
|
+
],
|
|
23776
23846
|
"BreadcrumbSeparator": [
|
|
23777
23847
|
"children"
|
|
23778
23848
|
],
|
|
@@ -23942,6 +24012,7 @@
|
|
|
23942
24012
|
"onValueChange",
|
|
23943
24013
|
"placeholder",
|
|
23944
24014
|
"sendLabel",
|
|
24015
|
+
"sendOn",
|
|
23945
24016
|
"stopLabel",
|
|
23946
24017
|
"value"
|
|
23947
24018
|
],
|
|
@@ -24213,7 +24284,8 @@
|
|
|
24213
24284
|
"disabled",
|
|
24214
24285
|
"hint",
|
|
24215
24286
|
"icon",
|
|
24216
|
-
"onSelect"
|
|
24287
|
+
"onSelect",
|
|
24288
|
+
"stacked"
|
|
24217
24289
|
],
|
|
24218
24290
|
"CommandMenuPanel": [
|
|
24219
24291
|
"children",
|
|
@@ -24351,7 +24423,11 @@
|
|
|
24351
24423
|
"empty",
|
|
24352
24424
|
"isRowSelected",
|
|
24353
24425
|
"onRowClick",
|
|
24354
|
-
"
|
|
24426
|
+
"onSelectedKeysChange",
|
|
24427
|
+
"rowKey",
|
|
24428
|
+
"rowSelectionLabel",
|
|
24429
|
+
"selectAllLabel",
|
|
24430
|
+
"selectedKeys"
|
|
24355
24431
|
],
|
|
24356
24432
|
"DatePicker": [
|
|
24357
24433
|
"className",
|
|
@@ -24536,6 +24612,7 @@
|
|
|
24536
24612
|
"disabled",
|
|
24537
24613
|
"error",
|
|
24538
24614
|
"label",
|
|
24615
|
+
"messages",
|
|
24539
24616
|
"name",
|
|
24540
24617
|
"orientation",
|
|
24541
24618
|
"required",
|
|
@@ -24592,6 +24669,7 @@
|
|
|
24592
24669
|
"Form": [
|
|
24593
24670
|
"actionsRef",
|
|
24594
24671
|
"errors",
|
|
24672
|
+
"noValidate",
|
|
24595
24673
|
"onFormSubmit",
|
|
24596
24674
|
"validationMode"
|
|
24597
24675
|
],
|
|
@@ -95,7 +95,12 @@ const template = {
|
|
|
95
95
|
avoidWhen: "",
|
|
96
96
|
change: { before: "", after: "" },
|
|
97
97
|
provenance: { kind: "build-review", source: "", date: "", reviewer: "", sableVersion: "" },
|
|
98
|
-
|
|
98
|
+
// The paths say their own rules, which recording enforces: a dogfood run met
|
|
99
|
+
// both only as ERR_LESSON_INVALID after filling the template in.
|
|
100
|
+
evidence: [
|
|
101
|
+
{ kind: "before", path: "<a non-empty file inside this project, relative to its root, e.g. design/evidence/before.png>", observation: "" },
|
|
102
|
+
{ kind: "after", path: "<a different file from the before one, also inside the project>", observation: "" },
|
|
103
|
+
],
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
function main() {
|
|
@@ -118,7 +123,7 @@ function main() {
|
|
|
118
123
|
const actions = ["--list", "--search", "--show", "--record", "--template", "--check"].filter((flag) => flags.has(flag))
|
|
119
124
|
if (actions.length > 1) return fail("ERR_BAD_FLAG", "Choose one of --list, --search, --show, --record, --template, --check.")
|
|
120
125
|
if (flags.has("--help")) {
|
|
121
|
-
process.stdout.write("sable learn — retrieve precedents and record project-owned lessons\n\n --list | --search <words> | --show <id> | --template | --record <lesson.json> | --check\n --json structured { type, data } output\n\nRun from the project root. Evidence paths are relative to that root. Records are provisional reviewer observations, never automatic proof of taste.\n")
|
|
126
|
+
process.stdout.write("sable learn — retrieve precedents and record project-owned lessons\n\n --list | --search <words> | --show <id> | --template | --record <lesson.json> | --check\n --json structured { type, data } output\n\nRun from the project root. Evidence paths are relative to that root, must stay inside it, and before and after must be different files. Records are provisional reviewer observations, never automatic proof of taste.\n")
|
|
122
127
|
return
|
|
123
128
|
}
|
|
124
129
|
if (flags.has("--template")) {
|
package/scripts/lint-rules.mjs
CHANGED
|
@@ -1038,6 +1038,37 @@ export function makeRules({ semanticTokens, motionTokens, labelContrast = null,
|
|
|
1038
1038
|
return ctx?.isStylesheet && statusText ? statusText(text) : []
|
|
1039
1039
|
},
|
|
1040
1040
|
},
|
|
1041
|
+
{
|
|
1042
|
+
id: "form-required-error",
|
|
1043
|
+
/* CONSUMER-scoped. A support app set `error` on a required Field and
|
|
1044
|
+
* never saw it: Form validates every Field before it calls onSubmit, so
|
|
1045
|
+
* an empty one stopped the submit with the browser's wording and the
|
|
1046
|
+
* handler that sets `error` never ran. */
|
|
1047
|
+
scope: "consumer",
|
|
1048
|
+
since: "0.8.0",
|
|
1049
|
+
file: true,
|
|
1050
|
+
code: true,
|
|
1051
|
+
why:
|
|
1052
|
+
"A `required` Field with an `error` prop, inside a `Form` that validates for you. On submit Form checks every Field first, so an empty required one stops the submit with the browser's message (\"Please fill out this field.\") and your onSubmit, which would set `error`, never runs. Either let your code own validation, `<Form noValidate>` (onSubmit always runs and the Field shows your `error`), or let Form validate and word the message on the Field: `messages={{ valueMissing: \"Enter a subject.\" }}`.",
|
|
1053
|
+
scan(text) {
|
|
1054
|
+
const src = maskMarkupTemplates(text)
|
|
1055
|
+
const out = []
|
|
1056
|
+
// A boolean prop is on when written bare or as anything but `={false}`.
|
|
1057
|
+
const flag = (attrs, name) => {
|
|
1058
|
+
const m = new RegExp(`(?:^|\\s)${name}(?![\\w-])(\\s*=\\s*\\{\\s*false\\s*\\})?`).exec(attrs)
|
|
1059
|
+
return Boolean(m) && !m[1]
|
|
1060
|
+
}
|
|
1061
|
+
for (const form of openingTags(src, /^Form$/)) {
|
|
1062
|
+
if (flag(form.attrs, "noValidate")) continue
|
|
1063
|
+
const { inner } = elementContent(src, form)
|
|
1064
|
+
for (const field of openingTags(inner, /^Field$/)) {
|
|
1065
|
+
if (!flag(field.attrs, "required") || !/(?:^|\s)error\s*=/.test(field.attrs)) continue
|
|
1066
|
+
out.push({ index: form.end + 1 + field.index, match: "required Field with error inside a validating Form → <Form noValidate> or messages={{ valueMissing }}" })
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
return out
|
|
1070
|
+
},
|
|
1071
|
+
},
|
|
1041
1072
|
{
|
|
1042
1073
|
id: "hand-derived-surface",
|
|
1043
1074
|
/* CONSUMER-scoped. Mixing ink into a surface by hand is what the
|
|
@@ -1229,7 +1260,7 @@ export function makeRules({ semanticTokens, motionTokens, labelContrast = null,
|
|
|
1229
1260
|
file: true,
|
|
1230
1261
|
code: true,
|
|
1231
1262
|
why:
|
|
1232
|
-
"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.",
|
|
1263
|
+
"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. If the component is defective rather than missing a look, patch it with `sable patch <Component>` (recorded, and re-checked on every upgrade) instead of restyling it.",
|
|
1233
1264
|
scan(text) {
|
|
1234
1265
|
const out = []
|
|
1235
1266
|
for (const m of openingTags(text, /^(?:Button|Pill|Badge)$/)) {
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sable patch — fix a Sable component in place while the library fix ships, and
|
|
3
|
+
* hear about it on every upgrade.
|
|
4
|
+
*
|
|
5
|
+
* A defect in a component left an app two choices: restyle it with className,
|
|
6
|
+
* which `component-restyle` rightly flags, or wait for a release. A patch is the
|
|
7
|
+
* third: the project's own package manager patches the installed module
|
|
8
|
+
* (`pnpm patch`, `yarn patch`, or patch-package for npm), and this records why,
|
|
9
|
+
* against which version, and how to tell whether it is still needed:
|
|
10
|
+
*
|
|
11
|
+
* .sable/patches.json { patches: [{ component, files, version, base, reason, repro, issue? }] }
|
|
12
|
+
*
|
|
13
|
+
* `base` holds the hash of each file as Sable shipped it at that version
|
|
14
|
+
* (dist/file-hashes.json), so `sable doctor` can tell three things on a later
|
|
15
|
+
* install: whether the patch is applied, whether Sable has changed the file
|
|
16
|
+
* since, and what the changelog says about the component in between. A patch
|
|
17
|
+
* whose file Sable changed fails doctor (and so `sable check`) until someone
|
|
18
|
+
* re-runs the repro and keeps the patch or drops it. Nothing changes silently,
|
|
19
|
+
* in either direction.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from "node:crypto"
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
23
|
+
import { dirname, join, parse, relative, resolve } from "node:path"
|
|
24
|
+
import { fileURLToPath } from "node:url"
|
|
25
|
+
import { packageManager } from "./agent-setup.mjs"
|
|
26
|
+
import { parseFlags } from "./design-preferences.mjs"
|
|
27
|
+
|
|
28
|
+
export const PATCHES_FILE = join(".sable", "patches.json")
|
|
29
|
+
const PACKAGE = "@sableui/react"
|
|
30
|
+
|
|
31
|
+
const hash = (path) => createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 16)
|
|
32
|
+
|
|
33
|
+
/** Hashes of every dist file as published, written at build time. */
|
|
34
|
+
function shippedHashes(pkgRoot) {
|
|
35
|
+
const path = join(pkgRoot, "dist", "file-hashes.json")
|
|
36
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readPatches(root) {
|
|
40
|
+
const path = join(root, PATCHES_FILE)
|
|
41
|
+
if (!existsSync(path)) return []
|
|
42
|
+
const data = JSON.parse(readFileSync(path, "utf8"))
|
|
43
|
+
if (!Array.isArray(data?.patches)) throw Object.assign(new Error(`${PATCHES_FILE} has no patches array.`), { code: "ERR_PATCHES_INVALID" })
|
|
44
|
+
return data.patches
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writePatches(root, patches) {
|
|
48
|
+
const path = join(root, PATCHES_FILE)
|
|
49
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
50
|
+
writeFileSync(path, `${JSON.stringify({ patches }, null, 2)}\n`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The dist modules a component's patch covers: its own folder's modules, not
|
|
55
|
+
* the barrel or the docs metadata. A part (CardList) resolves to its component.
|
|
56
|
+
*/
|
|
57
|
+
export function componentFiles(pkgRoot, name) {
|
|
58
|
+
const registry = JSON.parse(readFileSync(join(pkgRoot, "registry.json"), "utf8"))
|
|
59
|
+
const entries = [...(registry.components ?? []), ...(registry.layoutPrimitives?.primitives ?? [])]
|
|
60
|
+
const entry =
|
|
61
|
+
entries.find((c) => c.name === name) ??
|
|
62
|
+
entries.find((c) => (c.subcomponents ?? []).some((s) => s.name === name))
|
|
63
|
+
if (!entry?.path) return null
|
|
64
|
+
const dir = join(pkgRoot, "dist", entry.path)
|
|
65
|
+
if (!existsSync(dir)) return null
|
|
66
|
+
return {
|
|
67
|
+
component: entry.name,
|
|
68
|
+
files: readdirSync(dir)
|
|
69
|
+
.filter((f) => f.endsWith(".js") && f !== "index.js" && !f.endsWith(".meta.js"))
|
|
70
|
+
.map((f) => `dist/${entry.path}/${f}`),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Changelog bullets between two versions (exclusive, inclusive) that name the component. */
|
|
75
|
+
export function changelogMentions(pkgRoot, component, from, to) {
|
|
76
|
+
const path = join(pkgRoot, "CHANGELOG.md")
|
|
77
|
+
if (!existsSync(path)) return []
|
|
78
|
+
const text = readFileSync(path, "utf8")
|
|
79
|
+
const out = []
|
|
80
|
+
for (const section of text.split(/^## /m).slice(1)) {
|
|
81
|
+
const version = section.split(/\s/, 1)[0]
|
|
82
|
+
if (compareVersions(version, from) <= 0 || compareVersions(version, to) > 0) continue
|
|
83
|
+
for (const bullet of section.split(/\n(?=- )/)) {
|
|
84
|
+
if (!bullet.startsWith("- ")) continue
|
|
85
|
+
if (!new RegExp(`\\b${component}\\b`).test(bullet)) continue
|
|
86
|
+
const lead = /^- \*\*(.+?)\*\*/.exec(bullet)?.[1] ?? bullet.slice(2, 120)
|
|
87
|
+
out.push(`${version}: ${lead.replace(/\s+/g, " ").replace(/\.$/, "")}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function compareVersions(a, b) {
|
|
94
|
+
const x = String(a).split(/[.-]/).map((n) => Number(n) || 0)
|
|
95
|
+
const y = String(b).split(/[.-]/).map((n) => Number(n) || 0)
|
|
96
|
+
for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0) ? -1 : 1
|
|
97
|
+
return 0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** How to make, commit and remove a patch with this project's package manager. */
|
|
101
|
+
export function patchSteps(pm, version, files) {
|
|
102
|
+
const list = files.join(", ")
|
|
103
|
+
if (pm === "pnpm")
|
|
104
|
+
return {
|
|
105
|
+
make: [`pnpm patch ${PACKAGE}@${version}`, `edit ${list} in the folder it prints`, "pnpm patch-commit <that folder>"],
|
|
106
|
+
remove: [`pnpm patch-remove ${PACKAGE}@${version}`],
|
|
107
|
+
}
|
|
108
|
+
if (pm === "yarn")
|
|
109
|
+
return {
|
|
110
|
+
make: [`yarn patch ${PACKAGE}`, `edit ${list} in the folder it prints`, "yarn patch-commit -s <that folder>", "(Yarn 1: use the npm steps with patch-package)"],
|
|
111
|
+
remove: [`remove the patch: entry for ${PACKAGE} from package.json resolutions, then yarn install`],
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
make: [`edit ${files.map((f) => `node_modules/${PACKAGE}/${f}`).join(", ")}`, `npx patch-package ${PACKAGE}`, 'add "postinstall": "patch-package" to package.json scripts'],
|
|
115
|
+
remove: [`delete patches/${PACKAGE.replace("/", "+")}+${version}.patch, then reinstall`],
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One doctor check per recorded patch. `fail` when someone has to decide —
|
|
121
|
+
* Sable changed a patched file, or a patch is missing from the installed copy —
|
|
122
|
+
* so the agent is told on the upgrade itself, not when a user hits the bug.
|
|
123
|
+
*/
|
|
124
|
+
export function patchChecks(pkgRoot, root, installed) {
|
|
125
|
+
let patches
|
|
126
|
+
try {
|
|
127
|
+
patches = readPatches(root)
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return [{ status: "fail", name: "patches", detail: error.message, fix: `Fix or delete ${PATCHES_FILE}.` }]
|
|
130
|
+
}
|
|
131
|
+
if (!patches.length) return []
|
|
132
|
+
const shipped = shippedHashes(pkgRoot)
|
|
133
|
+
if (!shipped) return [{ status: "warn", name: "patches", detail: `${patches.length} recorded, but this Sable build ships no file hashes to check them against.` }]
|
|
134
|
+
const pm = packageManager(parse(root).root, root)
|
|
135
|
+
return patches.map((p) => {
|
|
136
|
+
const name = `patch:${p.component}`
|
|
137
|
+
const why = `"${p.reason}"`
|
|
138
|
+
const missing = p.files.filter((f) => !shipped[f])
|
|
139
|
+
if (missing.length)
|
|
140
|
+
return { status: "fail", name, detail: `${p.version} → ${installed}: Sable no longer ships ${missing.join(", ")} (${why}). The component was restructured.`, fix: `Re-run the repro without the patch: ${p.repro}. Fixed → sable patch ${p.component} --drop. Still broken → patch the new files, then sable patch ${p.component} --keep.` }
|
|
141
|
+
const applied = p.files.some((f) => existsSync(join(pkgRoot, f)) && hash(join(pkgRoot, f)) !== shipped[f])
|
|
142
|
+
const changed = p.files.filter((f) => p.base?.[f] !== shipped[f])
|
|
143
|
+
const steps = patchSteps(pm, installed, p.files)
|
|
144
|
+
if (p.version === installed) {
|
|
145
|
+
if (applied) return { status: "pass", name, detail: `patched for ${why}${p.issue ? ` (${p.issue})` : ""}.` }
|
|
146
|
+
return { status: "warn", name, detail: `recorded for ${why} but not applied: the installed files are Sable's own.`, fix: steps.make.join(" → ") }
|
|
147
|
+
}
|
|
148
|
+
if (!changed.length && applied) return { status: "pass", name, detail: `patched for ${why}; Sable has not changed these files since ${p.version}.` }
|
|
149
|
+
const notes = changelogMentions(pkgRoot, p.component, p.version, installed)
|
|
150
|
+
const news = notes.length ? ` Changelog: ${notes.join(" | ")}.` : ""
|
|
151
|
+
if (!changed.length)
|
|
152
|
+
return { status: "fail", name, detail: `${p.version} → ${installed}: Sable did not change ${p.component}, and your patch (${why}) is not applied to ${installed}.`, fix: `Re-apply it: ${steps.make.join(" → ")}. Then sable patch ${p.component} --keep.` }
|
|
153
|
+
return {
|
|
154
|
+
status: "fail",
|
|
155
|
+
name,
|
|
156
|
+
detail: `${p.version} → ${installed}: Sable changed ${changed.join(", ")} since your patch (${why}).${news}`,
|
|
157
|
+
fix: `Re-run the repro on Sable's own files${applied ? " (remove the patch first)" : ""}: ${p.repro}. Fixed → sable patch ${p.component} --drop. Still broken → re-patch (${steps.make.join(" → ")}), then sable patch ${p.component} --keep, and report it.`,
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* ── CLI ────────────────────────────────────────────────────────────────────── */
|
|
163
|
+
|
|
164
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
165
|
+
if (isMain) {
|
|
166
|
+
const args = process.argv.slice(2)
|
|
167
|
+
const json = args.includes("--json")
|
|
168
|
+
const emit = (type, data, text) => process.stdout.write(json ? `${JSON.stringify({ type, data })}\n` : `${text}\n`)
|
|
169
|
+
try {
|
|
170
|
+
const pkgRoot = fileURLToPath(new URL("../", import.meta.url))
|
|
171
|
+
const root = process.cwd()
|
|
172
|
+
const installed = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8")).version
|
|
173
|
+
const name = args[0] && !args[0].startsWith("--") ? args[0] : null
|
|
174
|
+
const flags = parseFlags(name ? args.slice(1) : args, ["--reason", "--repro", "--issue", "--file"], ["--list", "--keep", "--drop", "--json"])
|
|
175
|
+
const patches = readPatches(root)
|
|
176
|
+
const pm = packageManager(parse(root).root, root)
|
|
177
|
+
if (flags.has("--list") || !name) {
|
|
178
|
+
const checks = patchChecks(pkgRoot, root, installed)
|
|
179
|
+
emit("patch.list", { patches, checks }, checks.length ? checks.map((c) => `${c.status === "pass" ? "✓" : c.status === "warn" ? "!" : "✗"} ${c.name} ${c.detail}${c.fix ? `\n → ${c.fix}` : ""}`).join("\n") : "No Sable patches recorded.")
|
|
180
|
+
} else if (flags.has("--drop")) {
|
|
181
|
+
const p = patches.find((x) => x.component === name)
|
|
182
|
+
if (!p) throw Object.assign(new Error(`No recorded patch for ${name}.`), { code: "ERR_NO_PATCH" })
|
|
183
|
+
writePatches(root, patches.filter((x) => x !== p))
|
|
184
|
+
const steps = patchSteps(pm, p.version, p.files)
|
|
185
|
+
emit("patch.dropped", { component: name, remove: steps.remove }, `Dropped the ${name} patch record. Remove the patch itself too: ${steps.remove.join(" → ")}`)
|
|
186
|
+
} else if (flags.has("--keep")) {
|
|
187
|
+
const p = patches.find((x) => x.component === name)
|
|
188
|
+
if (!p) throw Object.assign(new Error(`No recorded patch for ${name}.`), { code: "ERR_NO_PATCH" })
|
|
189
|
+
const shipped = shippedHashes(pkgRoot) ?? {}
|
|
190
|
+
Object.assign(p, { version: installed, base: Object.fromEntries(p.files.map((f) => [f, shipped[f]])) })
|
|
191
|
+
writePatches(root, patches)
|
|
192
|
+
emit("patch.kept", p, `Kept the ${name} patch against ${installed}. Report it upstream if you haven't, so the library fix can replace it.`)
|
|
193
|
+
} else {
|
|
194
|
+
const reason = flags.get("--reason")
|
|
195
|
+
const repro = flags.get("--repro")
|
|
196
|
+
if (!reason || !repro)
|
|
197
|
+
throw Object.assign(new Error("A patch records why and how to check it: pass --reason \"<what is wrong>\" and --repro \"<how to see it>\"."), { code: "ERR_BAD_ARGUMENT" })
|
|
198
|
+
if (patches.some((x) => x.component === name)) throw Object.assign(new Error(`${name} already has a patch record. Use --keep or --drop.`), { code: "ERR_PATCH_EXISTS" })
|
|
199
|
+
const found = componentFiles(pkgRoot, name)
|
|
200
|
+
const extra = flags.get("--file")
|
|
201
|
+
if (!found && !extra) throw Object.assign(new Error(`No Sable component named ${name}. For a shared module, pass --file dist/lib/<module>.js.`), { code: "ERR_UNKNOWN_COMPONENT" })
|
|
202
|
+
const shipped = shippedHashes(pkgRoot) ?? {}
|
|
203
|
+
const files = [...(found?.files ?? []), ...(extra ? [extra.replace(/\\/g, "/")] : [])]
|
|
204
|
+
const unknown = files.filter((f) => !shipped[f])
|
|
205
|
+
if (unknown.length) throw Object.assign(new Error(`Not a published Sable file: ${unknown.join(", ")}.`), { code: "ERR_UNKNOWN_FILE" })
|
|
206
|
+
const record = { component: found?.component ?? name, files, version: installed, base: Object.fromEntries(files.map((f) => [f, shipped[f]])), reason, repro, ...(flags.get("--issue") ? { issue: flags.get("--issue") } : {}) }
|
|
207
|
+
writePatches(root, [...patches, record])
|
|
208
|
+
const steps = patchSteps(pm, installed, files)
|
|
209
|
+
emit(
|
|
210
|
+
"patch.recorded",
|
|
211
|
+
{ ...record, steps: steps.make },
|
|
212
|
+
`Recorded a patch for ${record.component} against ${installed} in ${relative(root, join(root, PATCHES_FILE))}.\nNow make it with ${pm}:\n${steps.make.map((s) => ` - ${s}`).join("\n")}\nOn every upgrade, sable doctor checks it: it fails when Sable changes these files, until the repro says keep (--keep) or drop (--drop).\nReport the defect upstream so the library fix can replace the patch.`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
process.stderr.write(`${JSON.stringify({ type: "error", code: error.code ?? "ERR_PATCH", message: error.message, suggestions: ["sable patch --list", "sable docs patching-a-component"] })}\n`)
|
|
217
|
+
process.exitCode = 1
|
|
218
|
+
}
|
|
219
|
+
}
|