@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 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.toLocaleString("en-US"), label: "Visitors" }
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 runs its rows to the card's edges, and an interactive
48
- // row's hover fill would otherwise poke square-ish corners out past
49
- // the card's rounded ones — clip to the card's own curve instead.
50
- "has-data-[slot=card-list]:overflow-clip",
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: centerLabel.label ? "-0.1em" : "0.32em", className: "fill-foreground text-heading-2 tabular-nums", children: centerLabel.value }),
181
- centerLabel.label ? /* @__PURE__ */ jsx("tspan", { x: g.cx, dy: "1.6em", className: "fill-muted-foreground text-caption", children: centerLabel.label }) : null
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: "752", label: "Visitors" }}\n/>'
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" : centerLabel.label ? "-0.1em" : "0.32em", className: "fill-foreground text-heading-2 tabular-nums", children: centerLabel.value }),
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: "A value and caption in the middle."
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
- "grid shrink-0 place-items-center",
23
+ "flex shrink-0 items-center justify-center",
24
24
  {
25
- variants: { size: { md: "size-5 text-xl", lg: "size-6 text-xl", rail: "size-6 text-2xl" } },
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
- value: string;
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. Hover-lifts; mark the selected row with `data-selected`.
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.0";
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.0";
163
+ var SABLE_UI_VERSION = "0.7.1";
164
164
  export {
165
165
  ART_TOKENS,
166
166
  CHART_SERIES_COUNT,
@@ -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 — full-bleed safety ceiling
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 fully fluid, edge-to-edge (the default).
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`. */
@@ -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 content role, such as prose, form, feed, content, wide, or full.",
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
  }),
@@ -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 };
@@ -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.toLocaleString("en-US"), label: "Visitors" }}
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
- <CardAction>
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: "752", label: "Visitors" }}
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. |
@@ -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 content role, such as prose, form, feed, content, wide, or full.
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 (full-bleed ceiling). Omit for fully fluid. |
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
- ### Fluid dashboard region
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 capped per region by role:
34
- `prose` / `form` / `feed` / `content` / `wide` / `full`. A form region is narrow
35
- because a 700px text input is harder to use, not more generous.
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 capped per region by role:
631
- `prose` / `form` / `feed` / `content` / `wide` / `full`. A form region is narrow
632
- because a 700px text input is harder to use, not more generous.
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.toLocaleString("en-US"), label: "Visitors" }}
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
- <CardAction>
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 content role, such as prose, form, feed, content, wide, or full.
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 (full-bleed ceiling). Omit for fully fluid. |
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
- ### Fluid dashboard region
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: "752", label: "Visitors" }}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sableui/react",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Sable: themeable React components, layout patterns, and tools for coding agents.",
5
5
  "type": "module",
6
6
  "sideEffects": [
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.0",
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 content role, such as prose, form, feed, content, wide, or full."
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 (full-bleed ceiling). Omit for fully fluid."
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": "Fluid dashboard region",
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: \"752\", label: \"Visitors\" }}\n/>",
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.toLocaleString(\"en-US\"), 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}"
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
- return Object.fromEntries(PRESET_TOKEN_NAMES.filter(name => values[name]).map(name => [name, resolveValue(values[name])]))
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)
@@ -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. Label invented
161
- sample content. A supplied preset is an explicit input: read its exact record and
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>"