@sableui/react 0.7.0 → 0.7.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/CHANGELOG.md +42 -0
- package/dist/blocks/traffic-sources-block.js +1 -1
- package/dist/blocks/traffic-trend-block.js +3 -3
- package/dist/components/card/card.js +7 -4
- package/dist/components/pie-chart/pie-chart.js +3 -3
- package/dist/components/pie-chart/pie-chart.meta.js +3 -3
- package/dist/components/radial-chart/radial-chart.js +3 -3
- package/dist/components/radial-chart/radial-chart.meta.js +2 -2
- package/dist/components/side-nav/side-nav.variants.js +3 -2
- package/dist/components/table/table.js +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.js +1 -1
- package/dist/layout/index.d.ts +5 -2
- package/dist/lib/chart/format.js +5 -0
- package/dist/lib/component-guidance.js +7 -2
- package/dist/presets/index.d.ts +7 -1
- package/dist/presets/index.js +10 -0
- package/llms/blocks/traffic-sources-block.md +1 -1
- package/llms/blocks/traffic-trend-block.md +5 -3
- package/llms/components/pie-chart.md +2 -2
- package/llms/components/radial-chart.md +1 -1
- package/llms/layout/region.md +16 -3
- package/llms/tasks/building-a-page.md +34 -3
- package/llms/tasks/creating-a-preset.md +4 -1
- package/llms/tasks/reviewing-a-design.md +4 -0
- package/llms/tasks/using-presets.md +19 -0
- package/llms-full.txt +86 -14
- package/package.json +1 -1
- package/registry.json +36 -11
- package/scripts/design-presets.mjs +15 -4
- package/skills/sable/SKILL.md +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# What's new
|
|
2
2
|
|
|
3
|
+
## 0.7.1
|
|
4
|
+
|
|
5
|
+
Fixes from building a customer-support app on 0.7.0 with an app-owned preset,
|
|
6
|
+
plus guidance for choosing a page width. Nothing to migrate.
|
|
7
|
+
|
|
8
|
+
- **A table in a flush card keeps the card's corners.** `Card` now clips to its
|
|
9
|
+
rounded corners when it holds a `CardContent flush`, as it already did for a
|
|
10
|
+
`CardList`. The hover fill of an edge-to-edge `Table` or `DataTable` row and a
|
|
11
|
+
`TableFooter`'s tint no longer poke square corners out past the card's.
|
|
12
|
+
- **Only body rows hover.** `TableRow` lifts on hover inside `TableBody`; header
|
|
13
|
+
and footer rows stay still, since nothing there responds to a click.
|
|
14
|
+
- **SideNav and Sidebar icons stay level with their labels at any spacing
|
|
15
|
+
step.** The icon box was sized in spacing steps around an icon sized in type,
|
|
16
|
+
so a theme with a step other than 4px shrank the box and dropped every icon
|
|
17
|
+
about 2px below its label. The box is now sized in the icon's own unit and
|
|
18
|
+
centred with flex.
|
|
19
|
+
- **A donut's number holds the centre.** In `PieChart` and a full-ring
|
|
20
|
+
`RadialChart`, `centerLabel`'s value sits on the centre and its caption hangs
|
|
21
|
+
below, instead of the pair splitting the centre between them.
|
|
22
|
+
- **`centerLabel.value` takes a number.** It is written in full up to 9,999 and
|
|
23
|
+
shortened from 10,000 on (`10.2K`, `102.1K`, `1.2M`), in the chart's
|
|
24
|
+
`locale`. A string still shows as written, for units.
|
|
25
|
+
- **Presets can set page widths.** `page-inset` and the role caps
|
|
26
|
+
(`region-form`, `-feed`, `-content`, `-wide`, `-full`) are preset tokens, so a
|
|
27
|
+
product's widths live in its preset rather than app CSS. They are opt-in:
|
|
28
|
+
`sable preset --template` and Canvas exports leave them out, so a preset never
|
|
29
|
+
overrides widths an app already sets. `PAGE_WIDTH_TOKENS` lists them.
|
|
30
|
+
- **Guidance for choosing a page width.** `sable docs building-a-page` has a
|
|
31
|
+
table of which `max` to use by what the person does on the page, and when to
|
|
32
|
+
go fluid. The Region docs say `full` is the largest cap (edge to edge is
|
|
33
|
+
`bleed`) and show a fluid workspace; the old "Fluid dashboard" example was
|
|
34
|
+
capped and is retitled. `reviewing-a-design` asks for a 1920px capture of a
|
|
35
|
+
capped or fluid route, where the choice shows, and the skill's brief records
|
|
36
|
+
each route's width.
|
|
37
|
+
- **A pasted preset gets applied.** Canvas's "Copy for agent" message now asks
|
|
38
|
+
the agent to apply the preset and build the app, not only save it. The skill
|
|
39
|
+
treats a preset supplied for an existing app as a restyle, `sable docs
|
|
40
|
+
using-presets` has an "Apply to an existing app" section, and a passing
|
|
41
|
+
`sable preset --check` lists the steps that make an app use the preset.
|
|
42
|
+
- **`TrafficTrendBlock` fits a narrow card.** Below 24rem its range toggles
|
|
43
|
+
move under the title instead of squeezing it.
|
|
44
|
+
|
|
3
45
|
## 0.7.0
|
|
4
46
|
|
|
5
47
|
A dogfooding release. An agent built a music streaming app on Sable with
|
|
@@ -24,7 +24,7 @@ function TrafficSourcesBlock({ className, framed = true }) {
|
|
|
24
24
|
index: "source",
|
|
25
25
|
valueKey: "visitors",
|
|
26
26
|
donut: true,
|
|
27
|
-
centerLabel: { value: total
|
|
27
|
+
centerLabel: { value: total, label: "Visitors" }
|
|
28
28
|
}
|
|
29
29
|
) }),
|
|
30
30
|
/* @__PURE__ */ jsxs(CardFooter, { className: "text-body-sm text-muted-foreground", children: [
|
|
@@ -27,11 +27,11 @@ var shortDate = (iso) => (/* @__PURE__ */ new Date(`${iso}T00:00:00`)).toLocaleD
|
|
|
27
27
|
function TrafficTrendBlock({ className, framed = true }) {
|
|
28
28
|
const [range, setRange] = useState("90d");
|
|
29
29
|
const shown = days.slice(-(ranges.find((r) => r.id === range)?.days ?? 90));
|
|
30
|
-
return /* @__PURE__ */ jsxs(Card, { variant: framed ? "default" : "flat", className: cn("w-full max-w-3xl", className), children: [
|
|
31
|
-
/* @__PURE__ */ jsxs(CardHeader, { children: [
|
|
30
|
+
return /* @__PURE__ */ jsxs(Card, { variant: framed ? "default" : "flat", className: cn("@container/trend w-full max-w-3xl", className), children: [
|
|
31
|
+
/* @__PURE__ */ jsxs(CardHeader, { className: "@max-sm/trend:has-data-[slot=card-action]:grid-cols-1", children: [
|
|
32
32
|
/* @__PURE__ */ jsx(CardTitle, { render: /* @__PURE__ */ jsx("h2", {}), children: "Visitors" }),
|
|
33
33
|
/* @__PURE__ */ jsx(CardDescription, { children: "Desktop and mobile, by day." }),
|
|
34
|
-
/* @__PURE__ */ jsx(CardAction, { children: /* @__PURE__ */ jsx(ToggleGroup, { value: [range], onValueChange: (v) => v[0] && setRange(String(v[0])), "aria-label": "Time range", children: ranges.map((r) => /* @__PURE__ */ jsx(Toggle, { value: r.id, children: r.label }, r.id)) }) })
|
|
34
|
+
/* @__PURE__ */ jsx(CardAction, { className: "@max-sm/trend:col-start-1 @max-sm/trend:row-span-1 @max-sm/trend:row-start-auto @max-sm/trend:mt-2 @max-sm/trend:justify-self-start", children: /* @__PURE__ */ jsx(ToggleGroup, { value: [range], onValueChange: (v) => v[0] && setRange(String(v[0])), "aria-label": "Time range", children: ranges.map((r) => /* @__PURE__ */ jsx(Toggle, { value: r.id, children: r.label }, r.id)) }) })
|
|
35
35
|
] }),
|
|
36
36
|
/* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsx(
|
|
37
37
|
AreaChart,
|
|
@@ -44,10 +44,13 @@ function Card({
|
|
|
44
44
|
"[--card-inner-radius:max(var(--radius-sm),calc(var(--radius-lg)-var(--card-p)-1px))]",
|
|
45
45
|
// Declares its surface so sable-well / sable-surface-interactive inside derive from the card.
|
|
46
46
|
variant === "default" && "border border-border bg-card shadow-sm [--sable-surface-bg:var(--card)]",
|
|
47
|
-
// A CardList
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
47
|
+
// A CardList or a flush CardContent (an edge-to-edge Table) runs its
|
|
48
|
+
// rows to the card's edges, and a row's hover fill or a TableFooter's
|
|
49
|
+
// tint would otherwise poke square corners out past the card's
|
|
50
|
+
// rounded ones — clip to the card's own curve instead. Nothing
|
|
51
|
+
// overflows on purpose here: the table insets its edge cells by
|
|
52
|
+
// --table-edge-px, so focus rings stay inside, and menus portal out.
|
|
53
|
+
"has-data-[slot=card-list]:overflow-clip has-[[data-slot=card-content][data-flush]]:overflow-clip",
|
|
51
54
|
INTERACTIVE_BASE,
|
|
52
55
|
INTERACTIVE_HOVER[variant],
|
|
53
56
|
className
|
|
@@ -5,7 +5,7 @@ import { useId } from "react";
|
|
|
5
5
|
import { ChartFrame } from "../../lib/chart/chart-frame.js";
|
|
6
6
|
import { ChartLegend } from "../../lib/chart/chart-legend.js";
|
|
7
7
|
import { ChartTooltipContent } from "../../lib/chart/chart-tooltip.js";
|
|
8
|
-
import { categoryText, chartFormatters } from "../../lib/chart/format.js";
|
|
8
|
+
import { categoryText, centerValueText, chartFormatters } from "../../lib/chart/format.js";
|
|
9
9
|
import { arcPath, polar } from "../../lib/chart/geometry.js";
|
|
10
10
|
import { DRAW_DURATION, morphTransition, pathStyle } from "../../lib/chart/motion.js";
|
|
11
11
|
import {
|
|
@@ -177,8 +177,8 @@ function PieChart({
|
|
|
177
177
|
);
|
|
178
178
|
}) : null,
|
|
179
179
|
centerLabel && g.inner > 0 ? /* @__PURE__ */ jsxs("text", { x: g.cx, y: g.cy, textAnchor: "middle", className: "[[data-reveal=out]_&]:opacity-0", style: { transition: morphTransition() }, children: [
|
|
180
|
-
/* @__PURE__ */ jsx("tspan", { x: g.cx, dy:
|
|
181
|
-
centerLabel.label ? /* @__PURE__ */ jsx("tspan", { x: g.cx, dy: "1.
|
|
180
|
+
/* @__PURE__ */ jsx("tspan", { x: g.cx, dy: "0.35em", className: "fill-foreground text-heading-2 tabular-nums", children: centerValueText(centerLabel.value, locale) }),
|
|
181
|
+
centerLabel.label ? /* @__PURE__ */ jsx("tspan", { x: g.cx, dy: "1.9em", className: "fill-muted-foreground text-caption", children: centerLabel.label }) : null
|
|
182
182
|
] }) : null
|
|
183
183
|
] });
|
|
184
184
|
}
|
|
@@ -83,8 +83,8 @@ var pieChartMeta = {
|
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
name: "centerLabel",
|
|
86
|
-
type: "{ value: string; label?: string }",
|
|
87
|
-
description: "A value and caption in the hole of a donut."
|
|
86
|
+
type: "{ value: string | number; label?: string }",
|
|
87
|
+
description: "A value and caption in the hole of a donut. The value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K, 102.1K, 1.2M); pass a string for units."
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
name: "separator",
|
|
@@ -197,7 +197,7 @@ var pieChartMeta = {
|
|
|
197
197
|
examples: [
|
|
198
198
|
{
|
|
199
199
|
label: "Donut with a total",
|
|
200
|
-
code: 'const sources = [\n { source: "Search", visitors: 275 },\n { source: "Direct", visitors: 200 },\n { source: "Social", visitors: 187 },\n { source: "Email", visitors: 90 },\n]\n\n<PieChart\n label="Visitors by source"\n data={sources}\n index="source"\n valueKey="visitors"\n donut\n centerLabel={{ value:
|
|
200
|
+
code: 'const sources = [\n { source: "Search", visitors: 275 },\n { source: "Direct", visitors: 200 },\n { source: "Social", visitors: 187 },\n { source: "Email", visitors: 90 },\n]\n\n<PieChart\n label="Visitors by source"\n data={sources}\n index="source"\n valueKey="visitors"\n donut\n centerLabel={{ value: 752, label: "Visitors" }}\n/>'
|
|
201
201
|
},
|
|
202
202
|
{
|
|
203
203
|
label: "Labelled slices",
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { ChartFrame } from "../../lib/chart/chart-frame.js";
|
|
5
5
|
import { ChartLegend } from "../../lib/chart/chart-legend.js";
|
|
6
6
|
import { ChartTooltipContent } from "../../lib/chart/chart-tooltip.js";
|
|
7
|
-
import { categoryText, chartFormatters } from "../../lib/chart/format.js";
|
|
7
|
+
import { categoryText, centerValueText, chartFormatters } from "../../lib/chart/format.js";
|
|
8
8
|
import { polar, strokeArcPath } from "../../lib/chart/geometry.js";
|
|
9
9
|
import { drawTransition, morphTransition, pathStyle, stagger } from "../../lib/chart/motion.js";
|
|
10
10
|
import {
|
|
@@ -159,8 +159,8 @@ function RadialChart({
|
|
|
159
159
|
className: "[[data-reveal=out]_&]:opacity-0",
|
|
160
160
|
style: { transition: morphTransition() },
|
|
161
161
|
children: [
|
|
162
|
-
/* @__PURE__ */ jsx("tspan", { x: g.cx, dy: gauge && sweep <= 180 ? centerLabel.label ? "-1.4em" : "0" :
|
|
163
|
-
centerLabel.label ? /* @__PURE__ */ jsx("tspan", { x: g.cx, dy: "1.6em", className: "fill-muted-foreground text-caption", children: centerLabel.label }) : null
|
|
162
|
+
/* @__PURE__ */ jsx("tspan", { x: g.cx, dy: gauge && sweep <= 180 ? centerLabel.label ? "-1.4em" : "0" : "0.35em", className: "fill-foreground text-heading-2 tabular-nums", children: centerValueText(centerLabel.value, locale) }),
|
|
163
|
+
centerLabel.label ? /* @__PURE__ */ jsx("tspan", { x: g.cx, dy: gauge && sweep <= 180 ? "1.6em" : "1.9em", className: "fill-muted-foreground text-caption", children: centerLabel.label }) : null
|
|
164
164
|
]
|
|
165
165
|
}
|
|
166
166
|
) : null
|
|
@@ -107,8 +107,8 @@ var radialChartMeta = {
|
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
name: "centerLabel",
|
|
110
|
-
type: "{ value: string; label?: string }",
|
|
111
|
-
description:
|
|
110
|
+
type: "{ value: string | number; label?: string }",
|
|
111
|
+
description: 'A value and caption in the middle. On a full ring the value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K); pass a string for units ("7.2 GB", "72%").'
|
|
112
112
|
},
|
|
113
113
|
{
|
|
114
114
|
name: "legend",
|
|
@@ -20,9 +20,10 @@ var sideNavItemVariants = cva(
|
|
|
20
20
|
}
|
|
21
21
|
);
|
|
22
22
|
var sideNavItemIconVariants = cva(
|
|
23
|
-
"
|
|
23
|
+
"flex shrink-0 items-center justify-center",
|
|
24
24
|
{
|
|
25
|
-
|
|
25
|
+
// md 20px icon in a 20px box; lg 20px in 24px; rail 24px in 24px.
|
|
26
|
+
variants: { size: { md: "size-[1em] text-xl", lg: "size-[1.2em] text-xl", rail: "size-[1em] text-2xl" } },
|
|
26
27
|
defaultVariants: { size: "md" }
|
|
27
28
|
}
|
|
28
29
|
);
|
|
@@ -51,7 +51,7 @@ function TableRow({ className, ...props }) {
|
|
|
51
51
|
"tr",
|
|
52
52
|
{
|
|
53
53
|
className: cn(
|
|
54
|
-
"border-b border-border motion-interactive hover:bg-muted/50",
|
|
54
|
+
"border-b border-border motion-interactive [tbody>&:hover]:bg-muted/50",
|
|
55
55
|
"*:motion-interactive data-[selected]:*:bg-muted data-[selected]:*:shadow-[1px_0_0_var(--muted)]",
|
|
56
56
|
className
|
|
57
57
|
),
|
package/dist/index.d.ts
CHANGED
|
@@ -1926,7 +1926,11 @@ type ChartCategories = Record<string, {
|
|
|
1926
1926
|
}>;
|
|
1927
1927
|
/** A big number with a caption in the middle of a donut or radial chart. */
|
|
1928
1928
|
interface ChartCenterLabel {
|
|
1929
|
-
|
|
1929
|
+
/**
|
|
1930
|
+
* The number. Pass a number to have it formatted: in full up to 9,999,
|
|
1931
|
+
* compact from 10,000 ("10.2K"). Pass a string for units or your own wording.
|
|
1932
|
+
*/
|
|
1933
|
+
value: string | number;
|
|
1930
1934
|
label?: string;
|
|
1931
1935
|
}
|
|
1932
1936
|
/** How the tooltip marks each row. */
|
|
@@ -2898,7 +2902,9 @@ declare namespace TableFooter {
|
|
|
2898
2902
|
var displayName: string;
|
|
2899
2903
|
}
|
|
2900
2904
|
/**
|
|
2901
|
-
* A table row.
|
|
2905
|
+
* A table row. Body rows hover-lift; header and footer rows stay still, since
|
|
2906
|
+
* nothing there responds to a click on the row. Mark the selected row with
|
|
2907
|
+
* `data-selected`.
|
|
2902
2908
|
*
|
|
2903
2909
|
* The selected fill is painted by the cells, each spilling 1px into the next
|
|
2904
2910
|
* (a box-shadow, so layout does not move). A row's own background is painted
|
|
@@ -8218,6 +8224,6 @@ interface UseCopyResult {
|
|
|
8218
8224
|
*/
|
|
8219
8225
|
declare function useCopy(text: string, { timeout }?: UseCopyOptions): UseCopyResult;
|
|
8220
8226
|
|
|
8221
|
-
declare const SABLE_UI_VERSION: "0.7.
|
|
8227
|
+
declare const SABLE_UI_VERSION: "0.7.1";
|
|
8222
8228
|
|
|
8223
8229
|
export { Accordion, AccordionItem, AccordionPanel, AccordionTrigger, AlertDialog, AlertDialogClose, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogTitle, AlertDialogTrigger, AppHeader, type AppHeaderProps, AppShell, type AppShellProps, AreaChart, type AreaChartProps, type ArtTone, Attachment, type AttachmentProps, Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, type AvatarGroupCountProps, type AvatarGroupProps, AvatarImage, type AvatarProps, AvatarRoot, BADGE_APPEARANCES, BADGE_TONES, Badge, type BadgeAppearance, type BadgeProps, type BadgeTone, Banner, type BannerProps, BarChart, type BarChartProps, BottomNav, BottomNavItem, type BottomNavItemProps, BreadcrumbItem, type BreadcrumbItemProps, BreadcrumbLink, type BreadcrumbLinkProps, BreadcrumbPage, type BreadcrumbPageProps, BreadcrumbSeparator, type BreadcrumbSeparatorProps, Breadcrumbs, type BreadcrumbsProps, Browser, type BrowserProps, Bubble, type BubbleProps, BubbleReaction, type BubbleReactionProps, BubbleReactions, type BubbleReactionsProps, Button, type ButtonBleed, type ButtonProps, CHART_SERIES_COUNT, COMPONENT_GROUPS, COMPONENT_TAXONOMY, Calendar, type CalendarProps, Card, CardAction, CardContent, type CardContentProps, CardDescription, CardFooter, type CardFooterProps, CardHeader, CardList, type CardProps, CardTitle, type CardTitleProps, Carousel, type CarouselProps, type CarouselSlidesToShow, type ChartCategories, type ChartCenterLabel, type ChartCurve, type ChartDatum, type ChartSeries, type ChartTone, type ChartTooltipIndicator, type ChartTooltipOptions, type ChartValueFormatter, ChatComposer, type ChatComposerProps, Checkbox, CheckboxCard, type CheckboxCardProps, type CheckboxCardTone, CheckboxGroup, type CheckboxGroupProps, Code, CodeBlock, type CodeBlockProps, CodeHighlightProvider, type CodeHighlighter, type CodeProps, Collapsible, CollapsibleIndicator, CollapsiblePanel, CollapsibleTrigger, ColorField, type ColorFieldProps, Combobox, ComboboxChips, ComboboxClear, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxGroupLabel, ComboboxInput, ComboboxItem, type ComboboxItemGroup, ComboboxItemIndicator, ComboboxList, ComboboxPortal, type ComboboxProps, ComboboxValue, ComboboxVirtualList, type ComboboxVirtualListProps, type CommandFilter, type CommandItem, CommandMenu, CommandMenuGroup, type CommandMenuGroupProps, CommandMenuItem, type CommandMenuItemProps, CommandMenuPanel, type CommandMenuPanelProps, type CommandMenuProps, CommandMenuTrigger, type CommandMenuTriggerProps, type ComponentGroup, type ComponentGuidance, ComponentMeta, type ComponentRegister, ContextCard, ContextCardArrow, ContextCardContent, ContextCardPortal, ContextCardTrigger, ContextMenu, ContextMenuContent, ContextMenuGroup, ContextMenuGroupLabel, ContextMenuItem, ContextMenuPortal, ContextMenuSeparator, ContextMenuTrigger, DataTable, type DataTableColumn, type DataTableProps, DatePicker, type DatePickerProps, DateTile, type DateTileProps, Density, DensityProvider, Density as DensityValue, DestructiveActionModal, type DestructiveActionModalProps, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, type DialogSize, DialogTitle, DialogTrigger, DisclosurePicker, DisclosurePickerEmpty, DisclosurePickerList, type DisclosurePickerListProps, DisclosurePickerOption, type DisclosurePickerOptionProps, DisclosurePickerPanel, type DisclosurePickerPanelProps, type DisclosurePickerProps, type DisclosurePickerSearch, DisclosurePickerTrigger, type DisclosurePickerTriggerProps, EmptyState, type EmptyStateProps, Entity, type EntityProps, Feedback, type FeedbackData, type FeedbackProps, type FeedbackRating, Field, type FieldProps, Fieldset, FieldsetContent, FieldsetDescription, FieldsetFooter, FieldsetLegend, FileDropzone, type FileDropzoneProps, type FileRejection, type FileRejectionCode, FontPicker, type FontPickerFont, type FontPickerOption, type FontPickerProps, Form, type FormProps, GlidingHighlight, type GlidingHighlightProps, GradientArt, type GradientArtProps, type Hsv, InlineStatus, type InlineStatusProps, type StatusTone as InlineTone, Input, type InputProps$1 as InputProps, Kbd, KbdGroup, type KbdGroupProps, type KbdProps, Label, type LabelProps$2 as LabelProps, LineChart, type LineChartProps, Marker, type MarkerProps, Marquee, type MarqueeProps, MediaImage, type MediaImageProps, MediaTile, type MediaTileProps, Menu, MenuCheckboxItem, MenuContent, MenuGroup, MenuGroupLabel, MenuItem, MenuPortal, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuSubmenu, MenuSubmenuTrigger, MenuTrigger, Message, type MessageProps, MessageScroller, type MessageScrollerHandle, type MessageScrollerProps, Meter, MeterIndicator, MeterLabel, type MeterSize, type MeterTone, MeterTrack, MeterValue, MiddleTruncate, type MiddleTruncateProps, type MotionPolicy, MotionProvider, type MotionProviderProps, Note, type NoteAction, type NoteProps, NotificationBadge, type NotificationBadgeProps, NumberField, type NumberFieldProps, type OrbMode, type OrbSize, OtpField, type OtpFieldProps, OverflowMenu, type OverflowMenuProps, PageHeader, type PageHeaderProps, PageLoader, type PageLoaderProps, Pagination, type PaginationProps, Panel, PanelGroup, PanelResizer, type PanelResizerProps, PanelSection, type PanelWidthBounds, Phone, type PhoneProps, PieChart, type PieChartProps, Pill, type PillProps, Popover, PopoverArrow, PopoverClose, PopoverContent, PopoverDescription, PopoverPortal, PopoverTitle, PopoverTrigger, Presence, type PresenceProps, type Presentation, PresentationProvider, Progress, ProgressIndicator, ProgressLabel, type ProgressSize, ProgressTrack, ProgressValue, PropertyPair, PropertyRow, RadarChart, type RadarChartProps, RadialChart, type RadialChartProps, Radio, RadioCard, type RadioCardProps, type RadioCardTone, RadioGroup, RelativeTime, type RelativeTimeProps, Reveal, type RevealProps, type Rgb, SABLE_UI_VERSION, SEGMENTED_BAR_TONES, STATUS_TONES, STATUS_TONE_ICON, type ScrollCommandOptions, Scroller, type ScrollerProps, ScrubField, type ScrubFieldProps, SearchInput, type SearchInputProps, SectionHeader, type SectionHeaderProps, SegmentedBar, type SegmentedBarProps, type SegmentedBarSegment, type SegmentedBarTone, Select, SelectContent, SelectGroup, SelectGroupLabel, SelectItem, SelectPortal, SelectSeparator, SelectTrigger, SelectValue, Separator, type SeparatorProps$3 as SeparatorProps, SettingsMenu, type SettingsMenuProps, SettingsRow, type SettingsRowProps, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetList, SheetPortal, SheetTitle, SheetTrigger, ShowMore, type ShowMoreProps, SideNav, type SideNavActiveVariant, SideNavGroup, type SideNavGroupProps, SideNavItem, type SideNavItemProps, type SideNavProps, type SideNavSize, Sidebar, SidebarBrand, type SidebarBrandProps, SidebarFooter, SidebarHeader, SidebarItem, type SidebarItemProps, SidebarNav, type SidebarProps, SimpleTooltip, Skeleton, Slider, SliderControl, SliderIndicator, SliderRoot, SliderThumb, SliderTrack, SliderValue, Sparkline, type SparklineProps, Spinner, type SpinnerProps, StatusBar, StatusBarGroup, StatusBarSeparator, StatusDot, type StatusDotProps, type StatusTone, type StepStatus, Stepper, StepperItem, type StepperItemProps, type StepperOrientation, type StepperProps, StreamingText, type StreamingTextProps, Switch, TRANSPARENT, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsPanel as TabsContent, TabsList, TabsPanel, TabsTrigger, TextReveal, type TextRevealProps, Textarea, type TextareaProps, ThemeId, ThemeManifest, ThemeMode$1 as ThemeManifestMode, type ThemeMode, ThemePreview, type ThemePreviewProps, ThemeSelector, type ThemeSelectorProps, ThemeSwitcher, type ThemeSwitcherProps, ThinkingOrb, type ThinkingOrbProps, ToastAction, type ToastApi, ToastClose, ToastContent, ToastDescription, type ToastManagerApi, type ToastOptions, type ToastPosition, ToastProvider, type ToastProviderProps, ToastRoot, ToastTitle, type ToastType, ToastViewport, Toggle, ToggleGroup, type ToggleGroupAppearance, Toggle as ToggleGroupItem, Toolbar, ToolbarButton, type ToolbarButtonProps, ToolbarGroup, type ToolbarGroupProps, ToolbarInput, type ToolbarInputProps, ToolbarLink, type ToolbarLinkProps, type ToolbarProps, ToolbarSeparator, type ToolbarSeparatorProps, Tooltip, TooltipArrow, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger, Tree, TreeAddRow, TreeEmpty, TreeRow, TreeSection, type UseCopyOptions, type UseCopyResult, type UseInViewOptions, type UsePanelWidthOptions, Video, type VideoProps, ViewTransitionName, type ViewTransitionNameProps, Workspace, WorkspaceBar, WorkspaceCanvas, accordionMeta, appHeaderMeta, appShellMeta, areaChartMeta, attachmentMeta, avatarGroupVariants, avatarMeta, avatarVariants, badgeMeta, badgeVariants, bannerMeta, bannerVariants, barChartMeta, bottomNavMeta, breadcrumbsMeta, browserMeta, bubbleMeta, buttonMeta, buttonVariants, calendarMeta, cardMeta, carouselMeta, chartColor, toneColor as chartToneColor, chatComposerMeta, checkboxCardMeta, checkboxMeta, cn, codeBlockMeta, codeMeta, collapsibleMeta, colorFieldMeta, comboboxMeta, commandMenuMeta, componentGuidance, componentLabel, componentSlug, componentsInGroup, contextCardMeta, contextMenuMeta, dataTableMeta, datePickerMeta, dateTileMeta, destructiveActionModalMeta, dialogMeta, disclosurePickerMeta, emptyStateMeta, entityMeta, feedbackMeta, fieldsetMeta, fileDropzoneMeta, fileDropzoneVariants, fontPickerMeta, formMeta, formatFileSize, formatHex, glidingHighlightMeta, gradientArtMeta, hsvToRgb, hueColor, inlineStatusMeta, inputMeta, isTransparent, kbdMeta, labelMeta, lineChartMeta, markerMeta, marqueeMeta, mediaImageMeta, mediaTileMeta, menuMeta, messageMeta, messageScrollerMeta, meterIndicatorVariants, meterMeta, meterTrackVariants, middleTruncateMeta, noteMeta, noteVariants, notificationBadgeMeta, notificationCountVariants, notificationDotVariants, numberFieldMeta, otpFieldMeta, overflowMenuMeta, pageHeaderMeta, pageLoaderMeta, paginationMeta, panelGroupMeta, panelSectionMeta, parseHex, phoneMeta, pieChartMeta, pillMeta, pillVariants, popoverMeta, progressMeta, progressTrackVariants, radarChartMeta, radialChartMeta, radioCardMeta, radioMeta, relativeTimeMeta, rgbToHsv, scrollerMeta, scrubFieldMeta, searchInputMeta, sectionHeaderMeta, segmentedBarMeta, segmentedBarSegmentVariants, segmentedBarVariants, selectMeta, separatorMeta, settingsMenuMeta, sheetMeta, sheetVariants, showMoreMeta, sideNavMeta, sidebarMeta, skeletonMeta, sliderMeta, sparklineMeta, spinnerMeta, startViewTransition, statusBarMeta, statusDotMeta, statusDotVariants, stepperConnectorVariants, stepperIndicatorVariants, stepperMeta, streamingTextMeta, supportsViewTransitions, switchMeta, tableMeta, tabsMeta, textareaMeta, themeSelectorMeta, themeSwitcherMeta, thinkingOrbMeta, toast, toastMeta, toggleMeta, toolbarGroupVariants, toolbarMeta, toolbarSeparatorVariants, toolbarVariants, tooltipMeta, treeIndent, treeMeta, useAnchoredDensity, useComboboxFilteredItems, useCommandMenuShortcut, useCopy, useDensity, useDisclosurePicker, useInView, useMotion, usePanelWidth, usePresentation, useReducedMotion, useScrollActivity, useSidebar, useToast, useToastManager, useTypeTier, videoMeta, workspaceMeta };
|
package/dist/index.js
CHANGED
|
@@ -160,7 +160,7 @@ import { PresentationProvider, usePresentation } from "./lib/presentation.js";
|
|
|
160
160
|
import { useScrollActivity } from "./lib/use-scroll-activity.js";
|
|
161
161
|
import { useCopy } from "./lib/use-copy.js";
|
|
162
162
|
import { STATUS_TONES, STATUS_TONE_ICON } from "./lib/status-tone.js";
|
|
163
|
-
var SABLE_UI_VERSION = "0.7.
|
|
163
|
+
var SABLE_UI_VERSION = "0.7.1";
|
|
164
164
|
export {
|
|
165
165
|
ART_TOKENS,
|
|
166
166
|
CHART_SERIES_COUNT,
|
package/dist/layout/index.d.ts
CHANGED
|
@@ -12,7 +12,10 @@ type Edge = "top" | "bottom" | "left" | "right";
|
|
|
12
12
|
* - `feed` 640px — single-column timeline / reading feed
|
|
13
13
|
* - `content` 896px — general content + settings/admin panes (some 2-up fields)
|
|
14
14
|
* - `wide` 1280px — dashboards, tables, multi-column grids
|
|
15
|
-
* - `full` 1536px —
|
|
15
|
+
* - `full` 1536px — the largest cap; keeps the inset (edge to edge is `bleed`)
|
|
16
|
+
*
|
|
17
|
+
* Omit `max` for fluid: work views whose panes, board or canvas use the space.
|
|
18
|
+
* A cap is a ceiling, reached only when the viewport minus app chrome exceeds it.
|
|
16
19
|
* - `ultrawide` 1800px — legacy; prefer `full`
|
|
17
20
|
*/
|
|
18
21
|
type RegionMax = "prose" | "form" | "feed" | "content" | "wide" | "full" | "ultrawide";
|
|
@@ -24,7 +27,7 @@ interface RegionProps extends ComponentPropsWithRef<"div"> {
|
|
|
24
27
|
/**
|
|
25
28
|
* Opt-in centered max-width cap: a purpose-named region ("prose" | "form" |
|
|
26
29
|
* "feed" | "content" | "wide" | "full") or any raw CSS length (e.g. "56rem").
|
|
27
|
-
* Omit for
|
|
30
|
+
* Omit for fluid (the default): full width inside the page inset.
|
|
28
31
|
*/
|
|
29
32
|
max?: RegionMax | (string & {});
|
|
30
33
|
/** Full-bleed: edge to edge with no inset. Overrides `inset`. */
|
package/dist/lib/chart/format.js
CHANGED
|
@@ -12,8 +12,13 @@ function categoryText(v, locale) {
|
|
|
12
12
|
function approxTextWidth(text) {
|
|
13
13
|
return text.length * 6.6 + 4;
|
|
14
14
|
}
|
|
15
|
+
function centerValueText(value, locale) {
|
|
16
|
+
if (typeof value === "string") return value;
|
|
17
|
+
return Math.abs(value) < 1e4 ? new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value) : new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 }).format(value);
|
|
18
|
+
}
|
|
15
19
|
export {
|
|
16
20
|
approxTextWidth,
|
|
17
21
|
categoryText,
|
|
22
|
+
centerValueText,
|
|
18
23
|
chartFormatters
|
|
19
24
|
};
|
|
@@ -45,11 +45,16 @@ var componentGuidance = {
|
|
|
45
45
|
keywords: ["region", "page width", "container", "layout"],
|
|
46
46
|
description: "Use Region as the page-width primitive that applies fluid page inset and an optional purpose-named max width.",
|
|
47
47
|
dos: [
|
|
48
|
-
"Choose the max value by
|
|
48
|
+
"Choose the max value by what the person does on the page: read (prose), fill in a form (form), scroll one stream (feed), review one record or settings (content), scan and compare rows or cards (wide).",
|
|
49
|
+
"Omit max (fluid) for work across panes, a board, an editor or a map, where a flexible part uses the extra width; cap text inside it with --measure-prose.",
|
|
50
|
+
"Give sibling routes in one nav the same width so the header does not jump between them.",
|
|
51
|
+
"Review a capped or fluid route at 1920px and 2560px: a cap is a ceiling, so below the cap plus app chrome every choice looks the same.",
|
|
49
52
|
"Use Region near the route or section boundary so inner layout stays intentional."
|
|
50
53
|
],
|
|
51
54
|
donts: [
|
|
52
|
-
"Do not use Region as a global wrapper that forces every page into the same max width."
|
|
55
|
+
"Do not use Region as a global wrapper that forces every page into the same max width.",
|
|
56
|
+
"Do not go fluid when the extra width only widens the gaps between fixed columns; cap it.",
|
|
57
|
+
"Do not read full as edge to edge: it is the largest cap and keeps the page inset. Use bleed for edge to edge."
|
|
53
58
|
],
|
|
54
59
|
related: { components: ["Page", "Grid", "Columns"] }
|
|
55
60
|
}),
|
package/dist/presets/index.d.ts
CHANGED
|
@@ -77,6 +77,12 @@ declare function checkLabelledFills(tokens: {
|
|
|
77
77
|
unmeasured: string[];
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Page widths: the inset and the role caps. A product sets their values, keeping
|
|
82
|
+
* the role names; bundled themes already do. Opt-in, so a template or snapshot
|
|
83
|
+
* leaves them out rather than copying defaults over widths an app set itself.
|
|
84
|
+
*/
|
|
85
|
+
declare const PAGE_WIDTH_TOKENS: readonly ["page-inset", "region-form", "region-feed", "region-content", "region-wide", "region-full"];
|
|
80
86
|
/** The public theme vocabulary. Presets never need component overrides. */
|
|
81
87
|
declare const PRESET_TOKEN_NAMES: readonly string[];
|
|
82
88
|
interface SablePreset {
|
|
@@ -137,4 +143,4 @@ declare function presetLabelContrast(input: SablePreset): {
|
|
|
137
143
|
unmeasured: string[];
|
|
138
144
|
};
|
|
139
145
|
|
|
140
|
-
export { BRAND_FILLS, LABELLED_FILLS, LABEL_CONTRAST_RULE, type LabelFinding, type OklchColor, PRESET_TOKEN_NAMES, type PresetException, STATUS_FILLS, type SablePreset, checkLabelledFills, fitFillToInk, inkFor, inkPolarity, labelContrast, parsePreset, presetCss, presetLabelContrast, presetManifest, validatePreset };
|
|
146
|
+
export { BRAND_FILLS, LABELLED_FILLS, LABEL_CONTRAST_RULE, type LabelFinding, type OklchColor, PAGE_WIDTH_TOKENS, PRESET_TOKEN_NAMES, type PresetException, STATUS_FILLS, type SablePreset, checkLabelledFills, fitFillToInk, inkFor, inkPolarity, labelContrast, parsePreset, presetCss, presetLabelContrast, presetManifest, validatePreset };
|
package/dist/presets/index.js
CHANGED
|
@@ -25,6 +25,14 @@ import {
|
|
|
25
25
|
inkPolarity,
|
|
26
26
|
labelContrast
|
|
27
27
|
} from "../themes/contrast.js";
|
|
28
|
+
var PAGE_WIDTH_TOKENS = [
|
|
29
|
+
"page-inset",
|
|
30
|
+
"region-form",
|
|
31
|
+
"region-feed",
|
|
32
|
+
"region-content",
|
|
33
|
+
"region-wide",
|
|
34
|
+
"region-full"
|
|
35
|
+
];
|
|
28
36
|
var PRESET_TOKEN_NAMES = [.../* @__PURE__ */ new Set([
|
|
29
37
|
...SEMANTIC_TOKENS,
|
|
30
38
|
...WORKSPACE_SURFACE_TOKENS.map((name) => `theme-${name}`),
|
|
@@ -45,6 +53,7 @@ var PRESET_TOKEN_NAMES = [.../* @__PURE__ */ new Set([
|
|
|
45
53
|
"font-mono",
|
|
46
54
|
"font-display",
|
|
47
55
|
"spacing",
|
|
56
|
+
...PAGE_WIDTH_TOKENS,
|
|
48
57
|
"theme-shadow-sm",
|
|
49
58
|
"theme-shadow",
|
|
50
59
|
"theme-shadow-md",
|
|
@@ -202,6 +211,7 @@ export {
|
|
|
202
211
|
BRAND_FILLS,
|
|
203
212
|
LABELLED_FILLS2 as LABELLED_FILLS,
|
|
204
213
|
LABEL_CONTRAST_RULE2 as LABEL_CONTRAST_RULE,
|
|
214
|
+
PAGE_WIDTH_TOKENS,
|
|
205
215
|
PRESET_TOKEN_NAMES,
|
|
206
216
|
STATUS_FILLS,
|
|
207
217
|
checkLabelledFills2 as checkLabelledFills,
|
|
@@ -51,7 +51,7 @@ export function TrafficSourcesBlock({ className, framed = true }: TrafficSources
|
|
|
51
51
|
index="source"
|
|
52
52
|
valueKey="visitors"
|
|
53
53
|
donut
|
|
54
|
-
centerLabel={{ value: total
|
|
54
|
+
centerLabel={{ value: total, label: "Visitors" }}
|
|
55
55
|
/>
|
|
56
56
|
</CardContent>
|
|
57
57
|
<CardFooter className="text-body-sm text-muted-foreground">
|
|
@@ -56,11 +56,13 @@ export function TrafficTrendBlock({ className, framed = true }: TrafficTrendBloc
|
|
|
56
56
|
const [range, setRange] = useState("90d")
|
|
57
57
|
const shown = days.slice(-(ranges.find((r) => r.id === range)?.days ?? 90))
|
|
58
58
|
return (
|
|
59
|
-
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-3xl", className)}>
|
|
60
|
-
<CardHeader>
|
|
59
|
+
<Card variant={framed ? "default" : "flat"} className={cn("@container/trend w-full max-w-3xl", className)}>
|
|
60
|
+
<CardHeader className="@max-sm/trend:has-data-[slot=card-action]:grid-cols-1">
|
|
61
61
|
<CardTitle render={<h2 />}>Visitors</CardTitle>
|
|
62
62
|
<CardDescription>Desktop and mobile, by day.</CardDescription>
|
|
63
|
-
|
|
63
|
+
{/* Beside the title on a wide card; under it on a narrow one, where three
|
|
64
|
+
ranges would squeeze the title and description into a sliver. */}
|
|
65
|
+
<CardAction className="@max-sm/trend:col-start-1 @max-sm/trend:row-span-1 @max-sm/trend:row-start-auto @max-sm/trend:mt-2 @max-sm/trend:justify-self-start">
|
|
64
66
|
<ToggleGroup value={[range]} onValueChange={(v: unknown[]) => v[0] && setRange(String(v[0]))} aria-label="Time range">
|
|
65
67
|
{ranges.map((r) => (
|
|
66
68
|
<Toggle key={r.id} value={r.id}>
|
|
@@ -28,7 +28,7 @@ Use PieChart for a share of one total across two to six categories — traffic s
|
|
|
28
28
|
| categories | Record<string, { label?: string; tone?: ChartTone; color?: string; icon?: string }> | — | Labels, tones and icons per slice, keyed by the slice's name. Unlisted slices take the tones in order. |
|
|
29
29
|
| className | string | "h-64" | Sizes the chart. Set a height. |
|
|
30
30
|
| donut | boolean \| number | false | Cut out the middle: `true` for a ring at 60% of the radius, or a ratio (0–0.9). |
|
|
31
|
-
| centerLabel | { value: string; label?: string } | — | A value and caption in the hole of a donut. |
|
|
31
|
+
| centerLabel | { value: string \| number; label?: string } | — | A value and caption in the hole of a donut. The value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K, 102.1K, 1.2M); pass a string for units. |
|
|
32
32
|
| separator | boolean | true | A hairline in the surface colour between slices. |
|
|
33
33
|
| labels | "none" \| "outside" \| "inside" | "none" | Label each slice outside the pie or inside it. |
|
|
34
34
|
| labelContent | "value" \| "percent" \| "name" | "value" | What a slice label says. |
|
|
@@ -82,7 +82,7 @@ const sources = [
|
|
|
82
82
|
index="source"
|
|
83
83
|
valueKey="visitors"
|
|
84
84
|
donut
|
|
85
|
-
centerLabel={{ value:
|
|
85
|
+
centerLabel={{ value: 752, label: "Visitors" }}
|
|
86
86
|
/>
|
|
87
87
|
```
|
|
88
88
|
|
|
@@ -32,7 +32,7 @@ Use RadialChart for progress toward goals (several rings), a single score or usa
|
|
|
32
32
|
| stacked | boolean | false | Split ONE ring between the rows instead of a ring each. |
|
|
33
33
|
| track | boolean | true | Draw each ring's full track behind it. |
|
|
34
34
|
| labels | boolean | false | Name each ring at its start. |
|
|
35
|
-
| centerLabel | { value: string; label?: string } | — | A value and caption in the middle. |
|
|
35
|
+
| centerLabel | { value: string \| number; label?: string } | — | A value and caption in the middle. On a full ring the value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K); pass a string for units ("7.2 GB", "72%"). |
|
|
36
36
|
| legend | boolean | true unless rings are labelled | Show the legend. |
|
|
37
37
|
| tooltip | boolean \| { indicator?: "dot" \| "line" \| "dashed" \| "none"; hideLabel?: boolean; labelFormatter?: (label, datum) => ReactNode; showTotal?: boolean } | true | Show a tooltip on hover and keyboard focus, or configure it: the row marker, hiding the heading, rewriting it, adding a Total row. |
|
|
38
38
|
| valueFormatter | (value: number) => string | — | Format values everywhere (axis, labels, tooltip, table) — units like "$12,480". Default: compact on the axis, full elsewhere. |
|
package/llms/layout/region.md
CHANGED
|
@@ -12,9 +12,14 @@ Use Region as the page-width primitive that applies fluid page inset and an opti
|
|
|
12
12
|
|
|
13
13
|
### Best practices
|
|
14
14
|
|
|
15
|
-
- Do: Choose the max value by
|
|
15
|
+
- Do: Choose the max value by what the person does on the page: read (prose), fill in a form (form), scroll one stream (feed), review one record or settings (content), scan and compare rows or cards (wide).
|
|
16
|
+
- Do: Omit max (fluid) for work across panes, a board, an editor or a map, where a flexible part uses the extra width; cap text inside it with --measure-prose.
|
|
17
|
+
- Do: Give sibling routes in one nav the same width so the header does not jump between them.
|
|
18
|
+
- Do: Review a capped or fluid route at 1920px and 2560px: a cap is a ceiling, so below the cap plus app chrome every choice looks the same.
|
|
16
19
|
- Do: Use Region near the route or section boundary so inner layout stays intentional.
|
|
17
20
|
- Don't: Do not use Region as a global wrapper that forces every page into the same max width.
|
|
21
|
+
- Don't: Do not go fluid when the extra width only widens the gaps between fixed columns; cap it.
|
|
22
|
+
- Don't: Do not read full as edge to edge: it is the largest cap and keeps the page inset. Use bleed for edge to edge.
|
|
18
23
|
|
|
19
24
|
## Props
|
|
20
25
|
|
|
@@ -22,7 +27,7 @@ Use Region as the page-width primitive that applies fluid page inset and an opti
|
|
|
22
27
|
| --- | --- | --- | --- |
|
|
23
28
|
| as | ElementType | "div" | Semantic element to render (e.g. "main", "section", "nav"). |
|
|
24
29
|
| inset | boolean | true | Apply the fluid horizontal page inset (--page-inset). |
|
|
25
|
-
| max | "prose" \| "form" \| "feed" \| "content" \| "wide" \| "full" \| CSS length | — | Opt-in centered max-width cap, purpose-named: prose 65ch (long-form reading) · form 480px (auth, narrow single-column forms) · feed 640px (single-column timeline) · content 896px (settings/admin panes) · wide 1280px (dashboards, tables, card grids) · full 1536px (
|
|
30
|
+
| max | "prose" \| "form" \| "feed" \| "content" \| "wide" \| "full" \| CSS length | — | Opt-in centered max-width cap, purpose-named: prose 65ch (long-form reading) · form 480px (auth, narrow single-column forms) · feed 640px (single-column timeline) · content 896px (settings/admin panes) · wide 1280px (dashboards, tables, card grids) · full 1536px (the largest cap; keeps the inset, so not edge to edge — that is bleed). Omit for fluid: work views whose panes, board or canvas use the extra width. A cap is a ceiling: it applies only once the viewport minus app chrome exceeds it, so judge it at 1920px and up. |
|
|
26
31
|
| bleed | boolean | false | Full-bleed: edge to edge with no inset. Overrides inset. |
|
|
27
32
|
| safeArea | boolean \| ("top"\|"bottom"\|"left"\|"right")[] | false | env(safe-area-inset-*) padding for fixed or native-shell surfaces. true pads top+bottom; pass an edge array to target edges. |
|
|
28
33
|
|
|
@@ -40,10 +45,18 @@ import { PageHeader, Region } from "@sableui/react"
|
|
|
40
45
|
<Region as="main" max="content"><PageHeader title="Settings" /></Region>
|
|
41
46
|
```
|
|
42
47
|
|
|
43
|
-
###
|
|
48
|
+
### Dashboard region (capped at wide)
|
|
44
49
|
|
|
45
50
|
```tsx
|
|
46
51
|
import { Card, CardHeader, CardTitle, Grid, Region } from "@sableui/react"
|
|
47
52
|
|
|
48
53
|
<Region max="wide"><Grid minItemWidth="16rem"><Card><CardHeader><CardTitle>Revenue</CardTitle></CardHeader></Card><Card><CardHeader><CardTitle>Collects</CardTitle></CardHeader></Card></Grid></Region>
|
|
49
54
|
```
|
|
55
|
+
|
|
56
|
+
### Fluid workspace: the queue's table takes the extra width
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { Card, CardContent, Col, Columns, DataTable, Region, SideNav, SideNavItem } from "@sableui/react"
|
|
60
|
+
|
|
61
|
+
<Region as="main"><Columns count={{ base: 1, lg: 12 }}><Col span={{ base: 1, lg: 3 }}><SideNav aria-label="Views"><SideNavItem label="Open" active /><SideNavItem label="Pending" /></SideNav></Col><Col span={{ base: 1, lg: 9 }}><Card><CardContent flush><DataTable columns={columns} data={tickets} /></CardContent></Card></Col></Columns></Region>
|
|
62
|
+
```
|
|
@@ -30,9 +30,9 @@ designs. Record the reason for the chosen composition.
|
|
|
30
30
|
</AppShell>
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
- **Never a global `max-width`.** Width is
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
- **Never a global `max-width`.** Width is chosen per route by what the person
|
|
34
|
+
does there (below). A form region is narrow because a 700px text input is
|
|
35
|
+
harder to use, not more generous.
|
|
36
36
|
- **Measure ≠ width.** Cap prose line length in `ch` (`--measure-prose`)
|
|
37
37
|
independently of the region's layout width.
|
|
38
38
|
- **The 12-column grid lives *inside* a region**, for genuinely multi-column
|
|
@@ -40,6 +40,37 @@ designs. Record the reason for the chosen composition.
|
|
|
40
40
|
- **Chrome is off the grid.** `Sidebar` and rails are fixed-width, outside the
|
|
41
41
|
content grid.
|
|
42
42
|
|
|
43
|
+
## Choosing a width
|
|
44
|
+
|
|
45
|
+
Pick each route's `max` by what the person mainly does on it:
|
|
46
|
+
|
|
47
|
+
| They mainly… | `max` | Why |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| read long text | `prose` on the text, inside `content` | line length sets readability: cap the text, not the page |
|
|
50
|
+
| fill in a form | `form` | narrow fields are faster to scan and fill |
|
|
51
|
+
| scroll one stream | `feed` | one column, one eye path |
|
|
52
|
+
| review one record, or settings | `content` | labels stay close to their values |
|
|
53
|
+
| scan and compare rows, cards or charts | `wide` | room for columns without stretching the gaps between them |
|
|
54
|
+
| work across panes, a board, an editor or a map | omit it (fluid), or `full` | the extra width goes to the parts that use it |
|
|
55
|
+
|
|
56
|
+
- **Go fluid only when a flexible part uses the space**: a table's main column,
|
|
57
|
+
a canvas, a board, a reading pane. If extra width only widens the gaps between
|
|
58
|
+
fixed columns (a date drifting away from its subject), cap it.
|
|
59
|
+
- **In a fluid page, cap text by line length** (`--measure-prose`, or
|
|
60
|
+
`Center max="prose"`), not by page width.
|
|
61
|
+
- **Sibling routes in one nav share a width**, so the header doesn't jump as
|
|
62
|
+
people move between them. A detail view may differ.
|
|
63
|
+
- **`full` is the largest cap, not edge to edge.** It keeps the page inset;
|
|
64
|
+
`bleed` is edge to edge.
|
|
65
|
+
- **A cap is a ceiling.** It applies only once the viewport minus app chrome is
|
|
66
|
+
wider than it: beside a 256px sidebar, `wide` (1280px) starts at about 1536px,
|
|
67
|
+
so at 1440px `wide`, `full` and fluid look the same. Judge widths at 1920px and
|
|
68
|
+
2560px, and record each route's width and reason in the brief.
|
|
69
|
+
- **A product's widths belong in its theme.** A preset may set `page-inset` and
|
|
70
|
+
`region-*` (`form`, `feed`, `content`, `wide`, `full`) in `tokens.shared`
|
|
71
|
+
(a copy in `light` or `dark` wins over it); keep the role names and change
|
|
72
|
+
the values.
|
|
73
|
+
|
|
43
74
|
## Typography: pick the tier by ROLE, not by size
|
|
44
75
|
|
|
45
76
|
| tier | for | not |
|
|
@@ -13,7 +13,10 @@ cannot establish animation timing, hidden behavior, or an unseen responsive stat
|
|
|
13
13
|
Choose an original direction suitable for the user's future product. Map that
|
|
14
14
|
direction onto the installed Sable token vocabulary. Preserve readable semantic
|
|
15
15
|
roles and supported control behavior rather than recoloring every primitive
|
|
16
|
-
independently. Load fonts through the consuming project's font setup.
|
|
16
|
+
independently. Load fonts through the consuming project's font setup. Page widths
|
|
17
|
+
are optional: set `page-inset` and the `region-*` caps (`form`, `feed`, `content`,
|
|
18
|
+
`wide`, `full`) in `tokens.shared` only when the direction calls for a different
|
|
19
|
+
page proportion, and keep each cap's role.
|
|
17
20
|
|
|
18
21
|
```bash
|
|
19
22
|
pnpm exec sable preset --template
|
|
@@ -10,6 +10,10 @@ Save the surface brief, then capture the rendered route at a desktop width of at
|
|
|
10
10
|
least 1024px and a mobile width of at most 480px. Record the actual viewport,
|
|
11
11
|
route/state, supported color mode, and motion setting with each artifact. Inspect
|
|
12
12
|
the whole route as well as its first viewport. Ensure fonts and media have loaded.
|
|
13
|
+
Also capture a route that sets a width cap, or goes fluid, at 1920px: a cap applies
|
|
14
|
+
only once the viewport minus app chrome exceeds it, so at 1024–1440px `wide`,
|
|
15
|
+
`full` and fluid usually look the same and the width decision never shows. Add
|
|
16
|
+
2560px for a fluid route.
|
|
13
17
|
Use a recording or an observed interaction report for behavior that a still cannot
|
|
14
18
|
show. Include reduced motion and every color mode the surface promises to support.
|
|
15
19
|
|
|
@@ -76,6 +76,25 @@ Use a unique preset ID so it cannot override a bundled theme or another record.
|
|
|
76
76
|
Record any intentional adaptation and run the validation and rendered review in
|
|
77
77
|
[building-with-sable.md](./building-with-sable.md).
|
|
78
78
|
|
|
79
|
+
## Apply to an existing app
|
|
80
|
+
|
|
81
|
+
A preset pasted into an agent for a running app (Canvas's “Copy for agent”) is a
|
|
82
|
+
restyle: the job ends with the app built and rendered in the preset, not with a
|
|
83
|
+
saved file. Replace, don't add beside:
|
|
84
|
+
|
|
85
|
+
1. Save it to `.sable/presets/<id>.json`, overwriting an earlier revision of the
|
|
86
|
+
same ID, and run `sable preset --check`.
|
|
87
|
+
2. Regenerate its CSS with `sable preset --css` and import it after Sable's
|
|
88
|
+
stylesheet. Remove the stylesheet of a theme it replaces when nothing else uses it.
|
|
89
|
+
3. Load the families its `fonts` stacks name through the app's existing font
|
|
90
|
+
setup, and drop the old theme's font loading when no other code uses it.
|
|
91
|
+
An unloaded family falls back to a system face without any error.
|
|
92
|
+
4. Point SableThemeProvider's `defaultThemeId` at the preset ID and
|
|
93
|
+
`defaultPreference` at its `defaultMode`, unless the user asked for another
|
|
94
|
+
mode. A bundled theme left as the default keeps the app on the old look.
|
|
95
|
+
5. Run the app's build and type check, then `sable check`, and review the result
|
|
96
|
+
in both modes.
|
|
97
|
+
|
|
79
98
|
## Upgrade the package and skill
|
|
80
99
|
|
|
81
100
|
```bash
|
package/llms-full.txt
CHANGED
|
@@ -627,9 +627,9 @@ designs. Record the reason for the chosen composition.
|
|
|
627
627
|
</AppShell>
|
|
628
628
|
```
|
|
629
629
|
|
|
630
|
-
- **Never a global `max-width`.** Width is
|
|
631
|
-
|
|
632
|
-
|
|
630
|
+
- **Never a global `max-width`.** Width is chosen per route by what the person
|
|
631
|
+
does there (below). A form region is narrow because a 700px text input is
|
|
632
|
+
harder to use, not more generous.
|
|
633
633
|
- **Measure ≠ width.** Cap prose line length in `ch` (`--measure-prose`)
|
|
634
634
|
independently of the region's layout width.
|
|
635
635
|
- **The 12-column grid lives *inside* a region**, for genuinely multi-column
|
|
@@ -637,6 +637,37 @@ designs. Record the reason for the chosen composition.
|
|
|
637
637
|
- **Chrome is off the grid.** `Sidebar` and rails are fixed-width, outside the
|
|
638
638
|
content grid.
|
|
639
639
|
|
|
640
|
+
## Choosing a width
|
|
641
|
+
|
|
642
|
+
Pick each route's `max` by what the person mainly does on it:
|
|
643
|
+
|
|
644
|
+
| They mainly… | `max` | Why |
|
|
645
|
+
|---|---|---|
|
|
646
|
+
| read long text | `prose` on the text, inside `content` | line length sets readability: cap the text, not the page |
|
|
647
|
+
| fill in a form | `form` | narrow fields are faster to scan and fill |
|
|
648
|
+
| scroll one stream | `feed` | one column, one eye path |
|
|
649
|
+
| review one record, or settings | `content` | labels stay close to their values |
|
|
650
|
+
| scan and compare rows, cards or charts | `wide` | room for columns without stretching the gaps between them |
|
|
651
|
+
| work across panes, a board, an editor or a map | omit it (fluid), or `full` | the extra width goes to the parts that use it |
|
|
652
|
+
|
|
653
|
+
- **Go fluid only when a flexible part uses the space**: a table's main column,
|
|
654
|
+
a canvas, a board, a reading pane. If extra width only widens the gaps between
|
|
655
|
+
fixed columns (a date drifting away from its subject), cap it.
|
|
656
|
+
- **In a fluid page, cap text by line length** (`--measure-prose`, or
|
|
657
|
+
`Center max="prose"`), not by page width.
|
|
658
|
+
- **Sibling routes in one nav share a width**, so the header doesn't jump as
|
|
659
|
+
people move between them. A detail view may differ.
|
|
660
|
+
- **`full` is the largest cap, not edge to edge.** It keeps the page inset;
|
|
661
|
+
`bleed` is edge to edge.
|
|
662
|
+
- **A cap is a ceiling.** It applies only once the viewport minus app chrome is
|
|
663
|
+
wider than it: beside a 256px sidebar, `wide` (1280px) starts at about 1536px,
|
|
664
|
+
so at 1440px `wide`, `full` and fluid look the same. Judge widths at 1920px and
|
|
665
|
+
2560px, and record each route's width and reason in the brief.
|
|
666
|
+
- **A product's widths belong in its theme.** A preset may set `page-inset` and
|
|
667
|
+
`region-*` (`form`, `feed`, `content`, `wide`, `full`) in `tokens.shared`
|
|
668
|
+
(a copy in `light` or `dark` wins over it); keep the role names and change
|
|
669
|
+
the values.
|
|
670
|
+
|
|
640
671
|
## Typography: pick the tier by ROLE, not by size
|
|
641
672
|
|
|
642
673
|
| tier | for | not |
|
|
@@ -1319,7 +1350,10 @@ cannot establish animation timing, hidden behavior, or an unseen responsive stat
|
|
|
1319
1350
|
Choose an original direction suitable for the user's future product. Map that
|
|
1320
1351
|
direction onto the installed Sable token vocabulary. Preserve readable semantic
|
|
1321
1352
|
roles and supported control behavior rather than recoloring every primitive
|
|
1322
|
-
independently. Load fonts through the consuming project's font setup.
|
|
1353
|
+
independently. Load fonts through the consuming project's font setup. Page widths
|
|
1354
|
+
are optional: set `page-inset` and the `region-*` caps (`form`, `feed`, `content`,
|
|
1355
|
+
`wide`, `full`) in `tokens.shared` only when the direction calls for a different
|
|
1356
|
+
page proportion, and keep each cap's role.
|
|
1323
1357
|
|
|
1324
1358
|
```bash
|
|
1325
1359
|
pnpm exec sable preset --template
|
|
@@ -1910,6 +1944,10 @@ Save the surface brief, then capture the rendered route at a desktop width of at
|
|
|
1910
1944
|
least 1024px and a mobile width of at most 480px. Record the actual viewport,
|
|
1911
1945
|
route/state, supported color mode, and motion setting with each artifact. Inspect
|
|
1912
1946
|
the whole route as well as its first viewport. Ensure fonts and media have loaded.
|
|
1947
|
+
Also capture a route that sets a width cap, or goes fluid, at 1920px: a cap applies
|
|
1948
|
+
only once the viewport minus app chrome exceeds it, so at 1024–1440px `wide`,
|
|
1949
|
+
`full` and fluid usually look the same and the width decision never shows. Add
|
|
1950
|
+
2560px for a fluid route.
|
|
1913
1951
|
Use a recording or an observed interaction report for behavior that a still cannot
|
|
1914
1952
|
show. Include reduced motion and every color mode the surface promises to support.
|
|
1915
1953
|
|
|
@@ -2299,6 +2337,25 @@ Use a unique preset ID so it cannot override a bundled theme or another record.
|
|
|
2299
2337
|
Record any intentional adaptation and run the validation and rendered review in
|
|
2300
2338
|
[building-with-sable.md](./building-with-sable.md).
|
|
2301
2339
|
|
|
2340
|
+
## Apply to an existing app
|
|
2341
|
+
|
|
2342
|
+
A preset pasted into an agent for a running app (Canvas's “Copy for agent”) is a
|
|
2343
|
+
restyle: the job ends with the app built and rendered in the preset, not with a
|
|
2344
|
+
saved file. Replace, don't add beside:
|
|
2345
|
+
|
|
2346
|
+
1. Save it to `.sable/presets/<id>.json`, overwriting an earlier revision of the
|
|
2347
|
+
same ID, and run `sable preset --check`.
|
|
2348
|
+
2. Regenerate its CSS with `sable preset --css` and import it after Sable's
|
|
2349
|
+
stylesheet. Remove the stylesheet of a theme it replaces when nothing else uses it.
|
|
2350
|
+
3. Load the families its `fonts` stacks name through the app's existing font
|
|
2351
|
+
setup, and drop the old theme's font loading when no other code uses it.
|
|
2352
|
+
An unloaded family falls back to a system face without any error.
|
|
2353
|
+
4. Point SableThemeProvider's `defaultThemeId` at the preset ID and
|
|
2354
|
+
`defaultPreference` at its `defaultMode`, unless the user asked for another
|
|
2355
|
+
mode. A bundled theme left as the default keeps the app on the old look.
|
|
2356
|
+
5. Run the app's build and type check, then `sable check`, and review the result
|
|
2357
|
+
in both modes.
|
|
2358
|
+
|
|
2302
2359
|
## Upgrade the package and skill
|
|
2303
2360
|
|
|
2304
2361
|
```bash
|
|
@@ -8601,7 +8658,7 @@ export function TrafficSourcesBlock({ className, framed = true }: TrafficSources
|
|
|
8601
8658
|
index="source"
|
|
8602
8659
|
valueKey="visitors"
|
|
8603
8660
|
donut
|
|
8604
|
-
centerLabel={{ value: total
|
|
8661
|
+
centerLabel={{ value: total, label: "Visitors" }}
|
|
8605
8662
|
/>
|
|
8606
8663
|
</CardContent>
|
|
8607
8664
|
<CardFooter className="text-body-sm text-muted-foreground">
|
|
@@ -8672,11 +8729,13 @@ export function TrafficTrendBlock({ className, framed = true }: TrafficTrendBloc
|
|
|
8672
8729
|
const [range, setRange] = useState("90d")
|
|
8673
8730
|
const shown = days.slice(-(ranges.find((r) => r.id === range)?.days ?? 90))
|
|
8674
8731
|
return (
|
|
8675
|
-
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-3xl", className)}>
|
|
8676
|
-
<CardHeader>
|
|
8732
|
+
<Card variant={framed ? "default" : "flat"} className={cn("@container/trend w-full max-w-3xl", className)}>
|
|
8733
|
+
<CardHeader className="@max-sm/trend:has-data-[slot=card-action]:grid-cols-1">
|
|
8677
8734
|
<CardTitle render={<h2 />}>Visitors</CardTitle>
|
|
8678
8735
|
<CardDescription>Desktop and mobile, by day.</CardDescription>
|
|
8679
|
-
|
|
8736
|
+
{/* Beside the title on a wide card; under it on a narrow one, where three
|
|
8737
|
+
ranges would squeeze the title and description into a sliver. */}
|
|
8738
|
+
<CardAction className="@max-sm/trend:col-start-1 @max-sm/trend:row-span-1 @max-sm/trend:row-start-auto @max-sm/trend:mt-2 @max-sm/trend:justify-self-start">
|
|
8680
8739
|
<ToggleGroup value={[range]} onValueChange={(v: unknown[]) => v[0] && setRange(String(v[0]))} aria-label="Time range">
|
|
8681
8740
|
{ranges.map((r) => (
|
|
8682
8741
|
<Toggle key={r.id} value={r.id}>
|
|
@@ -8944,9 +9003,14 @@ Use Region as the page-width primitive that applies fluid page inset and an opti
|
|
|
8944
9003
|
|
|
8945
9004
|
### Best practices
|
|
8946
9005
|
|
|
8947
|
-
- Do: Choose the max value by
|
|
9006
|
+
- Do: Choose the max value by what the person does on the page: read (prose), fill in a form (form), scroll one stream (feed), review one record or settings (content), scan and compare rows or cards (wide).
|
|
9007
|
+
- Do: Omit max (fluid) for work across panes, a board, an editor or a map, where a flexible part uses the extra width; cap text inside it with --measure-prose.
|
|
9008
|
+
- Do: Give sibling routes in one nav the same width so the header does not jump between them.
|
|
9009
|
+
- Do: Review a capped or fluid route at 1920px and 2560px: a cap is a ceiling, so below the cap plus app chrome every choice looks the same.
|
|
8948
9010
|
- Do: Use Region near the route or section boundary so inner layout stays intentional.
|
|
8949
9011
|
- Don't: Do not use Region as a global wrapper that forces every page into the same max width.
|
|
9012
|
+
- Don't: Do not go fluid when the extra width only widens the gaps between fixed columns; cap it.
|
|
9013
|
+
- Don't: Do not read full as edge to edge: it is the largest cap and keeps the page inset. Use bleed for edge to edge.
|
|
8950
9014
|
|
|
8951
9015
|
## Props
|
|
8952
9016
|
|
|
@@ -8954,7 +9018,7 @@ Use Region as the page-width primitive that applies fluid page inset and an opti
|
|
|
8954
9018
|
| --- | --- | --- | --- |
|
|
8955
9019
|
| as | ElementType | "div" | Semantic element to render (e.g. "main", "section", "nav"). |
|
|
8956
9020
|
| inset | boolean | true | Apply the fluid horizontal page inset (--page-inset). |
|
|
8957
|
-
| max | "prose" \| "form" \| "feed" \| "content" \| "wide" \| "full" \| CSS length | — | Opt-in centered max-width cap, purpose-named: prose 65ch (long-form reading) · form 480px (auth, narrow single-column forms) · feed 640px (single-column timeline) · content 896px (settings/admin panes) · wide 1280px (dashboards, tables, card grids) · full 1536px (
|
|
9021
|
+
| max | "prose" \| "form" \| "feed" \| "content" \| "wide" \| "full" \| CSS length | — | Opt-in centered max-width cap, purpose-named: prose 65ch (long-form reading) · form 480px (auth, narrow single-column forms) · feed 640px (single-column timeline) · content 896px (settings/admin panes) · wide 1280px (dashboards, tables, card grids) · full 1536px (the largest cap; keeps the inset, so not edge to edge — that is bleed). Omit for fluid: work views whose panes, board or canvas use the extra width. A cap is a ceiling: it applies only once the viewport minus app chrome exceeds it, so judge it at 1920px and up. |
|
|
8958
9022
|
| bleed | boolean | false | Full-bleed: edge to edge with no inset. Overrides inset. |
|
|
8959
9023
|
| safeArea | boolean \| ("top"\|"bottom"\|"left"\|"right")[] | false | env(safe-area-inset-*) padding for fixed or native-shell surfaces. true pads top+bottom; pass an edge array to target edges. |
|
|
8960
9024
|
|
|
@@ -8972,7 +9036,7 @@ import { PageHeader, Region } from "@sableui/react"
|
|
|
8972
9036
|
<Region as="main" max="content"><PageHeader title="Settings" /></Region>
|
|
8973
9037
|
```
|
|
8974
9038
|
|
|
8975
|
-
###
|
|
9039
|
+
### Dashboard region (capped at wide)
|
|
8976
9040
|
|
|
8977
9041
|
```tsx
|
|
8978
9042
|
import { Card, CardHeader, CardTitle, Grid, Region } from "@sableui/react"
|
|
@@ -8980,6 +9044,14 @@ import { Card, CardHeader, CardTitle, Grid, Region } from "@sableui/react"
|
|
|
8980
9044
|
<Region max="wide"><Grid minItemWidth="16rem"><Card><CardHeader><CardTitle>Revenue</CardTitle></CardHeader></Card><Card><CardHeader><CardTitle>Collects</CardTitle></CardHeader></Card></Grid></Region>
|
|
8981
9045
|
```
|
|
8982
9046
|
|
|
9047
|
+
### Fluid workspace: the queue's table takes the extra width
|
|
9048
|
+
|
|
9049
|
+
```tsx
|
|
9050
|
+
import { Card, CardContent, Col, Columns, DataTable, Region, SideNav, SideNavItem } from "@sableui/react"
|
|
9051
|
+
|
|
9052
|
+
<Region as="main"><Columns count={{ base: 1, lg: 12 }}><Col span={{ base: 1, lg: 3 }}><SideNav aria-label="Views"><SideNavItem label="Open" active /><SideNavItem label="Pending" /></SideNav></Col><Col span={{ base: 1, lg: 9 }}><Card><CardContent flush><DataTable columns={columns} data={tickets} /></CardContent></Card></Col></Columns></Region>
|
|
9053
|
+
```
|
|
9054
|
+
|
|
8983
9055
|
---
|
|
8984
9056
|
|
|
8985
9057
|
# Page
|
|
@@ -16510,7 +16582,7 @@ Use PieChart for a share of one total across two to six categories — traffic s
|
|
|
16510
16582
|
| categories | Record<string, { label?: string; tone?: ChartTone; color?: string; icon?: string }> | — | Labels, tones and icons per slice, keyed by the slice's name. Unlisted slices take the tones in order. |
|
|
16511
16583
|
| className | string | "h-64" | Sizes the chart. Set a height. |
|
|
16512
16584
|
| donut | boolean \| number | false | Cut out the middle: `true` for a ring at 60% of the radius, or a ratio (0–0.9). |
|
|
16513
|
-
| centerLabel | { value: string; label?: string } | — | A value and caption in the hole of a donut. |
|
|
16585
|
+
| centerLabel | { value: string \| number; label?: string } | — | A value and caption in the hole of a donut. The value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K, 102.1K, 1.2M); pass a string for units. |
|
|
16514
16586
|
| separator | boolean | true | A hairline in the surface colour between slices. |
|
|
16515
16587
|
| labels | "none" \| "outside" \| "inside" | "none" | Label each slice outside the pie or inside it. |
|
|
16516
16588
|
| labelContent | "value" \| "percent" \| "name" | "value" | What a slice label says. |
|
|
@@ -16564,7 +16636,7 @@ const sources = [
|
|
|
16564
16636
|
index="source"
|
|
16565
16637
|
valueKey="visitors"
|
|
16566
16638
|
donut
|
|
16567
|
-
centerLabel={{ value:
|
|
16639
|
+
centerLabel={{ value: 752, label: "Visitors" }}
|
|
16568
16640
|
/>
|
|
16569
16641
|
```
|
|
16570
16642
|
|
|
@@ -16988,7 +17060,7 @@ Use RadialChart for progress toward goals (several rings), a single score or usa
|
|
|
16988
17060
|
| stacked | boolean | false | Split ONE ring between the rows instead of a ring each. |
|
|
16989
17061
|
| track | boolean | true | Draw each ring's full track behind it. |
|
|
16990
17062
|
| labels | boolean | false | Name each ring at its start. |
|
|
16991
|
-
| centerLabel | { value: string; label?: string } | — | A value and caption in the middle. |
|
|
17063
|
+
| centerLabel | { value: string \| number; label?: string } | — | A value and caption in the middle. On a full ring the value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K); pass a string for units ("7.2 GB", "72%"). |
|
|
16992
17064
|
| legend | boolean | true unless rings are labelled | Show the legend. |
|
|
16993
17065
|
| tooltip | boolean \| { indicator?: "dot" \| "line" \| "dashed" \| "none"; hideLabel?: boolean; labelFormatter?: (label, datum) => ReactNode; showTotal?: boolean } | true | Show a tooltip on hover and keyboard focus, or configure it: the row marker, hiding the heading, rewriting it, adding a Total row. |
|
|
16994
17066
|
| valueFormatter | (value: number) => string | — | Format values everywhere (axis, labels, tooltip, table) — units like "$12,480". Default: compact on the axis, full elsewhere. |
|
package/package.json
CHANGED
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.7.
|
|
4
|
+
"version": "0.7.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",
|
|
@@ -1706,7 +1706,19 @@
|
|
|
1706
1706
|
"bestPractices": [
|
|
1707
1707
|
{
|
|
1708
1708
|
"guidance": "do",
|
|
1709
|
-
"description": "Choose the max value by
|
|
1709
|
+
"description": "Choose the max value by what the person does on the page: read (prose), fill in a form (form), scroll one stream (feed), review one record or settings (content), scan and compare rows or cards (wide)."
|
|
1710
|
+
},
|
|
1711
|
+
{
|
|
1712
|
+
"guidance": "do",
|
|
1713
|
+
"description": "Omit max (fluid) for work across panes, a board, an editor or a map, where a flexible part uses the extra width; cap text inside it with --measure-prose."
|
|
1714
|
+
},
|
|
1715
|
+
{
|
|
1716
|
+
"guidance": "do",
|
|
1717
|
+
"description": "Give sibling routes in one nav the same width so the header does not jump between them."
|
|
1718
|
+
},
|
|
1719
|
+
{
|
|
1720
|
+
"guidance": "do",
|
|
1721
|
+
"description": "Review a capped or fluid route at 1920px and 2560px: a cap is a ceiling, so below the cap plus app chrome every choice looks the same."
|
|
1710
1722
|
},
|
|
1711
1723
|
{
|
|
1712
1724
|
"guidance": "do",
|
|
@@ -1715,6 +1727,14 @@
|
|
|
1715
1727
|
{
|
|
1716
1728
|
"guidance": "dont",
|
|
1717
1729
|
"description": "Do not use Region as a global wrapper that forces every page into the same max width."
|
|
1730
|
+
},
|
|
1731
|
+
{
|
|
1732
|
+
"guidance": "dont",
|
|
1733
|
+
"description": "Do not go fluid when the extra width only widens the gaps between fixed columns; cap it."
|
|
1734
|
+
},
|
|
1735
|
+
{
|
|
1736
|
+
"guidance": "dont",
|
|
1737
|
+
"description": "Do not read full as edge to edge: it is the largest cap and keeps the page inset. Use bleed for edge to edge."
|
|
1718
1738
|
}
|
|
1719
1739
|
]
|
|
1720
1740
|
},
|
|
@@ -1741,7 +1761,7 @@
|
|
|
1741
1761
|
{
|
|
1742
1762
|
"name": "max",
|
|
1743
1763
|
"type": "\"prose\" | \"form\" | \"feed\" | \"content\" | \"wide\" | \"full\" | CSS length",
|
|
1744
|
-
"description": "Opt-in centered max-width cap, purpose-named: prose 65ch (long-form reading) · form 480px (auth, narrow single-column forms) · feed 640px (single-column timeline) · content 896px (settings/admin panes) · wide 1280px (dashboards, tables, card grids) · full 1536px (
|
|
1764
|
+
"description": "Opt-in centered max-width cap, purpose-named: prose 65ch (long-form reading) · form 480px (auth, narrow single-column forms) · feed 640px (single-column timeline) · content 896px (settings/admin panes) · wide 1280px (dashboards, tables, card grids) · full 1536px (the largest cap; keeps the inset, so not edge to edge — that is bleed). Omit for fluid: work views whose panes, board or canvas use the extra width. A cap is a ceiling: it applies only once the viewport minus app chrome exceeds it, so judge it at 1920px and up."
|
|
1745
1765
|
},
|
|
1746
1766
|
{
|
|
1747
1767
|
"name": "bleed",
|
|
@@ -1764,9 +1784,14 @@
|
|
|
1764
1784
|
"imports": "import { PageHeader, Region } from \"@sableui/react\""
|
|
1765
1785
|
},
|
|
1766
1786
|
{
|
|
1767
|
-
"label": "
|
|
1787
|
+
"label": "Dashboard region (capped at wide)",
|
|
1768
1788
|
"code": "<Region max=\"wide\"><Grid minItemWidth=\"16rem\"><Card><CardHeader><CardTitle>Revenue</CardTitle></CardHeader></Card><Card><CardHeader><CardTitle>Collects</CardTitle></CardHeader></Card></Grid></Region>",
|
|
1769
1789
|
"imports": "import { Card, CardHeader, CardTitle, Grid, Region } from \"@sableui/react\""
|
|
1790
|
+
},
|
|
1791
|
+
{
|
|
1792
|
+
"label": "Fluid workspace: the queue's table takes the extra width",
|
|
1793
|
+
"code": "<Region as=\"main\"><Columns count={{ base: 1, lg: 12 }}><Col span={{ base: 1, lg: 3 }}><SideNav aria-label=\"Views\"><SideNavItem label=\"Open\" active /><SideNavItem label=\"Pending\" /></SideNav></Col><Col span={{ base: 1, lg: 9 }}><Card><CardContent flush><DataTable columns={columns} data={tickets} /></CardContent></Card></Col></Columns></Region>",
|
|
1794
|
+
"imports": "import { Card, CardContent, Col, Columns, DataTable, Region, SideNav, SideNavItem } from \"@sableui/react\""
|
|
1770
1795
|
}
|
|
1771
1796
|
]
|
|
1772
1797
|
},
|
|
@@ -13561,8 +13586,8 @@
|
|
|
13561
13586
|
},
|
|
13562
13587
|
{
|
|
13563
13588
|
"name": "centerLabel",
|
|
13564
|
-
"type": "{ value: string; label?: string }",
|
|
13565
|
-
"description": "A value and caption in the hole of a donut."
|
|
13589
|
+
"type": "{ value: string | number; label?: string }",
|
|
13590
|
+
"description": "A value and caption in the hole of a donut. The value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K, 102.1K, 1.2M); pass a string for units."
|
|
13566
13591
|
},
|
|
13567
13592
|
{
|
|
13568
13593
|
"name": "separator",
|
|
@@ -13675,7 +13700,7 @@
|
|
|
13675
13700
|
"examples": [
|
|
13676
13701
|
{
|
|
13677
13702
|
"label": "Donut with a total",
|
|
13678
|
-
"code": "const sources = [\n\t{ source: \"Search\", visitors: 275 },\n\t{ source: \"Direct\", visitors: 200 },\n\t{ source: \"Social\", visitors: 187 },\n\t{ source: \"Email\", visitors: 90 },\n]\n\n<PieChart\n\tlabel=\"Visitors by source\"\n\tdata={sources}\n\tindex=\"source\"\n\tvalueKey=\"visitors\"\n\tdonut\n\tcenterLabel={{ value:
|
|
13703
|
+
"code": "const sources = [\n\t{ source: \"Search\", visitors: 275 },\n\t{ source: \"Direct\", visitors: 200 },\n\t{ source: \"Social\", visitors: 187 },\n\t{ source: \"Email\", visitors: 90 },\n]\n\n<PieChart\n\tlabel=\"Visitors by source\"\n\tdata={sources}\n\tindex=\"source\"\n\tvalueKey=\"visitors\"\n\tdonut\n\tcenterLabel={{ value: 752, label: \"Visitors\" }}\n/>",
|
|
13679
13704
|
"imports": "import { PieChart } from \"@sableui/react\""
|
|
13680
13705
|
},
|
|
13681
13706
|
{
|
|
@@ -14439,8 +14464,8 @@
|
|
|
14439
14464
|
},
|
|
14440
14465
|
{
|
|
14441
14466
|
"name": "centerLabel",
|
|
14442
|
-
"type": "{ value: string; label?: string }",
|
|
14443
|
-
"description": "A value and caption in the middle."
|
|
14467
|
+
"type": "{ value: string | number; label?: string }",
|
|
14468
|
+
"description": "A value and caption in the middle. On a full ring the value holds the centre and the caption hangs below it. A number is formatted for you: in full up to 9,999, compact from 10,000 (10.2K); pass a string for units (\"7.2 GB\", \"72%\")."
|
|
14444
14469
|
},
|
|
14445
14470
|
{
|
|
14446
14471
|
"name": "legend",
|
|
@@ -21174,7 +21199,7 @@
|
|
|
21174
21199
|
"Hovering or arrowing through the slices pulls each out and announces it."
|
|
21175
21200
|
],
|
|
21176
21201
|
"imports": "import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, PieChart } from \"@sableui/react\"",
|
|
21177
|
-
"code": "const sources = [\n\t{ source: \"Search\", visitors: 4280 },\n\t{ source: \"Direct\", visitors: 2910 },\n\t{ source: \"Social\", visitors: 1840 },\n\t{ source: \"Email\", visitors: 960 },\n]\n\nconst total = sources.reduce((sum, s) => sum + s.visitors, 0)\n\nexport interface TrafficSourcesBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n}\n\nexport function TrafficSourcesBlock({ className, framed = true }: TrafficSourcesBlockProps) {\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-sm\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle render={<h2 />}>Traffic sources</CardTitle>\n\t\t\t\t<CardDescription>Where this month's visitors came from.</CardDescription>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<PieChart\n\t\t\t\t\tlabel=\"Visitors by source, this month\"\n\t\t\t\t\tdata={sources}\n\t\t\t\t\tindex=\"source\"\n\t\t\t\t\tvalueKey=\"visitors\"\n\t\t\t\t\tdonut\n\t\t\t\t\tcenterLabel={{ value: total
|
|
21202
|
+
"code": "const sources = [\n\t{ source: \"Search\", visitors: 4280 },\n\t{ source: \"Direct\", visitors: 2910 },\n\t{ source: \"Social\", visitors: 1840 },\n\t{ source: \"Email\", visitors: 960 },\n]\n\nconst total = sources.reduce((sum, s) => sum + s.visitors, 0)\n\nexport interface TrafficSourcesBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n}\n\nexport function TrafficSourcesBlock({ className, framed = true }: TrafficSourcesBlockProps) {\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-sm\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle render={<h2 />}>Traffic sources</CardTitle>\n\t\t\t\t<CardDescription>Where this month's visitors came from.</CardDescription>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<PieChart\n\t\t\t\t\tlabel=\"Visitors by source, this month\"\n\t\t\t\t\tdata={sources}\n\t\t\t\t\tindex=\"source\"\n\t\t\t\t\tvalueKey=\"visitors\"\n\t\t\t\t\tdonut\n\t\t\t\t\tcenterLabel={{ value: total, label: \"Visitors\" }}\n\t\t\t\t/>\n\t\t\t</CardContent>\n\t\t\t<CardFooter className=\"text-body-sm text-muted-foreground\">\n\t\t\t\tSearch brings {Math.round((sources[0].visitors / total) * 100)}% of visitors.\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
21178
21203
|
},
|
|
21179
21204
|
{
|
|
21180
21205
|
"name": "TrafficTrendBlock",
|
|
@@ -21197,7 +21222,7 @@
|
|
|
21197
21222
|
"The range control is a labelled ToggleGroup; the chart is one tab stop with arrow-key navigation."
|
|
21198
21223
|
],
|
|
21199
21224
|
"imports": "import { useState } from \"react\"\nimport { AreaChart, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, Icon, Toggle, ToggleGroup } from \"@sableui/react\"",
|
|
21200
|
-
"code": "/** 90 days of visitors — replace with your own rows ({ date, desktop, mobile }). */\nconst days = Array.from({ length: 90 }, (_, i) => {\n\tconst wave = Math.sin(i / 6) * 60 + Math.sin(i / 2.3) * 25\n\treturn {\n\t\tdate: new Date(2026, 6, 1 + i).toISOString().slice(0, 10),\n\t\tdesktop: Math.round(240 + wave + (i % 7) * 9),\n\t\tmobile: Math.round(160 + wave * 0.7 + ((i * 3) % 11) * 6),\n\t}\n})\n\nconst series = [\n\t{ key: \"desktop\", label: \"Desktop\" },\n\t{ key: \"mobile\", label: \"Mobile\" },\n]\n\nconst ranges = [\n\t{ id: \"90d\", label: \"3 months\", days: 90 },\n\t{ id: \"30d\", label: \"30 days\", days: 30 },\n\t{ id: \"7d\", label: \"7 days\", days: 7 },\n]\n\nconst shortDate = (iso: string) => new Date(`${iso}T00:00:00`).toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" })\n\nexport interface TrafficTrendBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n}\n\nexport function TrafficTrendBlock({ className, framed = true }: TrafficTrendBlockProps) {\n\tconst [range, setRange] = useState(\"90d\")\n\tconst shown = days.slice(-(ranges.find((r) => r.id === range)?.days ?? 90))\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"w-full max-w-3xl\", className)}>\n\t\t\t<CardHeader>\n\t\t\t\t<CardTitle render={<h2 />}>Visitors</CardTitle>\n\t\t\t\t<CardDescription>Desktop and mobile, by day.</CardDescription>\n\t\t\t\t<CardAction>\n\t\t\t\t\t<ToggleGroup value={[range]} onValueChange={(v: unknown[]) => v[0] && setRange(String(v[0]))} aria-label=\"Time range\">\n\t\t\t\t\t\t{ranges.map((r) => (\n\t\t\t\t\t\t\t<Toggle key={r.id} value={r.id}>\n\t\t\t\t\t\t\t\t{r.label}\n\t\t\t\t\t\t\t</Toggle>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</ToggleGroup>\n\t\t\t\t</CardAction>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<AreaChart\n\t\t\t\t\tlabel={`Visitors per day, last ${ranges.find((r) => r.id === range)?.label}`}\n\t\t\t\t\tdata={shown}\n\t\t\t\t\tindex=\"date\"\n\t\t\t\t\tseries={series}\n\t\t\t\t\tindexFormatter={shortDate}\n\t\t\t\t\ttooltip={{ labelFormatter: (d) => new Date(`${d}T00:00:00`).toLocaleDateString(\"en-US\", { weekday: \"short\", month: \"short\", day: \"numeric\" }) }}\n\t\t\t\t\tstacked\n\t\t\t\t\tgradient\n\t\t\t\t\tclassName=\"h-64\"\n\t\t\t\t/>\n\t\t\t</CardContent>\n\t\t\t<CardFooter className=\"gap-1.5 text-body-sm\">\n\t\t\t\t<Icon name=\"arrow-up\" className=\"text-success\" />\n\t\t\t\t<span>\n\t\t\t\t\tUp 5.2% on the previous period <span className=\"text-muted-foreground\">· desktop leads every week</span>\n\t\t\t\t</span>\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
21225
|
+
"code": "/** 90 days of visitors — replace with your own rows ({ date, desktop, mobile }). */\nconst days = Array.from({ length: 90 }, (_, i) => {\n\tconst wave = Math.sin(i / 6) * 60 + Math.sin(i / 2.3) * 25\n\treturn {\n\t\tdate: new Date(2026, 6, 1 + i).toISOString().slice(0, 10),\n\t\tdesktop: Math.round(240 + wave + (i % 7) * 9),\n\t\tmobile: Math.round(160 + wave * 0.7 + ((i * 3) % 11) * 6),\n\t}\n})\n\nconst series = [\n\t{ key: \"desktop\", label: \"Desktop\" },\n\t{ key: \"mobile\", label: \"Mobile\" },\n]\n\nconst ranges = [\n\t{ id: \"90d\", label: \"3 months\", days: 90 },\n\t{ id: \"30d\", label: \"30 days\", days: 30 },\n\t{ id: \"7d\", label: \"7 days\", days: 7 },\n]\n\nconst shortDate = (iso: string) => new Date(`${iso}T00:00:00`).toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" })\n\nexport interface TrafficTrendBlockProps {\n\tclassName?: string\n\t/** false drops the card's surface and border — for a muted section that already frames it. */\n\tframed?: boolean\n}\n\nexport function TrafficTrendBlock({ className, framed = true }: TrafficTrendBlockProps) {\n\tconst [range, setRange] = useState(\"90d\")\n\tconst shown = days.slice(-(ranges.find((r) => r.id === range)?.days ?? 90))\n\treturn (\n\t\t<Card variant={framed ? \"default\" : \"flat\"} className={cn(\"@container/trend w-full max-w-3xl\", className)}>\n\t\t\t<CardHeader className=\"@max-sm/trend:has-data-[slot=card-action]:grid-cols-1\">\n\t\t\t\t<CardTitle render={<h2 />}>Visitors</CardTitle>\n\t\t\t\t<CardDescription>Desktop and mobile, by day.</CardDescription>\n\t\t\t\t{/* Beside the title on a wide card; under it on a narrow one, where three\n\t\t\t\t ranges would squeeze the title and description into a sliver. */}\n\t\t\t\t<CardAction className=\"@max-sm/trend:col-start-1 @max-sm/trend:row-span-1 @max-sm/trend:row-start-auto @max-sm/trend:mt-2 @max-sm/trend:justify-self-start\">\n\t\t\t\t\t<ToggleGroup value={[range]} onValueChange={(v: unknown[]) => v[0] && setRange(String(v[0]))} aria-label=\"Time range\">\n\t\t\t\t\t\t{ranges.map((r) => (\n\t\t\t\t\t\t\t<Toggle key={r.id} value={r.id}>\n\t\t\t\t\t\t\t\t{r.label}\n\t\t\t\t\t\t\t</Toggle>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</ToggleGroup>\n\t\t\t\t</CardAction>\n\t\t\t</CardHeader>\n\t\t\t<CardContent>\n\t\t\t\t<AreaChart\n\t\t\t\t\tlabel={`Visitors per day, last ${ranges.find((r) => r.id === range)?.label}`}\n\t\t\t\t\tdata={shown}\n\t\t\t\t\tindex=\"date\"\n\t\t\t\t\tseries={series}\n\t\t\t\t\tindexFormatter={shortDate}\n\t\t\t\t\ttooltip={{ labelFormatter: (d) => new Date(`${d}T00:00:00`).toLocaleDateString(\"en-US\", { weekday: \"short\", month: \"short\", day: \"numeric\" }) }}\n\t\t\t\t\tstacked\n\t\t\t\t\tgradient\n\t\t\t\t\tclassName=\"h-64\"\n\t\t\t\t/>\n\t\t\t</CardContent>\n\t\t\t<CardFooter className=\"gap-1.5 text-body-sm\">\n\t\t\t\t<Icon name=\"arrow-up\" className=\"text-success\" />\n\t\t\t\t<span>\n\t\t\t\t\tUp 5.2% on the previous period <span className=\"text-muted-foreground\">· desktop leads every week</span>\n\t\t\t\t</span>\n\t\t\t</CardFooter>\n\t\t</Card>\n\t)\n}"
|
|
21201
21226
|
},
|
|
21202
21227
|
{
|
|
21203
21228
|
"name": "UploadListBlock",
|
|
@@ -55,7 +55,7 @@ try {
|
|
|
55
55
|
const target = match[1].includes(".dark") ? groups.dark : groups.light
|
|
56
56
|
for (const declaration of match[2].matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)) target[declaration[1]] = declaration[2].trim()
|
|
57
57
|
}
|
|
58
|
-
const { PRESET_TOKEN_NAMES } = await import("../dist/presets/index.js")
|
|
58
|
+
const { PAGE_WIDTH_TOKENS, PRESET_TOKEN_NAMES } = await import("../dist/presets/index.js")
|
|
59
59
|
function tokens(mode) {
|
|
60
60
|
const values = { ...groups.light, ...(mode === "dark" ? groups.dark : {}) }
|
|
61
61
|
function resolveValue(value, chain = []) {
|
|
@@ -65,7 +65,9 @@ try {
|
|
|
65
65
|
return resolveValue(values[name], [...chain, name])
|
|
66
66
|
})
|
|
67
67
|
}
|
|
68
|
-
|
|
68
|
+
// Page widths are opt-in: copying the defaults would override any widths
|
|
69
|
+
// an app already sets in its own CSS, since preset CSS loads after it.
|
|
70
|
+
return Object.fromEntries(PRESET_TOKEN_NAMES.filter(name => values[name] && !PAGE_WIDTH_TOKENS.includes(name)).map(name => [name, resolveValue(values[name])]))
|
|
69
71
|
}
|
|
70
72
|
const preset = parsePreset({ schemaVersion: 1, id: "my-preset", label: "My preset", revision: 1, sableVersion: manifest.version, defaultMode: "dark", fonts: { sans: groups.light["font-sans"], mono: groups.light["font-mono"] }, direction: "Content-first composition with a clear primary action. Replace this guidance with the direction for your product.", tokens: { shared: {}, light: tokens("light"), dark: tokens("dark") } })
|
|
71
73
|
emit("preset.template", preset, JSON.stringify(preset, null, 2))
|
|
@@ -95,10 +97,19 @@ try {
|
|
|
95
97
|
}
|
|
96
98
|
const accepted = labels.accepted.map(f => `\n accepted: ${pair(f)} — ${f.reason}`).join("")
|
|
97
99
|
const unmeasured = labels.unmeasured.length ? `, ${labels.unmeasured.length} unmeasured (${labels.unmeasured.join(", ")})` : ""
|
|
100
|
+
// A passing check is the start of applying a preset, not the end: say what
|
|
101
|
+
// makes the app use it, so a check does not read as "done".
|
|
102
|
+
const fonts = [...new Set(Object.values(preset.fonts).filter(Boolean).map(stack => stack.split(",")[0].trim().replace(/^["']|["']$/g, "")))]
|
|
103
|
+
const next = [
|
|
104
|
+
`sable preset --css ${file} > <stylesheet>, imported after Sable's`,
|
|
105
|
+
`load fonts: ${fonts.join(", ")}`,
|
|
106
|
+
`SableThemeProvider defaultThemeId="${preset.id}" defaultPreference="${preset.defaultMode}" with the preset in customThemes`,
|
|
107
|
+
"build the app, run sable check, review both modes (sable docs using-presets)",
|
|
108
|
+
]
|
|
98
109
|
emit(
|
|
99
110
|
"preset.check",
|
|
100
|
-
{ valid: true, id: preset.id, revision: preset.revision, authoredWith: preset.sableVersion, installed, warnings, labelContrast: { measured: labels.measured, accepted: labels.accepted, unmeasured: labels.unmeasured } },
|
|
101
|
-
`Valid preset ${preset.id}, revision ${preset.revision}. Labels on filled roles: ${labels.measured} pairs measured${unmeasured}.${accepted}${warningText}\nReview rendered contrast and brief fit separately
|
|
111
|
+
{ valid: true, id: preset.id, revision: preset.revision, authoredWith: preset.sableVersion, installed, warnings, labelContrast: { measured: labels.measured, accepted: labels.accepted, unmeasured: labels.unmeasured }, fonts, next },
|
|
112
|
+
`Valid preset ${preset.id}, revision ${preset.revision}. Labels on filled roles: ${labels.measured} pairs measured${unmeasured}.${accepted}${warningText}\nReview rendered contrast and brief fit separately.\nTo apply it to an app:\n${next.map(step => ` - ${step}`).join("\n")}`,
|
|
102
113
|
)
|
|
103
114
|
} else {
|
|
104
115
|
const css = presetCss(preset)
|
package/skills/sable/SKILL.md
CHANGED
|
@@ -157,12 +157,21 @@ The installed catalog and guides are authoritative for API and token names.
|
|
|
157
157
|
## Develop or use a preset
|
|
158
158
|
|
|
159
159
|
Save a short brief in the project's existing design location: audience, primary
|
|
160
|
-
task, content, constraints, selected identity, and meaningful states
|
|
161
|
-
|
|
160
|
+
task, content, constraints, selected identity, and meaningful states, plus each
|
|
161
|
+
route's width (a Region `max`, or fluid) and why, chosen by what the person does
|
|
162
|
+
there (`sable docs building-a-page`). Label invented sample content. A supplied preset is an explicit input: read its exact record and
|
|
162
163
|
revision, validate it through `sable preset --check <path> --json`, and read
|
|
163
164
|
`sable docs using-presets`. Preserve its tokens and guidance unless the user requests
|
|
164
165
|
a change or the new task requires a documented adaptation.
|
|
165
166
|
|
|
167
|
+
A preset pasted or supplied for a project that already has an app (for example
|
|
168
|
+
from Canvas's “Copy for agent”) is a request to restyle that app, so it is
|
|
169
|
+
`polish`: save and check it, then apply it. Generate its CSS, load its fonts,
|
|
170
|
+
make it SableThemeProvider's default theme and mode in place of the old one, run
|
|
171
|
+
the build and the finish checklist, and look at it rendered. Don't stop at a saved
|
|
172
|
+
file plus a list of wiring steps for the user. Stop after saving only when the
|
|
173
|
+
user says so or the project has no app yet.
|
|
174
|
+
|
|
166
175
|
Without a preset, develop an appropriate direction from the brief. Use the bundled
|
|
167
176
|
presets as starting points or create original app-owned tokens. When the project has
|
|
168
177
|
its own visual atlas, retrieve references with `sable atlas --search "<problem>"
|