@sableui/react 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/AGENTS.md +10 -3
  2. package/CHANGELOG.md +132 -0
  3. package/README.md +11 -4
  4. package/bin/sable.mjs +160 -18
  5. package/dist/activity-DT8VK9fx.d.ts +25 -0
  6. package/dist/adapters/github.d.ts +41 -0
  7. package/dist/adapters/github.js +24 -0
  8. package/dist/blocks/approval-block.js +30 -48
  9. package/dist/blocks/approval-block.meta.js +7 -8
  10. package/dist/blocks/index.d.ts +1 -1
  11. package/dist/components/activity-heatmap/activity-heatmap.js +420 -0
  12. package/dist/components/activity-heatmap/activity-heatmap.meta.js +142 -0
  13. package/dist/components/activity-heatmap/index.js +9 -0
  14. package/dist/components/code-block/code-block.js +5 -3
  15. package/dist/components/code-block/code-block.meta.js +6 -0
  16. package/dist/components/dialog/dialog.js +25 -5
  17. package/dist/components/dialog/dialog.meta.js +26 -5
  18. package/dist/components/media-image/media-image.js +3 -1
  19. package/dist/components/media-image/media-image.meta.js +15 -1
  20. package/dist/components/media-tile/media-tile.js +13 -1
  21. package/dist/components/media-tile/media-tile.meta.js +10 -0
  22. package/dist/components/page-header/page-header.js +9 -4
  23. package/dist/components/page-header/page-header.meta.js +26 -1
  24. package/dist/components/pill/pill.meta.js +15 -6
  25. package/dist/components/pill/pill.variants.js +1 -0
  26. package/dist/components/search-input/search-input.js +30 -5
  27. package/dist/components/search-input/search-input.meta.js +29 -4
  28. package/dist/components/section-header/section-header.js +27 -17
  29. package/dist/components/section-header/section-header.meta.js +27 -5
  30. package/dist/components/section-rail/index.js +7 -0
  31. package/dist/components/section-rail/section-rail.js +153 -0
  32. package/dist/components/section-rail/section-rail.meta.js +189 -0
  33. package/dist/components/sheet/sheet.js +15 -3
  34. package/dist/components/sheet/sheet.meta.js +2 -2
  35. package/dist/file-hashes.json +53 -39
  36. package/dist/icon-sets/reicon.js +23 -1
  37. package/dist/index.d.ts +364 -27
  38. package/dist/index.js +11 -1
  39. package/dist/layout/AspectBox.js +2 -5
  40. package/dist/layout/Bleed.js +10 -6
  41. package/dist/layout/Center.js +11 -3
  42. package/dist/layout/Columns.js +6 -3
  43. package/dist/layout/Grid.js +14 -6
  44. package/dist/layout/GridOverlay.js +7 -7
  45. package/dist/layout/Region.js +18 -3
  46. package/dist/layout/Row.js +27 -20
  47. package/dist/layout/Spacer.js +13 -2
  48. package/dist/layout/SplitPane.js +1 -1
  49. package/dist/layout/Stack.js +25 -18
  50. package/dist/layout/index.d.ts +57 -6
  51. package/dist/lib/chart/activity.js +32 -0
  52. package/dist/lib/chart/calendar.js +67 -0
  53. package/dist/lib/chart/chart-tooltip.js +2 -2
  54. package/dist/lib/chart/heat.js +55 -0
  55. package/dist/lib/focus-return.js +53 -0
  56. package/dist/lib/taxonomy.js +2 -0
  57. package/dist/lib/use-active-section.js +59 -0
  58. package/dist/styles.css +122 -114
  59. package/llms/blocks/approval-block.md +40 -55
  60. package/llms/components/activity-heatmap.md +133 -0
  61. package/llms/components/code-block.md +1 -0
  62. package/llms/components/dialog.md +34 -3
  63. package/llms/components/media-image.md +12 -0
  64. package/llms/components/media-tile.md +21 -0
  65. package/llms/components/page-header.md +16 -1
  66. package/llms/components/pill.md +24 -5
  67. package/llms/components/search-input.md +37 -4
  68. package/llms/components/section-header.md +28 -4
  69. package/llms/components/section-rail.md +101 -0
  70. package/llms/components/sheet.md +2 -2
  71. package/llms/layout/aspect-box.md +1 -1
  72. package/llms/layout/bleed.md +1 -1
  73. package/llms/layout/center.md +1 -1
  74. package/llms/layout/columns.md +1 -1
  75. package/llms/layout/grid.md +1 -1
  76. package/llms/layout/region.md +1 -1
  77. package/llms/layout/row.md +9 -1
  78. package/llms/layout/spacer.md +1 -1
  79. package/llms/layout/split-pane.md +1 -1
  80. package/llms/layout/stack.md +1 -1
  81. package/llms/tasks/auditing-an-app.md +31 -0
  82. package/llms/tasks/getting-started.md +10 -3
  83. package/llms/tasks/using-presets.md +1 -2
  84. package/llms-full.txt +516 -90
  85. package/llms.txt +5 -3
  86. package/package.json +6 -1
  87. package/registry.json +745 -46
  88. package/scripts/adoption.mjs +239 -0
  89. package/scripts/agent-setup.mjs +110 -18
  90. package/scripts/grade.mjs +17 -0
  91. package/scripts/wiring.mjs +2 -1
  92. package/skills/entry/SKILL.md +1 -1
  93. package/skills/sable/SKILL.md +33 -4
  94. package/skills/sable/agents/openai.yaml +1 -1
  95. package/targets/ios.json +18 -0
@@ -0,0 +1,55 @@
1
+ // src/lib/chart/heat.ts
2
+ function quantile(sorted, p) {
3
+ if (!sorted.length) return 0;
4
+ const i = (sorted.length - 1) * p;
5
+ const lo = Math.floor(i);
6
+ const hi = Math.ceil(i);
7
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo);
8
+ }
9
+ function activityLevels(values, { levels = 5, scale = "quantile" } = {}) {
10
+ const steps = Math.max(2, Math.round(levels));
11
+ const top = steps - 1;
12
+ const positive = values.filter((v) => v > 0).sort((a, b) => a - b);
13
+ const stats = { min: positive[0] ?? 0, max: positive.at(-1) ?? 0, levels: steps };
14
+ const clamp = (l) => Math.min(top, Math.max(0, Math.round(l)));
15
+ if (typeof scale === "function") {
16
+ return { thresholds: [], level: (v) => v > 0 ? Math.max(1, clamp(scale(v, stats))) : 0 };
17
+ }
18
+ let thresholds;
19
+ if (Array.isArray(scale)) {
20
+ thresholds = scale.slice(0, top);
21
+ } else if (scale === "linear") {
22
+ thresholds = Array.from({ length: top }, (_, k) => k === 0 ? Number.MIN_VALUE : stats.max * k / top);
23
+ } else {
24
+ thresholds = Array.from({ length: top }, (_, k) => quantile(positive, k / top));
25
+ }
26
+ return {
27
+ thresholds,
28
+ level: (v) => {
29
+ if (!(v > 0)) return 0;
30
+ let l = 1;
31
+ for (let k = 1; k < thresholds.length; k++) if (v >= thresholds[k]) l = k + 1;
32
+ return l;
33
+ }
34
+ };
35
+ }
36
+ var HEAT_EMPTY = "color-mix(in oklch, var(--foreground) 8%, transparent)";
37
+ function heatColor(base, level, levels) {
38
+ if (level <= 0) return HEAT_EMPTY;
39
+ const top = Math.max(1, levels - 1);
40
+ if (level >= top) return base;
41
+ const share = Math.round(35 + 65 * (level - 1) / (top - 1));
42
+ return `color-mix(in oklch, ${base} ${share}%, ${HEAT_EMPTY})`;
43
+ }
44
+ function levelNames(levels) {
45
+ if (levels === 5) return ["None", "Low", "Medium-low", "Medium-high", "High"];
46
+ if (levels === 2) return ["None", "Some"];
47
+ if (levels === 3) return ["None", "Low", "High"];
48
+ return Array.from({ length: levels }, (_, k) => k === 0 ? "None" : `Level ${k} of ${levels - 1}`);
49
+ }
50
+ export {
51
+ HEAT_EMPTY,
52
+ activityLevels,
53
+ heatColor,
54
+ levelNames
55
+ };
@@ -0,0 +1,53 @@
1
+ // src/lib/focus-return.tsx
2
+ import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
3
+ var FocusReturnContext = createContext(null);
4
+ function focusedElement() {
5
+ if (typeof document === "undefined") return null;
6
+ let element = document.activeElement;
7
+ while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
8
+ return element instanceof HTMLElement && element !== document.body ? element : null;
9
+ }
10
+ function useFocusReturn(open, onOpenChange) {
11
+ const [opener, setOpener] = useState(null);
12
+ const popupRef = useRef(null);
13
+ const [wasOpen, setWasOpen] = useState(false);
14
+ if (open !== void 0 && open !== wasOpen) {
15
+ setWasOpen(open);
16
+ if (open) setOpener(focusedElement());
17
+ }
18
+ const handleOpenChange = useCallback(
19
+ (next, ...rest) => {
20
+ if (next && open === void 0) setOpener(focusedElement());
21
+ onOpenChange?.(next, ...rest);
22
+ },
23
+ [open, onOpenChange]
24
+ );
25
+ const value = useMemo(
26
+ () => ({
27
+ popupRef(node) {
28
+ popupRef.current = node;
29
+ },
30
+ // Base UI reads finalFocus when the popup closes, from the latest props,
31
+ // so a new function per opener is picked up.
32
+ finalFocus() {
33
+ const target = opener;
34
+ if (!target?.isConnected) return true;
35
+ const active = focusedElement();
36
+ const popup = popupRef.current;
37
+ if (active && active !== target && popup && !popup.contains(active)) return false;
38
+ return target;
39
+ }
40
+ }),
41
+ [opener]
42
+ );
43
+ return { onOpenChange: handleOpenChange, focusReturn: value };
44
+ }
45
+ var FocusReturnProvider = FocusReturnContext.Provider;
46
+ function useFocusReturnDefault() {
47
+ return useContext(FocusReturnContext);
48
+ }
49
+ export {
50
+ FocusReturnProvider,
51
+ useFocusReturn,
52
+ useFocusReturnDefault
53
+ };
@@ -90,6 +90,7 @@ var COMPONENT_TAXONOMY = {
90
90
  BottomNav: ["Navigation", "page"],
91
91
  NavigationMenu: ["Navigation", "page"],
92
92
  Pagination: ["Navigation", "either"],
93
+ SectionRail: ["Navigation", "page"],
93
94
  ShowMore: ["Navigation", "page"],
94
95
  SideNav: ["Navigation", "page"],
95
96
  Sidebar: ["Navigation", "page"],
@@ -130,6 +131,7 @@ var COMPONENT_TAXONOMY = {
130
131
  RadarChart: ["Charts", "page"],
131
132
  RadialChart: ["Charts", "page"],
132
133
  Sparkline: ["Charts", "either"],
134
+ ActivityHeatmap: ["Charts", "either"],
133
135
  /* ── Data display ─────────────────────────────────────────────────────────
134
136
  Showing records and readings. `Meter` (bar or ring) is here: it shows a
135
137
  value within a known range (a reading), which is not loading or progress. */
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ // src/lib/use-active-section.ts
4
+ import { useEffect, useState } from "react";
5
+ var END_SLACK = 2;
6
+ function useActiveSection(ids, { offset = 0.35, root } = {}) {
7
+ const [active, setActive] = useState(ids[0] ?? null);
8
+ const key = ids.join("\n");
9
+ useEffect(() => {
10
+ const list = key ? key.split("\n") : [];
11
+ if (list.length === 0) {
12
+ setActive(null);
13
+ return;
14
+ }
15
+ const scroller = root?.current ?? null;
16
+ let frame = 0;
17
+ const measure = () => {
18
+ frame = 0;
19
+ const top = scroller ? scroller.getBoundingClientRect().top : 0;
20
+ const height = scroller ? scroller.clientHeight : window.innerHeight;
21
+ const line = top + height * offset;
22
+ const bottom = top + height;
23
+ const scrolled = scroller ? scroller.scrollTop : window.scrollY;
24
+ const range = scroller ? scroller.scrollHeight - scroller.clientHeight : document.documentElement.scrollHeight - window.innerHeight;
25
+ const atEnd = range > END_SLACK && scrolled >= range - END_SLACK;
26
+ let current = list[0];
27
+ for (const id of list) {
28
+ const section = document.getElementById(id);
29
+ if (!section) continue;
30
+ const sectionTop = section.getBoundingClientRect().top;
31
+ if (sectionTop <= line || atEnd && sectionTop < bottom) current = id;
32
+ }
33
+ setActive(current);
34
+ };
35
+ const schedule = () => {
36
+ if (!frame) frame = requestAnimationFrame(measure);
37
+ };
38
+ measure();
39
+ const target = scroller ?? window;
40
+ target.addEventListener("scroll", schedule, { passive: true });
41
+ window.addEventListener("resize", schedule);
42
+ const resize = new ResizeObserver(schedule);
43
+ resize.observe(scroller ?? document.documentElement);
44
+ for (const id of list) {
45
+ const section = document.getElementById(id);
46
+ if (section) resize.observe(section);
47
+ }
48
+ return () => {
49
+ cancelAnimationFrame(frame);
50
+ target.removeEventListener("scroll", schedule);
51
+ window.removeEventListener("resize", schedule);
52
+ resize.disconnect();
53
+ };
54
+ }, [key, offset, root]);
55
+ return active;
56
+ }
57
+ export {
58
+ useActiveSection
59
+ };
package/dist/styles.css CHANGED
@@ -1665,13 +1665,19 @@
1665
1665
  /* ==========================================================================
1666
1666
  Layout — responsive grid spans + split panes.
1667
1667
 
1668
- The fluid layout primitives (Region, Grid, Stack…) compute their styles inline
1669
- so any dynamic value works without Tailwind scanning a class. Breakpoint-aware
1670
- layout can't be inline (a style attribute has no media queries), so `Col` and
1671
- `SplitPane` set per-breakpoint CSS custom properties inline and these rules
1672
- resolve them mobile-first. Breakpoints match Tailwind's defaults
1673
- (sm 40 / md 48 / lg 64 / xl 80 / 2xl 96 rem) so they line up with the `lg:*`
1674
- visibility utilities the app-shell components already use.
1668
+ The fluid layout primitives (Region, Grid, Stack…) put their layout in
1669
+ classes (`flex`, `grid`, `items-*`, `px-(--page-inset)`) and pass dynamic
1670
+ values as inline CSS custom properties that those classes read, so any value
1671
+ works without Tailwind scanning a class. Never an inline `display`, `gap` or
1672
+ padding: inline style beats every utility, and `<Row className="hidden
1673
+ md:flex">` silently did nothing. Breakpoint-aware layout can't be inline (a
1674
+ style attribute has no media queries), so `Col` and `SplitPane` set
1675
+ per-breakpoint CSS custom properties inline and these rules resolve them
1676
+ mobile-first. The rules sit in `@layer components` so a utility in the
1677
+ caller's `className` (`hidden`, `lg:grid-cols-3`, `gap-8`) still wins.
1678
+ Breakpoints match Tailwind's defaults (sm 40 / md 48 / lg 64 / xl 80 /
1679
+ 2xl 96 rem) so they line up with the `lg:*` visibility utilities the
1680
+ app-shell components already use.
1675
1681
  ========================================================================== */
1676
1682
 
1677
1683
  /* Each element owns its breakpoint values. Custom properties inherit, so a
@@ -1679,18 +1685,20 @@
1679
1685
  Grid inside `Columns count={{ lg: 12 }}` drew 12 tracks at lg. Resetting
1680
1686
  them here makes an unset one invalid, so the fallback chain below runs on the
1681
1687
  element itself; the values it was given are inline and win over this. */
1682
- [data-sable-col] {
1683
- --col-span-base: initial; --col-span-sm: initial; --col-span-md: initial;
1684
- --col-span-lg: initial; --col-span-xl: initial; --col-span-2xl: initial;
1685
- --col-start-base: initial; --col-start-sm: initial; --col-start-md: initial;
1686
- --col-start-lg: initial; --col-start-xl: initial; --col-start-2xl: initial;
1687
- }
1688
- [data-sable-grid-cols] {
1689
- --cols-base: initial; --cols-sm: initial; --cols-md: initial;
1690
- --cols-lg: initial; --cols-xl: initial; --cols-2xl: initial;
1691
- }
1692
- [data-sable-split] {
1693
- --split-gap: initial;
1688
+ @layer components {
1689
+ [data-sable-col] {
1690
+ --col-span-base: initial; --col-span-sm: initial; --col-span-md: initial;
1691
+ --col-span-lg: initial; --col-span-xl: initial; --col-span-2xl: initial;
1692
+ --col-start-base: initial; --col-start-sm: initial; --col-start-md: initial;
1693
+ --col-start-lg: initial; --col-start-xl: initial; --col-start-2xl: initial;
1694
+ }
1695
+ [data-sable-grid-cols] {
1696
+ --cols-base: initial; --cols-sm: initial; --cols-md: initial;
1697
+ --cols-lg: initial; --cols-xl: initial; --cols-2xl: initial;
1698
+ }
1699
+ [data-sable-split] {
1700
+ --split-gap: initial;
1701
+ }
1694
1702
  }
1695
1703
 
1696
1704
  /* Toasts keep clear of an open Sheet's actions. A right sheet's pinned footer
@@ -1761,126 +1769,126 @@
1761
1769
  }
1762
1770
  }
1763
1771
 
1764
- /* `Col span={{ base, md, lg }}` / `start={…}` — each breakpoint resolves to the
1765
- most specific value defined at or below it (mobile-first cascade). */
1766
- [data-sable-col] {
1767
- grid-column: var(--col-start, auto) / span var(--col-span, 1);
1768
- --col-span: var(--col-span-base, 1);
1769
- --col-start: var(--col-start-base, auto);
1770
- }
1771
- @media (width >= 40rem) {
1772
+ @layer components {
1773
+ /* `Col span={{ base, md, lg }}` / `start={…}` — each breakpoint resolves to the
1774
+ most specific value defined at or below it (mobile-first cascade). */
1772
1775
  [data-sable-col] {
1773
- --col-span: var(--col-span-sm, var(--col-span-base, 1));
1774
- --col-start: var(--col-start-sm, var(--col-start-base, auto));
1776
+ grid-column: var(--col-start, auto) / span var(--col-span, 1);
1777
+ --col-span: var(--col-span-base, 1);
1778
+ --col-start: var(--col-start-base, auto);
1779
+ }
1780
+ @media (width >= 40rem) {
1781
+ [data-sable-col] {
1782
+ --col-span: var(--col-span-sm, var(--col-span-base, 1));
1783
+ --col-start: var(--col-start-sm, var(--col-start-base, auto));
1784
+ }
1775
1785
  }
1776
- }
1777
- @media (width >= 48rem) {
1778
- [data-sable-col] {
1779
- --col-span: var(--col-span-md, var(--col-span-sm, var(--col-span-base, 1)));
1780
- --col-start: var(--col-start-md, var(--col-start-sm, var(--col-start-base, auto)));
1786
+ @media (width >= 48rem) {
1787
+ [data-sable-col] {
1788
+ --col-span: var(--col-span-md, var(--col-span-sm, var(--col-span-base, 1)));
1789
+ --col-start: var(--col-start-md, var(--col-start-sm, var(--col-start-base, auto)));
1790
+ }
1781
1791
  }
1782
- }
1783
- @media (width >= 64rem) {
1784
- [data-sable-col] {
1785
- --col-span: var(--col-span-lg, var(--col-span-md, var(--col-span-sm, var(--col-span-base, 1))));
1786
- --col-start: var(--col-start-lg, var(--col-start-md, var(--col-start-sm, var(--col-start-base, auto))));
1792
+ @media (width >= 64rem) {
1793
+ [data-sable-col] {
1794
+ --col-span: var(--col-span-lg, var(--col-span-md, var(--col-span-sm, var(--col-span-base, 1))));
1795
+ --col-start: var(--col-start-lg, var(--col-start-md, var(--col-start-sm, var(--col-start-base, auto))));
1796
+ }
1787
1797
  }
1788
- }
1789
- @media (width >= 80rem) {
1790
- [data-sable-col] {
1791
- --col-span: var(--col-span-xl, var(--col-span-lg, var(--col-span-md, var(--col-span-sm, var(--col-span-base, 1)))));
1792
- --col-start: var(--col-start-xl, var(--col-start-lg, var(--col-start-md, var(--col-start-sm, var(--col-start-base, auto)))));
1798
+ @media (width >= 80rem) {
1799
+ [data-sable-col] {
1800
+ --col-span: var(--col-span-xl, var(--col-span-lg, var(--col-span-md, var(--col-span-sm, var(--col-span-base, 1)))));
1801
+ --col-start: var(--col-start-xl, var(--col-start-lg, var(--col-start-md, var(--col-start-sm, var(--col-start-base, auto)))));
1802
+ }
1793
1803
  }
1794
- }
1795
- @media (width >= 96rem) {
1796
- [data-sable-col] {
1797
- --col-span: var(--col-span-2xl, var(--col-span-xl, var(--col-span-lg, var(--col-span-md, var(--col-span-sm, var(--col-span-base, 1))))));
1798
- --col-start: var(--col-start-2xl, var(--col-start-xl, var(--col-start-lg, var(--col-start-md, var(--col-start-sm, var(--col-start-base, auto))))));
1804
+ @media (width >= 96rem) {
1805
+ [data-sable-col] {
1806
+ --col-span: var(--col-span-2xl, var(--col-span-xl, var(--col-span-lg, var(--col-span-md, var(--col-span-sm, var(--col-span-base, 1))))));
1807
+ --col-start: var(--col-start-2xl, var(--col-start-xl, var(--col-start-lg, var(--col-start-md, var(--col-start-sm, var(--col-start-base, auto))))));
1808
+ }
1799
1809
  }
1800
- }
1801
1810
 
1802
- /* `Columns count` / `Grid cols` as a breakpoint map — set per-breakpoint --cols-*
1803
- props inline; resolved mobile-first into the track's column count, so a grid
1804
- thins to fewer columns on small screens (e.g. 2 → 6 → 12). */
1805
- [data-sable-grid-cols] {
1806
- display: grid;
1807
- grid-template-columns: repeat(var(--cols, var(--grid-columns)), minmax(0, 1fr));
1808
- --cols: var(--cols-base, var(--grid-columns));
1809
- }
1810
- @media (width >= 40rem) {
1811
+ /* `Columns count` / `Grid cols` as a breakpoint map — set per-breakpoint --cols-*
1812
+ props inline; resolved mobile-first into the track's column count, so a grid
1813
+ thins to fewer columns on small screens (e.g. 2 → 6 → 12). */
1811
1814
  [data-sable-grid-cols] {
1812
- --cols: var(--cols-sm, var(--cols-base, var(--grid-columns)));
1815
+ grid-template-columns: repeat(var(--cols, var(--grid-columns)), minmax(0, 1fr));
1816
+ --cols: var(--cols-base, var(--grid-columns));
1813
1817
  }
1814
- }
1815
- @media (width >= 48rem) {
1816
- [data-sable-grid-cols] {
1817
- --cols: var(--cols-md, var(--cols-sm, var(--cols-base, var(--grid-columns))));
1818
+ @media (width >= 40rem) {
1819
+ [data-sable-grid-cols] {
1820
+ --cols: var(--cols-sm, var(--cols-base, var(--grid-columns)));
1821
+ }
1818
1822
  }
1819
- }
1820
- @media (width >= 64rem) {
1821
- [data-sable-grid-cols] {
1822
- --cols: var(
1823
- --cols-lg,
1824
- var(--cols-md, var(--cols-sm, var(--cols-base, var(--grid-columns))))
1825
- );
1823
+ @media (width >= 48rem) {
1824
+ [data-sable-grid-cols] {
1825
+ --cols: var(--cols-md, var(--cols-sm, var(--cols-base, var(--grid-columns))));
1826
+ }
1826
1827
  }
1827
- }
1828
- @media (width >= 80rem) {
1829
- [data-sable-grid-cols] {
1830
- --cols: var(
1831
- --cols-xl,
1832
- var(
1828
+ @media (width >= 64rem) {
1829
+ [data-sable-grid-cols] {
1830
+ --cols: var(
1833
1831
  --cols-lg,
1834
1832
  var(--cols-md, var(--cols-sm, var(--cols-base, var(--grid-columns))))
1835
- )
1836
- );
1833
+ );
1834
+ }
1837
1835
  }
1838
- }
1839
- @media (width >= 96rem) {
1840
- [data-sable-grid-cols] {
1841
- --cols: var(
1842
- --cols-2xl,
1843
- var(
1836
+ @media (width >= 80rem) {
1837
+ [data-sable-grid-cols] {
1838
+ --cols: var(
1844
1839
  --cols-xl,
1845
1840
  var(
1846
1841
  --cols-lg,
1847
1842
  var(--cols-md, var(--cols-sm, var(--cols-base, var(--grid-columns))))
1848
1843
  )
1849
- )
1850
- );
1844
+ );
1845
+ }
1846
+ }
1847
+ @media (width >= 96rem) {
1848
+ [data-sable-grid-cols] {
1849
+ --cols: var(
1850
+ --cols-2xl,
1851
+ var(
1852
+ --cols-xl,
1853
+ var(
1854
+ --cols-lg,
1855
+ var(--cols-md, var(--cols-sm, var(--cols-base, var(--grid-columns))))
1856
+ )
1857
+ )
1858
+ );
1859
+ }
1851
1860
  }
1852
- }
1853
1861
 
1854
- /* `SplitPane collapseBelow="lg"` — a single stacked column below the breakpoint;
1855
- a two-column track (primary:secondary fr ratio) at or above it. */
1856
- [data-sable-split] {
1857
- display: grid;
1858
- gap: var(--split-gap, var(--grid-gutter));
1859
- grid-template-columns: 1fr;
1860
- }
1861
- @media (width >= 40rem) {
1862
- [data-sable-split][data-collapse="sm"] {
1863
- grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1862
+ /* `SplitPane collapseBelow="lg"` — a single stacked column below the breakpoint;
1863
+ a two-column track (primary:secondary fr ratio) at or above it. */
1864
+ [data-sable-split] {
1865
+ gap: var(--split-gap, var(--grid-gutter));
1866
+ grid-template-columns: 1fr;
1864
1867
  }
1865
- }
1866
- @media (width >= 48rem) {
1867
- [data-sable-split][data-collapse="md"] {
1868
- grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1868
+ @media (width >= 40rem) {
1869
+ [data-sable-split][data-collapse="sm"] {
1870
+ grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1871
+ }
1869
1872
  }
1870
- }
1871
- @media (width >= 64rem) {
1872
- [data-sable-split][data-collapse="lg"] {
1873
- grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1873
+ @media (width >= 48rem) {
1874
+ [data-sable-split][data-collapse="md"] {
1875
+ grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1876
+ }
1874
1877
  }
1875
- }
1876
- @media (width >= 80rem) {
1877
- [data-sable-split][data-collapse="xl"] {
1878
- grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1878
+ @media (width >= 64rem) {
1879
+ [data-sable-split][data-collapse="lg"] {
1880
+ grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1881
+ }
1879
1882
  }
1880
- }
1881
- @media (width >= 96rem) {
1882
- [data-sable-split][data-collapse="2xl"] {
1883
- grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1883
+ @media (width >= 80rem) {
1884
+ [data-sable-split][data-collapse="xl"] {
1885
+ grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1886
+ }
1887
+ }
1888
+ @media (width >= 96rem) {
1889
+ [data-sable-split][data-collapse="2xl"] {
1890
+ grid-template-columns: var(--split-primary, 1fr) var(--split-secondary, 1fr);
1891
+ }
1884
1892
  }
1885
1893
  }
1886
1894
 
@@ -1,48 +1,45 @@
1
1
  # ApprovalBlock
2
2
 
3
- An agent asking permission for one tool call: the question, what will happen and what can't be undone, the call's details, an "allow for the rest of this chat" Checkbox, and Deny / Allow.
3
+ An agent asking permission for one tool call: the question, what is at stake, the call itself as a ToolCall with its exact input, and Deny / Always allow / Allow once.
4
4
 
5
- **Intent:** Reach for this in a chat or agent run when a tool call waits on the person — sending, paying, deleting, publishing. The call itself, before and after the decision, is a ToolCall with status="awaiting", then "complete" or "denied". A destructive action outside an agent is DestructiveActionModal.
5
+ **Intent:** Reach for this in a chat or agent run when a tool call waits on the person — running a command, sending, paying, deleting, publishing. It wraps the call's own ToolCall, which shows status="awaiting" until the person answers and then "complete" or "denied". A destructive action outside an agent is DestructiveActionModal.
6
6
 
7
7
  **Block:** AI & agents · card · designed for 18rem–28rem wide (fills its container up to the max)
8
8
 
9
9
  ## Notes
10
10
 
11
- - The title is the question in plain words ("Send the invoice to Ada?"), not the tool's name; the tool's name is one row of the details, for the person who wants to know exactly what runs.
12
- - The description says what can't be undone. That sentence is what the person is deciding on.
13
- - Deny comes first and Allow is the primary on the end, the same order as every Sable confirmation. Neither is focused on arrival: an approval that a stray Enter can grant is not an approval.
14
- - After a decision the buttons give way to one InlineStatus line saying what happened, so the thread keeps a record of the answer.
11
+ - The title is the question in plain words ("Run this command?"); the description says what is at stake. That sentence is what the person is deciding on.
12
+ - The call is shown as the agent made it — tool name and raw input, open on arrival — because the exact arguments are what a careful person checks. Do not paraphrase them into a form.
13
+ - Three answers, as agent products settle on: Deny on the start edge, then Always allow and Allow once, the primary on the end. Neither is focused on arrival: an approval that a stray Enter can grant is not an approval.
14
+ - After a decision the buttons give way to one InlineStatus line saying what happened, and the ToolCall shows the outcome, so the thread keeps a record of the answer.
15
15
 
16
16
  ## Accessibility
17
17
 
18
18
  - Focus moves to the outcome line when the buttons leave, instead of dropping to the page; the line is a role=status region, so the decision is announced.
19
- - The details are a dl, so each value is read with its label.
20
- - The remember Checkbox has its own Label, so the tool name inside it is part of its accessible name.
19
+ - The ToolCall announces its own status change, and its input is a labelled code view.
21
20
 
22
21
  ## Source
23
22
 
24
23
  ```tsx
25
- import { useState, useEffect, useRef, useId } from "react"
26
- import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, cn, Code, InlineStatus, Label } from "@sableui/react"
24
+ import { useState, useEffect, useRef } from "react"
25
+ import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, InlineStatus, ToolCall } from "@sableui/react"
27
26
 
28
27
  const request = {
29
- tool: "send_email",
30
- title: "Send the invoice to Ada?",
31
- description: "The agent wants to email invoice INV-1042. An email can't be unsent.",
32
- details: [
33
- { label: "To", value: "ada@example.com" },
34
- { label: "Subject", value: "INV-1042 for October" },
35
- { label: "Attached", value: "INV-1042.pdf" },
36
- ],
37
- allowed: "Allowed. Sending the email.",
38
- denied: "Denied. The agent won't send it.",
28
+ title: "Run this command?",
29
+ description: "The agent wants to migrate the production database in acme/api. It changes live data.",
30
+ tool: "run_command",
31
+ action: "Run a command",
32
+ // A command reads as the command; a structured call would pass its arguments object.
33
+ input: "pnpm db:migrate --env production",
34
+ allowed: "Allowed. Running the migration.",
35
+ denied: "Denied. The agent won't run it.",
39
36
  }
40
37
 
41
38
  export interface ApprovalBlockProps {
42
39
  className?: string
43
40
  /** false drops the card's surface and border — for a chat column that already frames it. */
44
41
  framed?: boolean
45
- /** The person allowed the call. `remember` is the "for the rest of this chat" choice. */
42
+ /** The person allowed the call. `remember` is true for "Always allow". */
46
43
  onApprove?: (options: { remember: boolean }) => void
47
44
  /** The person declined the call. */
48
45
  onDeny?: () => void
@@ -50,8 +47,6 @@ export interface ApprovalBlockProps {
50
47
 
51
48
  export function ApprovalBlock({ className, framed = true, onApprove, onDeny }: ApprovalBlockProps) {
52
49
  const [decision, setDecision] = useState<"allowed" | "denied" | null>(null)
53
- const [remember, setRemember] = useState(false)
54
- const id = useId()
55
50
  const outcome = useRef<HTMLDivElement>(null)
56
51
  // The buttons leave with the decision, so focus moves to the line that
57
52
  // replaces them instead of falling back to the top of the page.
@@ -59,37 +54,30 @@ export function ApprovalBlock({ className, framed = true, onApprove, onDeny }: A
59
54
  if (decision) outcome.current?.focus()
60
55
  }, [decision])
61
56
 
57
+ const approve = (remember: boolean) => {
58
+ setDecision("allowed")
59
+ onApprove?.({ remember })
60
+ }
61
+
62
62
  return (
63
63
  <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-md", className)}>
64
64
  <CardHeader>
65
65
  <CardTitle>{request.title}</CardTitle>
66
66
  <CardDescription>{request.description}</CardDescription>
67
67
  </CardHeader>
68
- <CardContent className="flex flex-col gap-4">
69
- <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-body-sm">
70
- {request.details.map((d) => (
71
- <div key={d.label} className="contents">
72
- <dt className="text-muted-foreground">{d.label}</dt>
73
- <dd className="truncate text-foreground">{d.value}</dd>
74
- </div>
75
- ))}
76
- <div className="contents">
77
- <dt className="text-muted-foreground">Tool</dt>
78
- <dd className="min-w-0">
79
- <Code>{request.tool}</Code>
80
- </dd>
81
- </div>
82
- </dl>
83
- {decision ? null : (
84
- <div className="flex items-center gap-2">
85
- <Checkbox id={`${id}-remember`} checked={remember} onCheckedChange={(v: boolean) => setRemember(v)} />
86
- <Label htmlFor={`${id}-remember`} className="text-body-sm">
87
- Allow <Code>{request.tool}</Code> for the rest of this chat
88
- </Label>
89
- </div>
90
- )}
68
+ <CardContent>
69
+ {/* The call itself, exactly as the agent made it. It answers with the
70
+ person: held, then done or denied. */}
71
+ <ToolCall
72
+ name={request.tool}
73
+ title={request.action}
74
+ status={decision === "allowed" ? "complete" : decision === "denied" ? "denied" : "awaiting"}
75
+ statusLabel={decision === "allowed" ? "Allowed" : undefined}
76
+ input={request.input}
77
+ defaultOpen
78
+ />
91
79
  </CardContent>
92
- <CardFooter actions className="justify-end">
80
+ <CardFooter actions>
93
81
  {decision ? (
94
82
  <InlineStatus
95
83
  ref={outcome}
@@ -103,7 +91,8 @@ export function ApprovalBlock({ className, framed = true, onApprove, onDeny }: A
103
91
  ) : (
104
92
  <>
105
93
  <Button
106
- variant="outline"
94
+ variant="ghost"
95
+ className="me-auto"
107
96
  onClick={() => {
108
97
  setDecision("denied")
109
98
  onDeny?.()
@@ -111,14 +100,10 @@ export function ApprovalBlock({ className, framed = true, onApprove, onDeny }: A
111
100
  >
112
101
  Deny
113
102
  </Button>
114
- <Button
115
- onClick={() => {
116
- setDecision("allowed")
117
- onApprove?.({ remember })
118
- }}
119
- >
120
- Allow
103
+ <Button variant="outline" onClick={() => approve(true)}>
104
+ Always allow
121
105
  </Button>
106
+ <Button onClick={() => approve(false)}>Allow once</Button>
122
107
  </>
123
108
  )}
124
109
  </CardFooter>