@sableui/react 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +73 -0
- package/README.md +2 -0
- package/bin/sable.mjs +34 -0
- 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/patching-a-component.md +48 -0
- package/llms/tasks/reviewing-a-design.md +5 -0
- package/llms-full.txt +121 -13
- package/llms.txt +1 -1
- package/package.json +4 -2
- package/registry.json +97 -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 +5 -2
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.0",
|
|
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",
|
|
@@ -1585,6 +1585,10 @@
|
|
|
1585
1585
|
{
|
|
1586
1586
|
"id": "lookalike-media-frame",
|
|
1587
1587
|
"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)."
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
"id": "form-required-error",
|
|
1591
|
+
"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
1592
|
}
|
|
1589
1593
|
],
|
|
1590
1594
|
"escapeHatch": {
|
|
@@ -1678,7 +1682,12 @@
|
|
|
1678
1682
|
"volume-low",
|
|
1679
1683
|
"volume-off",
|
|
1680
1684
|
"queue",
|
|
1681
|
-
"music"
|
|
1685
|
+
"music",
|
|
1686
|
+
"tag",
|
|
1687
|
+
"paperclip",
|
|
1688
|
+
"chart",
|
|
1689
|
+
"flag",
|
|
1690
|
+
"at-sign"
|
|
1682
1691
|
]
|
|
1683
1692
|
},
|
|
1684
1693
|
"layoutPrimitives": {
|
|
@@ -4482,8 +4491,8 @@
|
|
|
4482
4491
|
},
|
|
4483
4492
|
{
|
|
4484
4493
|
"name": "BreadcrumbLink",
|
|
4485
|
-
"type": "ComponentPropsWithRef<'a'>",
|
|
4486
|
-
"description": "An interactive ancestor crumb (<a>). Muted by default, brightens to foreground on hover/focus."
|
|
4494
|
+
"type": "ComponentPropsWithRef<'a'> & { render?: useRender.RenderProp }",
|
|
4495
|
+
"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
4496
|
},
|
|
4488
4497
|
{
|
|
4489
4498
|
"name": "BreadcrumbPage",
|
|
@@ -4808,6 +4817,10 @@
|
|
|
4808
4817
|
"guidance": "do",
|
|
4809
4818
|
"description": "Use a single primary button in a view or dense panel so the action hierarchy stays obvious."
|
|
4810
4819
|
},
|
|
4820
|
+
{
|
|
4821
|
+
"guidance": "do",
|
|
4822
|
+
"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`."
|
|
4823
|
+
},
|
|
4811
4824
|
{
|
|
4812
4825
|
"guidance": "do",
|
|
4813
4826
|
"description": "Write labels as actions, such as \"Save changes\" or \"Delete draft\", instead of vague labels like \"OK\"."
|
|
@@ -5647,7 +5660,7 @@
|
|
|
5647
5660
|
"group": "Chat & AI",
|
|
5648
5661
|
"register": "either",
|
|
5649
5662
|
"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.",
|
|
5663
|
+
"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
5664
|
"keywords": [
|
|
5652
5665
|
"chat input",
|
|
5653
5666
|
"composer",
|
|
@@ -5691,7 +5704,7 @@
|
|
|
5691
5704
|
},
|
|
5692
5705
|
{
|
|
5693
5706
|
"guidance": "dont",
|
|
5694
|
-
"description": "Do not use it as a general multi-line input. That is `Textarea
|
|
5707
|
+
"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
5708
|
},
|
|
5696
5709
|
{
|
|
5697
5710
|
"guidance": "dont",
|
|
@@ -5765,6 +5778,12 @@
|
|
|
5765
5778
|
"default": "\"Send a message…\"",
|
|
5766
5779
|
"description": "Placeholder for the field."
|
|
5767
5780
|
},
|
|
5781
|
+
{
|
|
5782
|
+
"name": "sendOn",
|
|
5783
|
+
"type": "\"enter\" | \"mod-enter\"",
|
|
5784
|
+
"default": "\"enter\"",
|
|
5785
|
+
"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\")."
|
|
5786
|
+
},
|
|
5768
5787
|
{
|
|
5769
5788
|
"name": "busy",
|
|
5770
5789
|
"type": "boolean",
|
|
@@ -7019,7 +7038,7 @@
|
|
|
7019
7038
|
{
|
|
7020
7039
|
"name": "items",
|
|
7021
7040
|
"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."
|
|
7041
|
+
"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
7042
|
},
|
|
7024
7043
|
{
|
|
7025
7044
|
"name": "query",
|
|
@@ -7100,8 +7119,8 @@
|
|
|
7100
7119
|
},
|
|
7101
7120
|
{
|
|
7102
7121
|
"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."
|
|
7122
|
+
"type": "{ onSelect?: () => void; icon?: string; detail?: ReactNode; stacked?: boolean; hint?: ReactNode; disabled?: boolean; id?: string; children }",
|
|
7123
|
+
"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
7124
|
}
|
|
7106
7125
|
],
|
|
7107
7126
|
"tokensUsed": [
|
|
@@ -7520,7 +7539,29 @@
|
|
|
7520
7539
|
{
|
|
7521
7540
|
"name": "isRowSelected",
|
|
7522
7541
|
"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."
|
|
7542
|
+
"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."
|
|
7543
|
+
},
|
|
7544
|
+
{
|
|
7545
|
+
"name": "selectedKeys",
|
|
7546
|
+
"type": "ReadonlySet<string | number>",
|
|
7547
|
+
"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."
|
|
7548
|
+
},
|
|
7549
|
+
{
|
|
7550
|
+
"name": "onSelectedKeysChange",
|
|
7551
|
+
"type": "(keys: Set<string | number>) => void",
|
|
7552
|
+
"description": "Called with the new set of checked keys. Its presence turns selection on. A click on a box never fires onRowClick."
|
|
7553
|
+
},
|
|
7554
|
+
{
|
|
7555
|
+
"name": "rowSelectionLabel",
|
|
7556
|
+
"type": "(row) => string",
|
|
7557
|
+
"default": "() => \"Select row\"",
|
|
7558
|
+
"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."
|
|
7559
|
+
},
|
|
7560
|
+
{
|
|
7561
|
+
"name": "selectAllLabel",
|
|
7562
|
+
"type": "(count: number) => string",
|
|
7563
|
+
"default": "(count) => `Select all ${count} shown`",
|
|
7564
|
+
"description": "The select-all checkbox's accessible name; say what it acts on (\"Select all 7 tickets shown\")."
|
|
7524
7565
|
},
|
|
7525
7566
|
{
|
|
7526
7567
|
"name": "defaultSort",
|
|
@@ -7550,6 +7591,11 @@
|
|
|
7550
7591
|
"Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure."
|
|
7551
7592
|
],
|
|
7552
7593
|
"examples": [
|
|
7594
|
+
{
|
|
7595
|
+
"label": "Checkbox selection with a bulk-action bar",
|
|
7596
|
+
"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>",
|
|
7597
|
+
"imports": "import { Button, DataTable, Stack, Toolbar } from \"@sableui/react\""
|
|
7598
|
+
},
|
|
7553
7599
|
{
|
|
7554
7600
|
"label": "Sortable columns",
|
|
7555
7601
|
"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 +9569,7 @@
|
|
|
9523
9569
|
{
|
|
9524
9570
|
"name": "Form",
|
|
9525
9571
|
"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
|
|
9572
|
+
"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
9573
|
},
|
|
9528
9574
|
{
|
|
9529
9575
|
"name": "validationMode",
|
|
@@ -9541,6 +9587,12 @@
|
|
|
9541
9587
|
"type": "(values: Record<string, unknown>, details) => void",
|
|
9542
9588
|
"description": "Called with the named Field values after validation passes; prevents the native submit."
|
|
9543
9589
|
},
|
|
9590
|
+
{
|
|
9591
|
+
"name": "noValidate",
|
|
9592
|
+
"type": "boolean",
|
|
9593
|
+
"default": "false",
|
|
9594
|
+
"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."
|
|
9595
|
+
},
|
|
9544
9596
|
{
|
|
9545
9597
|
"name": "actionsRef",
|
|
9546
9598
|
"type": "RefObject<{ validate(name?: string): void }>",
|
|
@@ -9550,8 +9602,8 @@
|
|
|
9550
9602
|
"subcomponents": [
|
|
9551
9603
|
{
|
|
9552
9604
|
"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
|
|
9605
|
+
"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 }",
|
|
9606
|
+
"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
9607
|
}
|
|
9556
9608
|
],
|
|
9557
9609
|
"tokensUsed": [
|
|
@@ -9575,6 +9627,16 @@
|
|
|
9575
9627
|
"code": "<Field label=\"Password\" error=\"Must be at least 8 characters.\"><Input type=\"password\" /></Field>",
|
|
9576
9628
|
"imports": "import { Field, Input } from \"@sableui/react\""
|
|
9577
9629
|
},
|
|
9630
|
+
{
|
|
9631
|
+
"label": "Your own wording for a missing value",
|
|
9632
|
+
"code": "<Form onSubmit={handleSubmit}><Field label=\"Subject\" required messages={{ valueMissing: \"Enter a subject.\" }}><Input name=\"subject\" /></Field><Button type=\"submit\">Send</Button></Form>",
|
|
9633
|
+
"imports": "import { Button, Field, Form, Input } from \"@sableui/react\""
|
|
9634
|
+
},
|
|
9635
|
+
{
|
|
9636
|
+
"label": "Validation in your own code",
|
|
9637
|
+
"code": "<Form noValidate onSubmit={handleSubmit}><Field label=\"Subject\" required error={errors.subject}><Input name=\"subject\" /></Field><Button type=\"submit\">Send</Button></Form>",
|
|
9638
|
+
"imports": "import { Button, Field, Form, Input } from \"@sableui/react\""
|
|
9639
|
+
},
|
|
9578
9640
|
{
|
|
9579
9641
|
"label": "Validated on submit",
|
|
9580
9642
|
"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 +11798,7 @@
|
|
|
11736
11798
|
{
|
|
11737
11799
|
"name": "ref",
|
|
11738
11800
|
"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."
|
|
11801
|
+
"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
11802
|
},
|
|
11741
11803
|
{
|
|
11742
11804
|
"name": "autoFollow",
|
|
@@ -12885,7 +12947,7 @@
|
|
|
12885
12947
|
{
|
|
12886
12948
|
"name": "actions",
|
|
12887
12949
|
"type": "ReactNode",
|
|
12888
|
-
"description": "Trailing actions aligned to the title row's end. They drop under the title when the
|
|
12950
|
+
"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
12951
|
},
|
|
12890
12952
|
{
|
|
12891
12953
|
"name": "titleAs",
|
|
@@ -15967,7 +16029,7 @@
|
|
|
15967
16029
|
"group": "Overlays",
|
|
15968
16030
|
"register": "either",
|
|
15969
16031
|
"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.",
|
|
16032
|
+
"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
16033
|
"keywords": [
|
|
15972
16034
|
"settings",
|
|
15973
16035
|
"model picker",
|
|
@@ -16492,6 +16554,11 @@
|
|
|
16492
16554
|
}
|
|
16493
16555
|
],
|
|
16494
16556
|
"subcomponents": [
|
|
16557
|
+
{
|
|
16558
|
+
"name": "SidebarHeader",
|
|
16559
|
+
"type": "div props",
|
|
16560
|
+
"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."
|
|
16561
|
+
},
|
|
16495
16562
|
{
|
|
16496
16563
|
"name": "SidebarBrand",
|
|
16497
16564
|
"type": "{ mark: ReactNode; label: ReactNode; render?: useRender.RenderProp } + div props",
|
|
@@ -17883,7 +17950,7 @@
|
|
|
17883
17950
|
"group": "Selection",
|
|
17884
17951
|
"register": "either",
|
|
17885
17952
|
"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).",
|
|
17953
|
+
"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
17954
|
"keywords": [
|
|
17888
17955
|
"switch",
|
|
17889
17956
|
"toggle setting",
|
|
@@ -17966,7 +18033,6 @@
|
|
|
17966
18033
|
"tokensUsed": [
|
|
17967
18034
|
"input",
|
|
17968
18035
|
"primary",
|
|
17969
|
-
"primary-foreground",
|
|
17970
18036
|
"ring"
|
|
17971
18037
|
],
|
|
17972
18038
|
"motion": [
|
|
@@ -23773,6 +23839,9 @@
|
|
|
23773
23839
|
"render",
|
|
23774
23840
|
"showLabel"
|
|
23775
23841
|
],
|
|
23842
|
+
"BreadcrumbLink": [
|
|
23843
|
+
"render"
|
|
23844
|
+
],
|
|
23776
23845
|
"BreadcrumbSeparator": [
|
|
23777
23846
|
"children"
|
|
23778
23847
|
],
|
|
@@ -23942,6 +24011,7 @@
|
|
|
23942
24011
|
"onValueChange",
|
|
23943
24012
|
"placeholder",
|
|
23944
24013
|
"sendLabel",
|
|
24014
|
+
"sendOn",
|
|
23945
24015
|
"stopLabel",
|
|
23946
24016
|
"value"
|
|
23947
24017
|
],
|
|
@@ -24213,7 +24283,8 @@
|
|
|
24213
24283
|
"disabled",
|
|
24214
24284
|
"hint",
|
|
24215
24285
|
"icon",
|
|
24216
|
-
"onSelect"
|
|
24286
|
+
"onSelect",
|
|
24287
|
+
"stacked"
|
|
24217
24288
|
],
|
|
24218
24289
|
"CommandMenuPanel": [
|
|
24219
24290
|
"children",
|
|
@@ -24351,7 +24422,11 @@
|
|
|
24351
24422
|
"empty",
|
|
24352
24423
|
"isRowSelected",
|
|
24353
24424
|
"onRowClick",
|
|
24354
|
-
"
|
|
24425
|
+
"onSelectedKeysChange",
|
|
24426
|
+
"rowKey",
|
|
24427
|
+
"rowSelectionLabel",
|
|
24428
|
+
"selectAllLabel",
|
|
24429
|
+
"selectedKeys"
|
|
24355
24430
|
],
|
|
24356
24431
|
"DatePicker": [
|
|
24357
24432
|
"className",
|
|
@@ -24536,6 +24611,7 @@
|
|
|
24536
24611
|
"disabled",
|
|
24537
24612
|
"error",
|
|
24538
24613
|
"label",
|
|
24614
|
+
"messages",
|
|
24539
24615
|
"name",
|
|
24540
24616
|
"orientation",
|
|
24541
24617
|
"required",
|
|
@@ -24592,6 +24668,7 @@
|
|
|
24592
24668
|
"Form": [
|
|
24593
24669
|
"actionsRef",
|
|
24594
24670
|
"errors",
|
|
24671
|
+
"noValidate",
|
|
24595
24672
|
"onFormSubmit",
|
|
24596
24673
|
"validationMode"
|
|
24597
24674
|
],
|
|
@@ -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
|
+
}
|