@robr0/design-system 0.13.0 → 0.14.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 (46) hide show
  1. package/README.md +9 -3
  2. package/components/Banner/Banner.css +110 -0
  3. package/components/Banner/Banner.d.ts +36 -0
  4. package/components/Banner/Banner.js +59 -0
  5. package/components/ContributionGraph/ContributionGraph.css +7 -1
  6. package/components/ContributionGraph/ContributionGraph.d.ts +9 -1
  7. package/components/ContributionGraph/ContributionGraph.js +59 -43
  8. package/components/EmptyState/EmptyState.css +5 -0
  9. package/components/FunnelChart/FunnelChart.css +3 -2
  10. package/components/FunnelChart/FunnelChart.d.ts +12 -4
  11. package/components/FunnelChart/FunnelChart.js +48 -28
  12. package/components/Gauge/Gauge.css +30 -13
  13. package/components/Gauge/Gauge.d.ts +16 -8
  14. package/components/Gauge/Gauge.js +78 -62
  15. package/components/HoverCard/HoverCard.css +97 -0
  16. package/components/HoverCard/HoverCard.d.ts +29 -0
  17. package/components/HoverCard/HoverCard.js +83 -0
  18. package/components/ImageCompare/ImageCompare.css +112 -0
  19. package/components/ImageCompare/ImageCompare.d.ts +40 -0
  20. package/components/ImageCompare/ImageCompare.js +134 -0
  21. package/components/LinkList/LinkList.d.ts +3 -1
  22. package/components/LinkList/LinkList.js +4 -3
  23. package/components/Meter/Meter.css +89 -0
  24. package/components/Meter/Meter.d.ts +36 -0
  25. package/components/Meter/Meter.js +48 -0
  26. package/components/NotificationCenter/NotificationCenter.css +11 -3
  27. package/components/Rating/Rating.css +74 -0
  28. package/components/Rating/Rating.d.ts +41 -0
  29. package/components/Rating/Rating.js +124 -0
  30. package/components/Sparkline/Sparkline.d.ts +5 -3
  31. package/components/Sparkline/Sparkline.js +30 -1
  32. package/components/SplitButton/SplitButton.css +93 -0
  33. package/components/SplitButton/SplitButton.d.ts +43 -0
  34. package/components/SplitButton/SplitButton.js +66 -0
  35. package/components/StreamingText/StreamingText.d.ts +27 -9
  36. package/components/StreamingText/StreamingText.js +17 -29
  37. package/components/StreamingText/useStreamReveal.d.ts +70 -0
  38. package/components/StreamingText/useStreamReveal.js +121 -0
  39. package/components/registry.json +48 -0
  40. package/components/registry.json.d.ts +48 -0
  41. package/components/registry.json.js +1 -1
  42. package/index.d.ts +6 -0
  43. package/index.js +15 -0
  44. package/package.json +5 -1
  45. package/tokens/motion.d.ts +9 -4
  46. package/tokens/motion.js +5 -1
@@ -0,0 +1,93 @@
1
+ /* ============================================
2
+ SPLIT BUTTON COMPONENT
3
+ Primary action + attached menu of alternatives
4
+ ============================================ */
5
+
6
+ .ds-split-button {
7
+ display: inline-flex;
8
+ align-items: stretch;
9
+ /* On the filled variant the hairline of page showing between the
10
+ segments is the split: a gap the width of the system hairline,
11
+ not a drawn border. */
12
+ gap: var(--border-xs);
13
+ }
14
+
15
+ /* ============================================
16
+ SEGMENT GEOMETRY
17
+ Outer silhouette stays the Button pill; the
18
+ meeting edges square off. The main segment's
19
+ inner-edge padding tightens one step - the
20
+ squared edge has no pill cap to clear, so
21
+ the resting 16px reads as a hole before the
22
+ chevron.
23
+ ============================================ */
24
+
25
+ .ds-split-button .ds-split-button__main {
26
+ border-start-end-radius: var(--radius-xxs);
27
+ border-end-end-radius: var(--radius-xxs);
28
+ padding-inline-end: var(--padding-sm-md);
29
+ }
30
+
31
+ .ds-split-button .ds-split-button__main.ds-button--compact {
32
+ padding-inline-end: var(--padding-sm);
33
+ }
34
+
35
+ .ds-split-button .ds-split-button__trigger {
36
+ border-start-start-radius: var(--radius-xxs);
37
+ border-end-start-radius: var(--radius-xxs);
38
+ /* Standalone, CircularButton is a fixed circle; inside the split its
39
+ explicit height would block the wrapper's align-items: stretch, and
40
+ the main segment's height moves (secondary's border adds to it, a
41
+ loading spinner grows it) — so the flat edges overhang at the seam.
42
+ Let the flex stretch size it to the main segment instead. */
43
+ height: auto;
44
+ align-self: stretch;
45
+ }
46
+
47
+ /* Optical centering: the trigger is flat on the left and a full round cap
48
+ on the right, so its visual mass sits left of the box centre (the
49
+ centroid of a square-plus-semicircle is ~5% of the width off-centre).
50
+ A geometrically centred chevron reads as pushed toward the cap; nudge
51
+ it toward the flat edge to match. */
52
+ .ds-split-button .ds-split-button__trigger .ds-circular-button__icon {
53
+ transform: translateX(-2px);
54
+ }
55
+
56
+ .ds-split-button .ds-split-button__trigger.ds-circular-button--compact .ds-circular-button__icon {
57
+ transform: translateX(-1px);
58
+ }
59
+
60
+ /* ============================================
61
+ LOADING
62
+ The trigger is inert while the main segment
63
+ runs its async action, but it is busy, not
64
+ broken: Button and CircularButton both define
65
+ loading as full-colour appearance with a
66
+ progress cursor, and the disabled dim on one
67
+ half would split the pill into two unrelated
68
+ controls. When the main segment is disabled
69
+ as well, the whole control dims together and
70
+ this rule deliberately stands down.
71
+ ============================================ */
72
+
73
+ .ds-split-button:has(.ds-button--loading:not(.ds-button--disabled)) .ds-split-button__trigger.ds-circular-button--disabled {
74
+ opacity: 1;
75
+ cursor: progress;
76
+ }
77
+
78
+ /* ============================================
79
+ SECONDARY
80
+ Both segments are outlined, so the gap would
81
+ put two hairlines side by side at the seam.
82
+ Collapse it: no gap, the trigger drops its
83
+ leading border, and the main segment's own
84
+ border is the one divider line.
85
+ ============================================ */
86
+
87
+ .ds-split-button--secondary {
88
+ gap: 0;
89
+ }
90
+
91
+ .ds-split-button--secondary .ds-split-button__trigger {
92
+ border-inline-start: none;
93
+ }
@@ -0,0 +1,43 @@
1
+ import { default as React } from 'react';
2
+ import { DropdownMenuEntry } from '../DropdownMenu/DropdownMenu';
3
+ export type { DropdownMenuEntry as SplitButtonMenuEntry } from '../DropdownMenu/DropdownMenu';
4
+ /** Props owned by SplitButton itself — everything else falls through to the primary button. */
5
+ type SplitButtonOwnProps = {
6
+ /** Label of the primary action */
7
+ label: string;
8
+ /** Click handler for the primary action */
9
+ onClick?: React.MouseEventHandler<HTMLButtonElement>;
10
+ /** Menu entries for the alternative actions */
11
+ items: DropdownMenuEntry[];
12
+ /** Visual treatment, shared by both segments */
13
+ variant?: 'primary' | 'secondary';
14
+ /** Component size */
15
+ size?: 'default' | 'compact';
16
+ /** Disables both segments */
17
+ disabled?: boolean;
18
+ /** Shows a spinner on the primary segment and blocks interaction while an async action runs */
19
+ loading?: boolean;
20
+ /** Icon for the primary segment — Material Symbol name or custom element */
21
+ iconLeft?: string | React.ReactNode;
22
+ /** Horizontal alignment of the menu panel relative to the control */
23
+ align?: 'start' | 'end';
24
+ /** Accessible name of the menu trigger segment */
25
+ menuLabel?: string;
26
+ /** Additional CSS classes, applied to the wrapper around both segments, not the primary button */
27
+ className?: string;
28
+ };
29
+ export interface SplitButtonProps extends SplitButtonOwnProps, Omit<React.ComponentPropsWithoutRef<'button'>, keyof SplitButtonOwnProps | 'type'> {
30
+ }
31
+ /**
32
+ * A primary action with an attached menu of alternatives — "Save" beside
33
+ * "Save as draft" and "Save as template". One pill silhouette, two segments:
34
+ * the label fires `onClick` directly, the chevron opens a DropdownMenu, so
35
+ * the default stays one click away while the variants stay discoverable.
36
+ *
37
+ * Forwards a ref to the primary segment's button and spreads unrecognised
38
+ * props onto that segment — while `className` composes onto the wrapper
39
+ * around both segments. Menu behaviour (keyboard, outside click, sub-menus)
40
+ * is DropdownMenu's, unchanged. Purely compositional (no 'use client'), like
41
+ * ButtonGroup: the interactivity lives in the pieces it assembles.
42
+ */
43
+ export declare const SplitButton: React.ForwardRefExoticComponent<SplitButtonProps & React.RefAttributes<HTMLButtonElement>>;
@@ -0,0 +1,66 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+ import React from "react";
3
+ import { Button } from "../Button/Button.js";
4
+ import { CircularButton } from "../CircularButton/CircularButton.js";
5
+ import { DropdownMenu } from "../DropdownMenu/DropdownMenu.js";
6
+ import "./SplitButton.css";
7
+ const SplitButton = React.forwardRef(
8
+ ({
9
+ label,
10
+ onClick,
11
+ items,
12
+ variant = "primary",
13
+ size = "default",
14
+ disabled = false,
15
+ loading = false,
16
+ iconLeft,
17
+ align = "end",
18
+ menuLabel = "More actions",
19
+ className = "",
20
+ ...rest
21
+ }, ref) => {
22
+ const baseClass = "ds-split-button";
23
+ const classes = [baseClass, `${baseClass}--${variant}`, className].filter(Boolean).join(" ");
24
+ return /* @__PURE__ */ jsxs("span", { className: classes, children: [
25
+ /* @__PURE__ */ jsx(
26
+ Button,
27
+ {
28
+ ...rest,
29
+ ref,
30
+ className: `${baseClass}__main`,
31
+ label,
32
+ iconLeft,
33
+ variant,
34
+ size,
35
+ disabled,
36
+ loading,
37
+ onClick
38
+ }
39
+ ),
40
+ /* @__PURE__ */ jsx(
41
+ DropdownMenu,
42
+ {
43
+ className: `${baseClass}__menu`,
44
+ align,
45
+ size,
46
+ items,
47
+ trigger: /* @__PURE__ */ jsx(
48
+ CircularButton,
49
+ {
50
+ className: `${baseClass}__trigger`,
51
+ icon: "keyboard_arrow_down",
52
+ ariaLabel: menuLabel,
53
+ variant,
54
+ size,
55
+ disabled: disabled || loading
56
+ }
57
+ )
58
+ }
59
+ )
60
+ ] });
61
+ }
62
+ );
63
+ SplitButton.displayName = "SplitButton";
64
+ export {
65
+ SplitButton
66
+ };
@@ -1,4 +1,6 @@
1
1
  import { default as React } from 'react';
2
+ export type { StreamReveal, StreamRevealOptions, UseStreamRevealOptions, } from './useStreamReveal';
3
+ export { createStreamReveal, useStreamReveal } from './useStreamReveal';
2
4
  /** Props owned by StreamingText itself — everything else falls through to the root span. */
3
5
  type StreamingTextOwnProps = {
4
6
  /**
@@ -13,9 +15,21 @@ type StreamingTextOwnProps = {
13
15
  */
14
16
  streaming?: boolean;
15
17
  /**
16
- * Milliseconds between reveal steps. The reveal adds more characters per
17
- * step the further it falls behind, so a large chunk catches up instead of
18
- * typing for seconds.
18
+ * Slowest the reveal ever runs, in characters per second — the pace a
19
+ * thin trickle of chunks types at. Defaults to MOTION_STREAM_FLOOR_CPS.
20
+ */
21
+ floorCps?: number;
22
+ /**
23
+ * However much text is waiting, it is fully on screen within this long,
24
+ * in milliseconds — the rate rises with the backlog. Defaults to
25
+ * MOTION_STREAM_DRAIN_MS.
26
+ */
27
+ drainMs?: number;
28
+ /**
29
+ * The retired interval between reveal steps; when set, its equivalent
30
+ * rate becomes the reveal's floor.
31
+ * @deprecated The reveal is frame-driven now — pace it with `floorCps`
32
+ * and `drainMs` instead.
19
33
  */
20
34
  charIntervalMs?: number;
21
35
  /** Shows the blinking cursor while streaming or revealing. */
@@ -34,11 +48,15 @@ export interface StreamingTextProps extends StreamingTextOwnProps, Omit<React.Co
34
48
  * StreamingText — the reveal for text that arrives in chunks: an LLM
35
49
  * response typing itself out, with a cursor that blinks while more is
36
50
  * coming. Feed it the accumulated text on every render and it animates
37
- * through what was appended, catching up faster the further behind it
38
- * falls. Under `prefers-reduced-motion` the reveal is skipped and each
39
- * chunk appears whole. Announcement is the container's job — pair it with
40
- * an `aria-live` region when the surrounding UI does not already announce
41
- * the message.
51
+ * through what was appended from a frame loop that spends real elapsed
52
+ * time: a thin trickle types at the floor rate, and however much lands at
53
+ * once is on screen within the drain window. Under
54
+ * `prefers-reduced-motion` the reveal is skipped and each chunk appears
55
+ * whole. Announcement is the container's job — pair it with an
56
+ * `aria-live` region when the surrounding UI does not already announce
57
+ * the message. When the streamed text needs its own renderer (markdown
58
+ * through a parser), pace it with `useStreamReveal` or
59
+ * `createStreamReveal` from this folder instead — the same engine without
60
+ * the span.
42
61
  */
43
62
  export declare const StreamingText: React.ForwardRefExoticComponent<StreamingTextProps & React.RefAttributes<HTMLSpanElement>>;
44
- export {};
@@ -1,48 +1,34 @@
1
1
  "use client";
2
2
  import { jsxs, jsx } from "react/jsx-runtime";
3
- import React, { useState, useRef, useEffect } from "react";
4
- import { MOTION_STREAM_CHAR_INTERVAL_MS } from "../../tokens/motion.js";
3
+ import React, { useRef, useEffect } from "react";
4
+ import { useStreamReveal } from "./useStreamReveal.js";
5
+ import { createStreamReveal } from "./useStreamReveal.js";
5
6
  import "./StreamingText.css";
6
7
  const StreamingText = React.forwardRef(
7
8
  ({
8
9
  text,
9
10
  streaming = false,
10
- charIntervalMs = MOTION_STREAM_CHAR_INTERVAL_MS,
11
+ floorCps,
12
+ drainMs,
13
+ charIntervalMs,
11
14
  cursor = true,
12
15
  onRevealComplete,
13
16
  className = "",
14
17
  ...rest
15
18
  }, ref) => {
16
19
  const baseClass = "ds-streaming-text";
17
- const [revealed, setRevealed] = useState(text.length);
18
- const previousText = useRef(text);
20
+ const { visible, caughtUp } = useStreamReveal(text, {
21
+ // The legacy interval's equivalent rate: one character per step.
22
+ floorCps: charIntervalMs != null ? 1e3 / charIntervalMs : floorCps,
23
+ drainMs
24
+ });
19
25
  const completed = useRef(false);
20
26
  useEffect(() => {
21
- if (!text.startsWith(previousText.current)) {
22
- setRevealed(0);
27
+ if (!caughtUp) {
23
28
  completed.current = false;
24
- }
25
- previousText.current = text;
26
- }, [text]);
27
- const caughtUp = revealed >= text.length;
28
- useEffect(() => {
29
- if (caughtUp) return;
30
- completed.current = false;
31
- if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
32
- setRevealed(text.length);
33
29
  return;
34
30
  }
35
- const interval = setInterval(() => {
36
- setRevealed((current) => {
37
- const pending = text.length - current;
38
- if (pending <= 0) return current;
39
- return current + Math.max(1, Math.round(pending / 20));
40
- });
41
- }, charIntervalMs);
42
- return () => clearInterval(interval);
43
- }, [text, caughtUp, charIntervalMs]);
44
- useEffect(() => {
45
- if (caughtUp && !streaming && text.length > 0 && !completed.current) {
31
+ if (!streaming && text.length > 0 && !completed.current) {
46
32
  completed.current = true;
47
33
  onRevealComplete?.();
48
34
  }
@@ -50,12 +36,14 @@ const StreamingText = React.forwardRef(
50
36
  const showCursor = cursor && (streaming || !caughtUp);
51
37
  const classes = [baseClass, className].filter(Boolean).join(" ");
52
38
  return /* @__PURE__ */ jsxs("span", { ...rest, ref, className: classes, children: [
53
- caughtUp ? text : text.slice(0, revealed),
39
+ visible,
54
40
  showCursor && /* @__PURE__ */ jsx("span", { className: `${baseClass}__cursor`, "aria-hidden": "true" })
55
41
  ] });
56
42
  }
57
43
  );
58
44
  StreamingText.displayName = "StreamingText";
59
45
  export {
60
- StreamingText
46
+ StreamingText,
47
+ createStreamReveal,
48
+ useStreamReveal
61
49
  };
@@ -0,0 +1,70 @@
1
+ export interface StreamRevealOptions {
2
+ /** Receives the visible slice each time the reveal moves it. */
3
+ onUpdate: (visible: string) => void;
4
+ /**
5
+ * Slowest the reveal ever runs, in characters per second. Defaults to
6
+ * MOTION_STREAM_FLOOR_CPS.
7
+ */
8
+ floorCps?: number;
9
+ /**
10
+ * However much text is waiting, it is fully on screen within this long,
11
+ * in milliseconds. Defaults to MOTION_STREAM_DRAIN_MS.
12
+ */
13
+ drainMs?: number;
14
+ /**
15
+ * Whether appended text is paced at all. Pass false (or a function
16
+ * returning false — it is read on every append) to show each chunk
17
+ * whole: the reduced-motion path.
18
+ */
19
+ paced?: boolean | (() => boolean);
20
+ }
21
+ export interface StreamReveal {
22
+ /** Text the source produced since the last call — paced onto the screen. */
23
+ append: (chunk: string) => void;
24
+ /** Everything that has arrived, on screen now — for stops and aborts. */
25
+ flush: () => void;
26
+ /**
27
+ * Replace what has arrived wholesale and show it immediately, unpaced —
28
+ * for text that is not the stream speaking (a notice, an error), or to
29
+ * seed text that should not animate.
30
+ */
31
+ showWhole: (text: string) => void;
32
+ /** Drop everything and reveal `text` as a new message, from zero. */
33
+ restart: (text: string) => void;
34
+ /**
35
+ * Resolves once the reveal has caught up with everything that arrived —
36
+ * immediately when it already has.
37
+ */
38
+ drained: () => Promise<void>;
39
+ /** Stop the frame loop. Nothing more is revealed until the next append. */
40
+ cancel: () => void;
41
+ }
42
+ /**
43
+ * Create a reveal for one streamed message. The caller owns rendering:
44
+ * `onUpdate` fires with the visible slice, at most once per frame.
45
+ */
46
+ export declare function createStreamReveal(options: StreamRevealOptions): StreamReveal;
47
+ export interface UseStreamRevealOptions {
48
+ /**
49
+ * Slowest the reveal ever runs, in characters per second. Defaults to
50
+ * MOTION_STREAM_FLOOR_CPS.
51
+ */
52
+ floorCps?: number;
53
+ /**
54
+ * However much text is waiting, it is fully on screen within this long,
55
+ * in milliseconds. Defaults to MOTION_STREAM_DRAIN_MS.
56
+ */
57
+ drainMs?: number;
58
+ }
59
+ /**
60
+ * The declarative face of the engine, for React surfaces: feed it the
61
+ * accumulated text on every render and read back the paced slice. Text
62
+ * present on mount shows whole; a value that does not extend the previous
63
+ * one is a new message and reveals from zero. Checks
64
+ * `prefers-reduced-motion` itself, since no CSS guard can see a frame
65
+ * loop, and shows each chunk whole under it.
66
+ */
67
+ export declare function useStreamReveal(text: string, { floorCps, drainMs }?: UseStreamRevealOptions): {
68
+ visible: string;
69
+ caughtUp: boolean;
70
+ };
@@ -0,0 +1,121 @@
1
+ "use client";
2
+ import { useState, useRef, useEffect } from "react";
3
+ import { MOTION_STREAM_FLOOR_CPS, MOTION_STREAM_DRAIN_MS } from "../../tokens/motion.js";
4
+ function createStreamReveal(options) {
5
+ let arrived = "";
6
+ let revealed = 0;
7
+ let rafId = null;
8
+ let lastFrame = 0;
9
+ let resolvers = [];
10
+ const isPaced = () => {
11
+ const paced = options.paced;
12
+ return typeof paced === "function" ? paced() : paced !== false;
13
+ };
14
+ const settle = () => {
15
+ const pending = resolvers;
16
+ resolvers = [];
17
+ for (const resolve of pending) resolve();
18
+ };
19
+ const cancel = () => {
20
+ if (rafId != null) cancelAnimationFrame(rafId);
21
+ rafId = null;
22
+ lastFrame = 0;
23
+ };
24
+ const schedule = () => {
25
+ if (rafId == null) rafId = requestAnimationFrame(frame);
26
+ };
27
+ function frame(now) {
28
+ rafId = null;
29
+ const seconds = lastFrame === 0 ? 0 : (now - lastFrame) / 1e3;
30
+ lastFrame = now;
31
+ const backlog = arrived.length - revealed;
32
+ if (backlog > 0 && seconds > 0) {
33
+ const floorCps = options.floorCps ?? MOTION_STREAM_FLOOR_CPS;
34
+ const drainMs = options.drainMs ?? MOTION_STREAM_DRAIN_MS;
35
+ const cps = Math.max(floorCps, backlog / (drainMs / 1e3));
36
+ revealed = Math.min(arrived.length, revealed + Math.max(1, Math.round(cps * seconds)));
37
+ options.onUpdate(arrived.slice(0, revealed));
38
+ }
39
+ if (revealed < arrived.length) schedule();
40
+ else {
41
+ lastFrame = 0;
42
+ settle();
43
+ }
44
+ }
45
+ const flush = () => {
46
+ cancel();
47
+ if (revealed < arrived.length) {
48
+ revealed = arrived.length;
49
+ options.onUpdate(arrived);
50
+ }
51
+ settle();
52
+ };
53
+ return {
54
+ append: (chunk) => {
55
+ if (chunk === "") return;
56
+ arrived += chunk;
57
+ if (isPaced()) schedule();
58
+ else flush();
59
+ },
60
+ flush,
61
+ showWhole: (text) => {
62
+ cancel();
63
+ arrived = text;
64
+ revealed = text.length;
65
+ options.onUpdate(text);
66
+ settle();
67
+ },
68
+ restart: (text) => {
69
+ cancel();
70
+ arrived = text;
71
+ revealed = 0;
72
+ options.onUpdate("");
73
+ if (arrived === "") return;
74
+ if (isPaced()) schedule();
75
+ else flush();
76
+ },
77
+ drained: () => revealed >= arrived.length ? Promise.resolve() : new Promise((resolve) => {
78
+ resolvers.push(resolve);
79
+ schedule();
80
+ }),
81
+ cancel
82
+ };
83
+ }
84
+ function useStreamReveal(text, { floorCps, drainMs } = {}) {
85
+ const [revealedText, setRevealedText] = useState(text);
86
+ const engineRef = useRef(null);
87
+ const floorCpsRef = useRef(floorCps);
88
+ const drainMsRef = useRef(drainMs);
89
+ const previous = useRef(text);
90
+ useEffect(() => () => engineRef.current?.cancel(), []);
91
+ useEffect(() => {
92
+ floorCpsRef.current = floorCps;
93
+ drainMsRef.current = drainMs;
94
+ }, [floorCps, drainMs]);
95
+ useEffect(() => {
96
+ if (engineRef.current === null) {
97
+ engineRef.current = createStreamReveal({
98
+ onUpdate: setRevealedText,
99
+ paced: () => !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
100
+ get floorCps() {
101
+ return floorCpsRef.current;
102
+ },
103
+ get drainMs() {
104
+ return drainMsRef.current;
105
+ }
106
+ });
107
+ engineRef.current.showWhole(previous.current);
108
+ }
109
+ if (text === previous.current) return;
110
+ const engine = engineRef.current;
111
+ if (text.startsWith(previous.current)) engine.append(text.slice(previous.current.length));
112
+ else engine.restart(text);
113
+ previous.current = text;
114
+ }, [text]);
115
+ const visible = text.startsWith(revealedText) ? revealedText : "";
116
+ return { visible, caughtUp: visible.length >= text.length };
117
+ }
118
+ export {
119
+ createStreamReveal,
120
+ useStreamReveal
121
+ };
@@ -163,6 +163,14 @@
163
163
  "category": "data-display",
164
164
  "client": false
165
165
  },
166
+ {
167
+ "name": "Banner",
168
+ "label": "Banner",
169
+ "slug": "banner",
170
+ "description": "Full-width status strip for page-level announcements, with an action slot and optional dismissal.",
171
+ "category": "feedback",
172
+ "client": false
173
+ },
166
174
  {
167
175
  "name": "BarChart",
168
176
  "label": "Bar chart",
@@ -509,6 +517,22 @@
509
517
  "category": "maps",
510
518
  "client": true
511
519
  },
520
+ {
521
+ "name": "HoverCard",
522
+ "label": "Hover card",
523
+ "slug": "hover-card",
524
+ "description": "Rich preview panel that opens from hover or focus, with interactive content and position options.",
525
+ "category": "overlays",
526
+ "client": true
527
+ },
528
+ {
529
+ "name": "ImageCompare",
530
+ "label": "Image compare",
531
+ "slug": "image-compare",
532
+ "description": "Before-and-after image comparison with a draggable divider, keyboard control, and corner labels.",
533
+ "category": "data-display",
534
+ "client": true
535
+ },
512
536
  {
513
537
  "name": "Input",
514
538
  "label": "Input",
@@ -598,6 +622,14 @@
598
622
  "category": "ai",
599
623
  "client": false
600
624
  },
625
+ {
626
+ "name": "Meter",
627
+ "label": "Meter",
628
+ "slug": "meter",
629
+ "description": "Level indicator for a known quantity, with a status-coloured fill and an optional value readout.",
630
+ "category": "feedback",
631
+ "client": false
632
+ },
601
633
  {
602
634
  "name": "ModelPicker",
603
635
  "label": "Model picker",
@@ -737,6 +769,14 @@
737
769
  "category": "forms",
738
770
  "client": true
739
771
  },
772
+ {
773
+ "name": "Rating",
774
+ "label": "Rating",
775
+ "slug": "rating",
776
+ "description": "Star-scale rating control with keyboard selection, a read-only mode, and a configurable icon.",
777
+ "category": "forms",
778
+ "client": true
779
+ },
740
780
  {
741
781
  "name": "Reasoning",
742
782
  "label": "Reasoning",
@@ -826,6 +866,14 @@
826
866
  "category": "feedback",
827
867
  "client": false
828
868
  },
869
+ {
870
+ "name": "SplitButton",
871
+ "label": "Split button",
872
+ "slug": "split-button",
873
+ "description": "Primary action with an attached menu of alternatives, composing Button and DropdownMenu in one pill.",
874
+ "category": "actions",
875
+ "client": false
876
+ },
829
877
  {
830
878
  "name": "SplitPane",
831
879
  "label": "Split pane",