@tanishqkancharla/maui 0.0.63 → 0.0.65

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/README.md CHANGED
@@ -41,6 +41,8 @@ function App() {
41
41
 
42
42
  Put the exported `themeFoucScript` in a classic inline `<script>` in `<head>` so `data-theme` is correct on first paint.
43
43
 
44
+ `CodeBlock` uses Shiki's JavaScript regex engine with Maui themes and lazy-loaded languages. No highlighting configuration is required.
45
+
44
46
  ## Develop
45
47
 
46
48
  ```bash
@@ -63,7 +65,21 @@ Vite serves the gallery at [http://localhost:5173/](http://localhost:5173/).
63
65
 
64
66
  The default package export is compiled `dist/` JavaScript and declarations. TypeScript source remains available through the `source` export condition and `maui/src`. Patterns, demo apps, and the gallery `Panel` preview frame are reference implementations, not package components.
65
67
 
66
- Pushes to `main` publish a new patch of `@tanishqkancharla/maui` to npm (tag `vX.Y.Z`, provenance). Add `[skip release]` to the merge commit message to skip. Manual `git tag vX.Y.Z && git push origin vX.Y.Z` still publishes when the tag matches `package.json`. The gallery deploys to [maui.tanishqkancharla.dev](https://maui.tanishqkancharla.dev) through Vercel on `main`.
68
+ Push directly to `main`; PRs are not required. Each push prepares the next version of `@tanishqkancharla/maui`, creates a `vX.Y.Z` tag, and stages the verified tarball on npm with provenance. **Staging does not make the version public.** Add `[skip release]` to the push's head commit message to skip staging. Manual `vX.Y.Z` tag pushes also stage a release when the tag matches `package.json`.
69
+
70
+ To publish, review the version in npm's **Staged Packages** tab and approve it with 2FA. With npm 11.15.0 or newer, you can also use `npm stage list @tanishqkancharla/maui`, `npm stage view <stage-id>`, `npm stage download <stage-id>`, and `npm stage approve <stage-id>`. Approve versions in increasing order so an older approval does not move `latest` backward. Git tags reserve version numbers even while approval is pending or after a staged version is rejected; the next branch release uses a new version. A failed staging upload can be retried by manually running the workflow against its existing `vX.Y.Z` tag.
71
+
72
+ ### Required npm security settings
73
+
74
+ In the package's npm settings:
75
+
76
+ 1. Configure the GitHub trusted publisher for `tanishqkancharla/maui`, workflow filename `publish.yml`, with **stage-only** permission. Do not allow direct `npm publish` or dist-tag management. Audit all trusted publisher entries, not just this workflow's entry. If npm requires replacing an existing connection, copy its repository/workflow details, replace it with a stage-only connection, and complete the first staged upload within two days before the new connection expires.
77
+ 2. Set **Publishing access** to **Require two-factor authentication and disallow bypass 2fa tokens (recommended)**. Revoke unused automation tokens.
78
+ 3. Keep 2FA enabled on maintainer accounts. CI must never approve its own staged release.
79
+
80
+ These npm-side restrictions are essential: changing the workflow command alone does not prevent a modified workflow from attempting a direct publish. See npm's [staged publishing](https://docs.npmjs.com/staged-publishing) and [trusted publisher](https://docs.npmjs.com/trusted-publishers) documentation.
81
+
82
+ `npm run release` also stages rather than publishing directly and requires npm 11.15.0 or newer. The gallery still deploys independently to [maui.tanishqkancharla.dev](https://maui.tanishqkancharla.dev) through Vercel on `main`; npm approval does not gate that deployment.
67
83
 
68
84
  Agent conventions for consuming Maui: [`skills/maui`](./skills/maui/SKILL.md).
69
85
 
@@ -1,6 +1,8 @@
1
+ import { type IconSize } from "../tokens/sizing";
1
2
  export type ActivityIndicatorVariant = "primary" | "accent" | "muted";
2
3
  export type ActivityIndicatorProps = {
3
- size?: string;
4
+ /** Icon t-shirt size, or a custom CSS length. Defaults to 1em. */
5
+ size?: IconSize | (string & {});
4
6
  variant?: ActivityIndicatorVariant;
5
7
  className?: string;
6
8
  "aria-label"?: string;
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useRef } from "react";
3
3
  import { style, useStyles } from "purse-styles";
4
4
  import { colors } from "../tokens/colors";
5
+ import { iconSizeValues } from "../tokens/sizing";
5
6
  import { cls } from "../utils/cls";
6
7
  // Geometry is in a 24×24 viewBox. Spokes end this far from the center.
7
8
  const VIEWBOX_CENTER = 12;
@@ -28,6 +29,9 @@ const spokes = Array.from({ length: SPOKE_COUNT }, (_, index) => {
28
29
  export function ActivityIndicator({ size = "1em", variant = "accent", className, "aria-label": ariaLabel = "Loading", }) {
29
30
  const svgRef = useRef(null);
30
31
  const rootClassName = useStyles(rootClass, variantStyles[variant]);
32
+ const dimension = Object.hasOwn(iconSizeValues, size)
33
+ ? iconSizeValues[size]
34
+ : size;
31
35
  useEffect(() => {
32
36
  const animation = svgRef.current?.animate([{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }], {
33
37
  duration: ROTATION_MS,
@@ -36,7 +40,7 @@ export function ActivityIndicator({ size = "1em", variant = "accent", className,
36
40
  });
37
41
  return () => animation?.cancel();
38
42
  }, []);
39
- return (_jsx("span", { role: "status", "aria-label": ariaLabel, className: cls(rootClassName, className), style: { width: size, height: size }, children: _jsx("svg", { ref: svgRef, "aria-hidden": "true", focusable: "false", viewBox: "0 0 24 24", width: "100%", height: "100%", children: spokes.map(({ angle, opacity }) => (_jsx("line", { x1: VIEWBOX_CENTER, y1: SPOKE_TOP, x2: VIEWBOX_CENTER, y2: SPOKE_TOP + SPOKE_LENGTH, stroke: "currentColor", strokeWidth: SPOKE_WIDTH, strokeLinecap: "round", opacity: opacity, transform: `rotate(${angle} ${VIEWBOX_CENTER} ${VIEWBOX_CENTER})` }, angle))) }) }));
43
+ return (_jsx("span", { role: "status", "aria-label": ariaLabel, className: cls(rootClassName, className), style: { width: dimension, height: dimension }, children: _jsx("svg", { ref: svgRef, "aria-hidden": "true", focusable: "false", viewBox: "0 0 24 24", width: "100%", height: "100%", children: spokes.map(({ angle, opacity }) => (_jsx("line", { x1: VIEWBOX_CENTER, y1: SPOKE_TOP, x2: VIEWBOX_CENTER, y2: SPOKE_TOP + SPOKE_LENGTH, stroke: "currentColor", strokeWidth: SPOKE_WIDTH, strokeLinecap: "round", opacity: opacity, transform: `rotate(${angle} ${VIEWBOX_CENTER} ${VIEWBOX_CENTER})` }, angle))) }) }));
40
44
  }
41
45
  const variantStyles = {
42
46
  primary: style({
@@ -24,6 +24,8 @@ export type ButtonProps = Omit<RACButtonProps, "children" | "className"> & {
24
24
  size?: ButtonSize;
25
25
  variant?: ButtonVariant;
26
26
  variantColor?: ButtonVariantColor;
27
+ /** Visible text beside the indicator while pending. Defaults to children. */
28
+ pendingLabel?: string;
27
29
  };
28
30
  export declare function useButton(props: Pick<ButtonProps, "onClick" | "onFocus">, forwardedRef?: React.ForwardedRef<HTMLButtonElement>): [
29
31
  ButtonData,
@@ -40,5 +42,7 @@ export declare const Button: React.ForwardRefExoticComponent<Omit<RACButtonProps
40
42
  size?: ButtonSize;
41
43
  variant?: ButtonVariant;
42
44
  variantColor?: ButtonVariantColor;
45
+ /** Visible text beside the indicator while pending. Defaults to children. */
46
+ pendingLabel?: string;
43
47
  } & React.RefAttributes<HTMLButtonElement>>;
44
48
  export {};
@@ -1,8 +1,10 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import React, { useCallback } from "react";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useCallback, useLayoutEffect, useState } from "react";
3
+ import { AnimatePresence, motion, useIsPresent, useReducedMotion, } from "motion/react";
3
4
  import { useId, useObjectRef } from "react-aria";
4
- import { Button as RACButton, } from "react-aria-components";
5
+ import { Button as RACButton, ProgressBar, } from "react-aria-components";
5
6
  import { style, useStyles } from "purse-styles";
7
+ import { ActivityIndicator } from "./ActivityIndicator";
6
8
  import { useFocus } from "../hooks/useFocus";
7
9
  import { useRefCurrent } from "../hooks/useRefCurrent";
8
10
  import { backgroundColor, surfaceMixPercent, surfaceWash, } from "../tokens/background";
@@ -15,6 +17,7 @@ import { spacing } from "../tokens/spacing";
15
17
  import { text } from "../tokens/text";
16
18
  import { memoize } from "../utils/memoize";
17
19
  const buttonShadow = shadowVars.control;
20
+ const pendingTransition = { duration: 0.15, ease: "easeInOut" };
18
21
  const disabledRaised = {
19
22
  cursor: "default",
20
23
  opacity: 1,
@@ -43,21 +46,28 @@ const buttonBaseClass = style(text({ size: "xs", fontWeight: 500, color: "highCo
43
46
  textOverflow: "ellipsis",
44
47
  overflow: "hidden",
45
48
  whiteSpace: "nowrap",
46
- "& > svg": {
49
+ transition: `padding-inline-start ${pendingTransition.duration}s ease-in-out`,
50
+ "@media (prefers-reduced-motion: reduce)": {
51
+ transition: "none",
52
+ },
53
+ "& > svg, & [data-button-content] > svg": {
47
54
  flexShrink: 0,
48
55
  },
49
- "&:has(> svg)": {
56
+ "&:has(> svg), &:has([data-button-content] > svg)": {
50
57
  paddingBlock: spacing.value(3),
51
58
  },
52
- "&:has(> svg:first-child:not(:only-child))": {
59
+ "&:has(> svg:first-child:not(:only-child)), &:has([data-button-content] > svg:first-child:not(:only-child))": {
53
60
  paddingInlineStart: spacing.value(4),
54
61
  },
55
- "&:has(> svg:last-child:not(:only-child))": {
62
+ "&:has(> svg:last-child:not(:only-child)), &:has([data-button-content] > svg:last-child:not(:only-child))": {
56
63
  paddingInlineEnd: spacing.value(4),
57
64
  },
58
- "&:has(> svg:only-child)": {
65
+ "&:has(> svg:only-child), &:has([data-button-content] > svg:only-child)": {
59
66
  paddingInline: spacing.value(3),
60
67
  },
68
+ "&[data-pending]": {
69
+ paddingInlineStart: `calc((${controlSize.height} - ${iconSizeValues.xs}) / 2)`,
70
+ },
61
71
  "&:disabled": disabledRaised,
62
72
  });
63
73
  const buttonTextClass = style({
@@ -65,32 +75,60 @@ const buttonTextClass = style({
65
75
  minWidth: 0,
66
76
  textBox: "trim-both cap alphabetic",
67
77
  });
78
+ const pendingIndicatorClass = style({
79
+ display: "inline-flex",
80
+ flexShrink: 0,
81
+ "& > span": { display: "inline-flex" },
82
+ });
83
+ const contentRootClass = style({
84
+ position: "relative",
85
+ display: "inline-flex",
86
+ alignItems: "center",
87
+ justifyContent: "center",
88
+ gap: "inherit",
89
+ minWidth: 0,
90
+ });
91
+ const contentLayerClass = style({
92
+ display: "inline-flex",
93
+ alignItems: "center",
94
+ gap: "inherit",
95
+ flexShrink: 0,
96
+ "&[inert]": {
97
+ position: "absolute",
98
+ insetBlock: 0,
99
+ insetInlineStart: 0,
100
+ width: "max-content",
101
+ },
102
+ });
68
103
  const smallButtonClass = style(text({ size: "2xs", fontWeight: 500, color: "highContrast" }), {
69
104
  height: controlSize.smHeight,
70
105
  gap: spacing.value(1),
71
106
  paddingInline: spacing.value(4),
72
107
  paddingBlock: spacing.value(2),
73
- "& > svg": {
108
+ "& > svg, & [data-button-content] > svg": {
74
109
  width: iconSizeValues.xs,
75
110
  height: iconSizeValues.xs,
76
111
  },
77
- "&:has(> svg)": {
112
+ "&:has(> svg), &:has([data-button-content] > svg)": {
78
113
  paddingBlock: spacing.value(2),
79
114
  },
80
- "&:has(> svg:first-child:not(:only-child))": {
115
+ "&:has(> svg:first-child:not(:only-child)), &:has([data-button-content] > svg:first-child:not(:only-child))": {
81
116
  paddingInlineStart: spacing.value(3),
82
117
  },
83
- "&:has(> svg:last-child:not(:only-child))": {
118
+ "&:has(> svg:last-child:not(:only-child)), &:has([data-button-content] > svg:last-child:not(:only-child))": {
84
119
  paddingInlineEnd: spacing.value(3),
85
120
  },
86
- "&:has(> svg:only-child)": {
121
+ "&:has(> svg:only-child), &:has([data-button-content] > svg:only-child)": {
87
122
  width: controlSize.smHeight,
88
123
  paddingInline: 0,
89
124
  },
125
+ "&[data-pending]": {
126
+ paddingInlineStart: `calc((${controlSize.smHeight} - ${iconSizeValues["2xs"]}) / 2)`,
127
+ },
90
128
  });
91
129
  const buttonClass = style(buttonBaseClass, {
92
130
  backgroundColor: backgroundColor.element,
93
- "&:hover:not(:disabled)": {
131
+ "&:hover:not(:disabled):not([data-pending])": {
94
132
  backgroundColor: backgroundColor.elementHover,
95
133
  },
96
134
  });
@@ -101,7 +139,7 @@ const quietClass = memoize((color, hoverColor, wash) => style(buttonBaseClass, f
101
139
  color,
102
140
  backgroundColor: "transparent",
103
141
  boxShadow: "none",
104
- "&:hover:not(:disabled)": {
142
+ "&:hover:not(:disabled):not([data-pending])": {
105
143
  color: hoverColor,
106
144
  backgroundColor: surfaceWash(wash, surfaceMixPercent.hover),
107
145
  },
@@ -163,7 +201,7 @@ const primaryClass = memoize((fill, textColor, lightEdge) => {
163
201
  backgroundColor: fill,
164
202
  backgroundImage: gradient,
165
203
  boxShadow: primaryShadow,
166
- "&:hover:not(:disabled)": {
204
+ "&:hover:not(:disabled):not([data-pending])": {
167
205
  backgroundImage: `${primaryHoverSheen}, ${gradient}`,
168
206
  },
169
207
  "&:disabled": disabledRaised,
@@ -209,13 +247,37 @@ export const buttonStyles = {
209
247
  label: buttonTextClass,
210
248
  };
211
249
  export const Button = React.forwardRef(function Button(props, forwardedRef) {
212
- const { children, className: classNameProp, onClick, onFocus, type = "button", size = "default", variant = "default", variantColor, isDisabled, ...buttonProps } = props;
250
+ const { children, className: classNameProp, onClick, onFocus, type = "button", size = "default", variant = "default", variantColor, isDisabled, pendingLabel, ...buttonProps } = props;
213
251
  const [, attributes] = useButton({ onClick, onFocus }, forwardedRef);
214
252
  const className = useStyles(buttonStyles.root({ variant, size, variantColor }));
215
253
  const textClassName = useStyles(buttonStyles.label);
254
+ const indicatorClassName = useStyles(pendingIndicatorClass);
216
255
  const mergedClassName = [className, classNameProp].filter(Boolean).join(" ");
217
- return (_jsx(RACButton, { ...buttonProps, ref: attributes.ref, type: type, isDisabled: isDisabled, className: mergedClassName, onClick: attributes.onClick, onFocus: attributes.onFocus, children: renderButtonChildren(children, textClassName) }));
256
+ return (_jsx(RACButton, { ...buttonProps, ref: attributes.ref, type: type, isDisabled: isDisabled, className: mergedClassName, onClick: attributes.onClick, onFocus: attributes.onFocus, children: ({ isPending }) => (_jsxs(ButtonContents, { isPending: isPending, children: [isPending && (_jsx(ProgressBar, { isIndeterminate: true, "aria-label": "Pending", className: indicatorClassName, children: _jsx("span", { "aria-hidden": "true", children: _jsx(ActivityIndicator, { variant: "primary", size: size === "sm" ? "2xs" : "xs" }) }) })), renderButtonChildren(isPending ? (pendingLabel ?? children) : children, textClassName)] })) }));
218
257
  });
258
+ function ButtonContents({ isPending, children, }) {
259
+ const [width, setWidth] = useState();
260
+ const reduceMotion = useReducedMotion();
261
+ const className = useStyles(contentRootClass);
262
+ const transition = reduceMotion ? { duration: 0 } : pendingTransition;
263
+ return (_jsx(motion.span, { className: className, initial: false, animate: { width: width ?? "auto" }, transition: transition, children: _jsx(AnimatePresence, { initial: false, children: _jsx(ButtonContentLayer, { onResize: setWidth, transition: transition, children: children }, isPending ? "pending" : "ready") }) }));
264
+ }
265
+ function ButtonContentLayer({ children, onResize, transition, }) {
266
+ const ref = React.useRef(null);
267
+ const isPresent = useIsPresent();
268
+ const className = useStyles(contentLayerClass);
269
+ useLayoutEffect(() => {
270
+ const node = ref.current;
271
+ if (!node || !isPresent)
272
+ return;
273
+ const measure = () => onResize(node.getBoundingClientRect().width);
274
+ measure();
275
+ const observer = new ResizeObserver(measure);
276
+ observer.observe(node);
277
+ return () => observer.disconnect();
278
+ }, [isPresent, onResize, ref]);
279
+ return (_jsx(motion.span, { ref: ref, className: className, "data-button-content": isPresent ? "" : undefined, "aria-hidden": !isPresent || undefined, inert: !isPresent || undefined, initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: transition, children: children }));
280
+ }
219
281
  function buttonVariantClass(variant, variantColor) {
220
282
  if (variant === "primary") {
221
283
  return coloredButtonClass("primary", variantColor ?? "accent");
@@ -1,5 +1,5 @@
1
1
  import { createHighlighterCore } from "shiki/core";
2
- import { createOnigurumaEngine } from "shiki/engine/oniguruma";
2
+ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
3
3
  import { mauiShikiThemeDark, mauiShikiThemeLight } from "./mauiShikiTheme";
4
4
  /** Canonical Shiki language ids loaded into the highlighter. */
5
5
  export const supportedCodeLangs = [
@@ -36,7 +36,7 @@ function getHighlighter() {
36
36
  import("@shikijs/langs/json"),
37
37
  import("@shikijs/langs/bash"),
38
38
  ],
39
- engine: createOnigurumaEngine(import("shiki/wasm")),
39
+ engine: createJavaScriptRegexEngine(),
40
40
  });
41
41
  }
42
42
  return highlighterPromise;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanishqkancharla/maui",
3
- "version": "0.0.63",
3
+ "version": "0.0.65",
4
4
  "description": "Maui design system",
5
5
  "type": "module",
6
6
  "main": "./dist/maui.js",
@@ -74,32 +74,32 @@
74
74
  "test": "vitest",
75
75
  "mcp:browser": "node .cursor/libretto-browser-mcp.mjs",
76
76
  "mcp:agentation": "node .cursor/agentation-mcp.mjs",
77
- "release": "npm publish --access public --provenance"
77
+ "release": "npm stage publish --access public --provenance"
78
78
  },
79
79
  "author": "Tanishq Kancharla",
80
80
  "license": "MIT",
81
81
  "devDependencies": {
82
82
  "@base-ui/react": "^1.9.0",
83
- "@modelcontextprotocol/sdk": "^1.32.1",
83
+ "@modelcontextprotocol/sdk": "^1.32.2",
84
84
  "@resvg/resvg-js": "^2.6.2",
85
85
  "@silk-hq/components": "^0.10.1",
86
- "@tiptap/core": "^3.32.0",
87
- "@tiptap/extension-placeholder": "^3.32.0",
88
- "@tiptap/markdown": "^3.32.0",
89
- "@tiptap/pm": "^3.32.0",
90
- "@tiptap/react": "^3.32.0",
91
- "@tiptap/starter-kit": "^3.32.0",
86
+ "@tiptap/core": "^3.32.1",
87
+ "@tiptap/extension-placeholder": "^3.32.1",
88
+ "@tiptap/markdown": "^3.32.1",
89
+ "@tiptap/pm": "^3.32.1",
90
+ "@tiptap/react": "^3.32.1",
91
+ "@tiptap/starter-kit": "^3.32.1",
92
92
  "@types/lodash": "^4.17.27",
93
- "@types/node": "^26.6.5",
93
+ "@types/node": "^26.6.6",
94
94
  "@types/react": "^19.3.0",
95
95
  "@types/react-dom": "^19.3.0",
96
96
  "@vitejs/plugin-react": "^6.1.2",
97
97
  "agentation": "^3.1.2",
98
98
  "agentation-mcp": "^1.3.2",
99
- "dialkit": "^2.0.2",
99
+ "dialkit": "^2.1.0",
100
100
  "esbuild": "^0.28.2",
101
101
  "libretto-browser-tools": "^0.1.2",
102
- "motion": "^14.6.0",
102
+ "motion": "^14.8.0",
103
103
  "process": "^0.11.10",
104
104
  "satori": "^0.63.2",
105
105
  "serve": "^14.2.6",
@@ -119,7 +119,6 @@
119
119
  "@fontsource/commit-mono": "^5.3.0",
120
120
  "@lezer/highlight": "^1.2.5",
121
121
  "@radix-ui/colors": "^3.0.0",
122
- "@streamdown/code": "^2.0.0",
123
122
  "@types/inline-style-prefixer": "^5.0.3",
124
123
  "@uiw/react-codemirror": "^4.25.12",
125
124
  "beautiful-mermaid": "^1.1.3",
@@ -2,15 +2,14 @@
2
2
 
3
3
  Gallery: `/components/activity-indicator`. Import from `"maui"`.
4
4
 
5
- iOS-style spinner: eight spokes with a fading tail that rotate in 45° steps. `size` is a CSS length (default `1em`). `variant`: `"accent"` (default) | `"primary"` (currentColor) | `"muted"`. `aria-label` defaults to `"Loading"`.
5
+ iOS-style spinner: eight spokes with a fading tail that rotate in 45° steps. `size` accepts the icon t-shirt scale (`"2xs"`–`"xl"`, including large-scale sizing) or a CSS length; default `1em`. `variant`: `"accent"` (default) | `"primary"` (currentColor) | `"muted"`. `aria-label` defaults to `"Loading"`.
6
6
 
7
7
  On an accent fill, such as a primary button, pass `variant="primary"` so the spinner takes the button text color.
8
8
 
9
9
  Use it for loading: fetching, saving, syncing, and other indeterminate waits. When waiting on LLM inference, use [Thinking](thinking.md) instead. The `Spinner` icon is a static glyph, not this component.
10
10
 
11
11
  ```tsx
12
- <Button variant="primary">
13
- <ActivityIndicator variant="primary" aria-label="Saving" />
14
- Saving
15
- </Button>
12
+ <ActivityIndicator size="xs" aria-label="Syncing" />
13
+ <ActivityIndicator size="37px" />
14
+ <Button variant="primary" isPending pendingLabel="Saving…">Save</Button>
16
15
  ```
@@ -14,6 +14,7 @@ Styled React Aria `Button` (default `type="button"`). Consumes `ButtonContext`,
14
14
  <Button variant="primary" variantColor="#6366f1">Indigo</Button>
15
15
  <Button size="sm">Compact</Button>
16
16
  <Button isDisabled>Wait</Button>
17
+ <Button isPending={isSaving} pendingLabel="Saving…">Save</Button>
17
18
  <Button aria-label="Search"><Search size="sm" /></Button>
18
19
  <Button>
19
20
  <Plus size="sm" />
@@ -27,6 +28,8 @@ Styled React Aria `Button` (default `type="button"`). Consumes `ButtonContext`,
27
28
  | `variant` | `"default"` (raised element) \| `"quiet"` (no shadow, transparent) \| `"primary"` (solid fill) |
28
29
  | `variantColor` | Palette name or opaque `#hex` / `rgb(...)`. Primary default is `"accent"`. Quiet ignores color unless set; when set, tints the label with no fill |
29
30
  | `isDisabled` | React Aria name. Maps to native `disabled`. **No `disabled` React prop** |
31
+ | `isPending` | Shows a current-color indicator beside visible content: `xs` (14px) by default, `2xs` (12px) for small buttons, with equal leading/top/bottom insets. Crossfades content and resizes over 150ms in both directions; reduced motion switches instantly. Blocks activation immediately and suppresses hover fill, retaining focusability and variant color. Controlled by the caller, not by returned promises |
32
+ | `pendingLabel` | Optional visible string replacing children while pending (e.g. `"Saving…"`). Omit to keep children; provide it to replace an icon with progress text |
30
33
  | `onClick` | Still supported. React Aria also exposes `onPress` |
31
34
  | `children` | Text is wrapped for cap-height trim; SVG icons sit beside text. Icon-only needs `aria-label` |
32
35
 
@@ -8,7 +8,7 @@ Gallery: `/components/code`. Import from `"maui"`.
8
8
  <CodeBlock lang="ts">{`const n = 1`}</CodeBlock>
9
9
  ```
10
10
 
11
- `CodeBlock` highlights with Shiki using the resolved theme. `lang` is required. Unsupported langs / pending highlight render a plain `<pre>`.
11
+ `CodeBlock` highlights with Shiki's JavaScript engine using the resolved theme. `lang` is required. Unsupported langs / pending highlight render a plain `<pre>`.
12
12
 
13
13
  ## Styles
14
14
 
@@ -0,0 +1,30 @@
1
+ import { renderToStaticMarkup } from "react-dom/server"
2
+ import { PurseProvider } from "purse-styles"
3
+ import { afterAll, beforeAll, expect, it, vi } from "vitest"
4
+ import { iconSizeValues } from "../tokens/sizing"
5
+ import { ActivityIndicator } from "./ActivityIndicator"
6
+
7
+ beforeAll(() => {
8
+ vi.stubGlobal("document", { createElement: () => ({}) })
9
+ })
10
+
11
+ afterAll(() => {
12
+ vi.unstubAllGlobals()
13
+ })
14
+
15
+ it("uses icon scale tokens for t-shirt sizes while preserving CSS lengths and the default", () => {
16
+ for (const [size, expected] of [
17
+ ["2xs", iconSizeValues["2xs"]],
18
+ ["xs", iconSizeValues.xs],
19
+ ["37px", "37px"],
20
+ [undefined, "1em"],
21
+ ] as const) {
22
+ const html = renderToStaticMarkup(
23
+ <PurseProvider>
24
+ <ActivityIndicator size={size} />
25
+ </PurseProvider>,
26
+ )
27
+ expect(html).toContain(`width:${expected}`)
28
+ expect(html).toContain(`height:${expected}`)
29
+ }
30
+ })
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef } from "react"
2
2
  import { style, useStyles } from "purse-styles"
3
3
  import { colors } from "../tokens/colors"
4
+ import { iconSizeValues, type IconSize } from "../tokens/sizing"
4
5
  import { cls } from "../utils/cls"
5
6
 
6
7
  // Geometry is in a 24×24 viewBox. Spokes end this far from the center.
@@ -27,7 +28,8 @@ const spokes = Array.from({ length: SPOKE_COUNT }, (_, index) => {
27
28
  export type ActivityIndicatorVariant = "primary" | "accent" | "muted"
28
29
 
29
30
  export type ActivityIndicatorProps = {
30
- size?: string
31
+ /** Icon t-shirt size, or a custom CSS length. Defaults to 1em. */
32
+ size?: IconSize | (string & {})
31
33
  variant?: ActivityIndicatorVariant
32
34
  className?: string
33
35
  "aria-label"?: string
@@ -46,6 +48,9 @@ export function ActivityIndicator({
46
48
  }: ActivityIndicatorProps) {
47
49
  const svgRef = useRef<SVGSVGElement>(null)
48
50
  const rootClassName = useStyles(rootClass, variantStyles[variant])
51
+ const dimension = Object.hasOwn(iconSizeValues, size)
52
+ ? iconSizeValues[size as IconSize]
53
+ : size
49
54
 
50
55
  useEffect(() => {
51
56
  const animation = svgRef.current?.animate(
@@ -64,7 +69,7 @@ export function ActivityIndicator({
64
69
  role="status"
65
70
  aria-label={ariaLabel}
66
71
  className={cls(rootClassName, className)}
67
- style={{ width: size, height: size }}
72
+ style={{ width: dimension, height: dimension }}
68
73
  >
69
74
  <svg
70
75
  ref={svgRef}
@@ -0,0 +1,46 @@
1
+ import { renderToStaticMarkup } from "react-dom/server"
2
+ import { ButtonContext } from "react-aria-components"
3
+ import { PurseProvider } from "purse-styles"
4
+ import { afterAll, beforeAll, expect, it, vi } from "vitest"
5
+ import { Button } from "./Button"
6
+
7
+ // Maui's client-only focus store has no server snapshot.
8
+ vi.mock("../hooks/useFocus", () => ({
9
+ useFocus: () => [false, {}],
10
+ }))
11
+
12
+ beforeAll(() => {
13
+ vi.stubGlobal("document", { createElement: () => ({}) })
14
+ })
15
+
16
+ afterAll(() => {
17
+ vi.unstubAllGlobals()
18
+ })
19
+
20
+ it("shows progress beside a visible label only while pending, including from context", () => {
21
+ const render = (isPending: boolean, pendingLabel?: string) =>
22
+ renderToStaticMarkup(
23
+ <PurseProvider>
24
+ <ButtonContext.Provider value={{ isPending }}>
25
+ <Button pendingLabel={pendingLabel}>Save changes</Button>
26
+ </ButtonContext.Provider>
27
+ </PurseProvider>,
28
+ )
29
+
30
+ const pending = render(true, "Saving changes…")
31
+ expect(pending).toContain("Saving changes…")
32
+ expect(pending).not.toContain("Save changes")
33
+ expect(pending).toContain('role="progressbar"')
34
+ expect(pending).toContain('aria-label="Pending"')
35
+ expect(pending).toContain('aria-disabled="true"')
36
+ expect(pending).not.toMatch(/\sdisabled=/)
37
+
38
+ const fallback = render(true)
39
+ expect(fallback).toContain("Save changes")
40
+ expect(fallback).toContain('role="progressbar"')
41
+
42
+ const ready = render(false, "Saving changes…")
43
+ expect(ready).toContain("Save changes")
44
+ expect(ready).not.toContain("Saving changes…")
45
+ expect(ready).not.toContain('role="progressbar"')
46
+ })
@@ -1,10 +1,19 @@
1
- import React, { useCallback } from "react"
1
+ import React, { useCallback, useLayoutEffect, useState } from "react"
2
+ import {
3
+ AnimatePresence,
4
+ motion,
5
+ useIsPresent,
6
+ useReducedMotion,
7
+ type Transition,
8
+ } from "motion/react"
2
9
  import { useId, useObjectRef } from "react-aria"
3
10
  import {
4
11
  Button as RACButton,
12
+ ProgressBar,
5
13
  type ButtonProps as RACButtonProps,
6
14
  } from "react-aria-components"
7
15
  import { style, useStyles } from "purse-styles"
16
+ import { ActivityIndicator } from "./ActivityIndicator"
8
17
  import { useFocus } from "../hooks/useFocus"
9
18
  import { useRefCurrent } from "../hooks/useRefCurrent"
10
19
  import {
@@ -26,6 +35,8 @@ export type ButtonSize = "default" | "sm"
26
35
 
27
36
  const buttonShadow = shadowVars.control
28
37
 
38
+ const pendingTransition = { duration: 0.15, ease: "easeInOut" } as const
39
+
29
40
  const disabledRaised = {
30
41
  cursor: "default",
31
42
  opacity: 1,
@@ -61,21 +72,30 @@ const buttonBaseClass = style(
61
72
  textOverflow: "ellipsis",
62
73
  overflow: "hidden",
63
74
  whiteSpace: "nowrap",
64
- "& > svg": {
75
+ transition: `padding-inline-start ${pendingTransition.duration}s ease-in-out`,
76
+ "@media (prefers-reduced-motion: reduce)": {
77
+ transition: "none",
78
+ },
79
+ "& > svg, & [data-button-content] > svg": {
65
80
  flexShrink: 0,
66
81
  },
67
- "&:has(> svg)": {
82
+ "&:has(> svg), &:has([data-button-content] > svg)": {
68
83
  paddingBlock: spacing.value(3),
69
84
  },
70
- "&:has(> svg:first-child:not(:only-child))": {
71
- paddingInlineStart: spacing.value(4),
72
- },
73
- "&:has(> svg:last-child:not(:only-child))": {
74
- paddingInlineEnd: spacing.value(4),
75
- },
76
- "&:has(> svg:only-child)": {
85
+ "&:has(> svg:first-child:not(:only-child)), &:has([data-button-content] > svg:first-child:not(:only-child))":
86
+ {
87
+ paddingInlineStart: spacing.value(4),
88
+ },
89
+ "&:has(> svg:last-child:not(:only-child)), &:has([data-button-content] > svg:last-child:not(:only-child))":
90
+ {
91
+ paddingInlineEnd: spacing.value(4),
92
+ },
93
+ "&:has(> svg:only-child), &:has([data-button-content] > svg:only-child)": {
77
94
  paddingInline: spacing.value(3),
78
95
  },
96
+ "&[data-pending]": {
97
+ paddingInlineStart: `calc((${controlSize.height} - ${iconSizeValues.xs}) / 2)`,
98
+ },
79
99
  "&:disabled": disabledRaised,
80
100
  },
81
101
  )
@@ -86,6 +106,34 @@ const buttonTextClass = style({
86
106
  textBox: "trim-both cap alphabetic",
87
107
  })
88
108
 
109
+ const pendingIndicatorClass = style({
110
+ display: "inline-flex",
111
+ flexShrink: 0,
112
+ "& > span": { display: "inline-flex" },
113
+ })
114
+
115
+ const contentRootClass = style({
116
+ position: "relative",
117
+ display: "inline-flex",
118
+ alignItems: "center",
119
+ justifyContent: "center",
120
+ gap: "inherit",
121
+ minWidth: 0,
122
+ })
123
+
124
+ const contentLayerClass = style({
125
+ display: "inline-flex",
126
+ alignItems: "center",
127
+ gap: "inherit",
128
+ flexShrink: 0,
129
+ "&[inert]": {
130
+ position: "absolute",
131
+ insetBlock: 0,
132
+ insetInlineStart: 0,
133
+ width: "max-content",
134
+ },
135
+ })
136
+
89
137
  const smallButtonClass = style(
90
138
  text({ size: "2xs", fontWeight: 500, color: "highContrast" }),
91
139
  {
@@ -93,29 +141,34 @@ const smallButtonClass = style(
93
141
  gap: spacing.value(1),
94
142
  paddingInline: spacing.value(4),
95
143
  paddingBlock: spacing.value(2),
96
- "& > svg": {
144
+ "& > svg, & [data-button-content] > svg": {
97
145
  width: iconSizeValues.xs,
98
146
  height: iconSizeValues.xs,
99
147
  },
100
- "&:has(> svg)": {
148
+ "&:has(> svg), &:has([data-button-content] > svg)": {
101
149
  paddingBlock: spacing.value(2),
102
150
  },
103
- "&:has(> svg:first-child:not(:only-child))": {
104
- paddingInlineStart: spacing.value(3),
105
- },
106
- "&:has(> svg:last-child:not(:only-child))": {
107
- paddingInlineEnd: spacing.value(3),
108
- },
109
- "&:has(> svg:only-child)": {
151
+ "&:has(> svg:first-child:not(:only-child)), &:has([data-button-content] > svg:first-child:not(:only-child))":
152
+ {
153
+ paddingInlineStart: spacing.value(3),
154
+ },
155
+ "&:has(> svg:last-child:not(:only-child)), &:has([data-button-content] > svg:last-child:not(:only-child))":
156
+ {
157
+ paddingInlineEnd: spacing.value(3),
158
+ },
159
+ "&:has(> svg:only-child), &:has([data-button-content] > svg:only-child)": {
110
160
  width: controlSize.smHeight,
111
161
  paddingInline: 0,
112
162
  },
163
+ "&[data-pending]": {
164
+ paddingInlineStart: `calc((${controlSize.smHeight} - ${iconSizeValues["2xs"]}) / 2)`,
165
+ },
113
166
  },
114
167
  )
115
168
 
116
169
  const buttonClass = style(buttonBaseClass, {
117
170
  backgroundColor: backgroundColor.element,
118
- "&:hover:not(:disabled)": {
171
+ "&:hover:not(:disabled):not([data-pending])": {
119
172
  backgroundColor: backgroundColor.elementHover,
120
173
  },
121
174
  })
@@ -129,7 +182,7 @@ const quietClass = memoize((color: string, hoverColor: string, wash: string) =>
129
182
  color,
130
183
  backgroundColor: "transparent",
131
184
  boxShadow: "none",
132
- "&:hover:not(:disabled)": {
185
+ "&:hover:not(:disabled):not([data-pending])": {
133
186
  color: hoverColor,
134
187
  backgroundColor: surfaceWash(wash, surfaceMixPercent.hover),
135
188
  },
@@ -213,7 +266,7 @@ const primaryClass = memoize(
213
266
  backgroundColor: fill,
214
267
  backgroundImage: gradient,
215
268
  boxShadow: primaryShadow,
216
- "&:hover:not(:disabled)": {
269
+ "&:hover:not(:disabled):not([data-pending])": {
217
270
  backgroundImage: `${primaryHoverSheen}, ${gradient}`,
218
271
  },
219
272
  "&:disabled": disabledRaised,
@@ -262,6 +315,8 @@ export type ButtonProps = Omit<RACButtonProps, "children" | "className"> & {
262
315
  size?: ButtonSize
263
316
  variant?: ButtonVariant
264
317
  variantColor?: ButtonVariantColor
318
+ /** Visible text beside the indicator while pending. Defaults to children. */
319
+ pendingLabel?: string
265
320
  }
266
321
 
267
322
  export function useButton(
@@ -329,6 +384,7 @@ export const Button = React.forwardRef(function Button(
329
384
  variant = "default",
330
385
  variantColor,
331
386
  isDisabled,
387
+ pendingLabel,
332
388
  ...buttonProps
333
389
  } = props
334
390
  const [, attributes] = useButton({ onClick, onFocus }, forwardedRef)
@@ -336,6 +392,7 @@ export const Button = React.forwardRef(function Button(
336
392
  buttonStyles.root({ variant, size, variantColor }),
337
393
  )
338
394
  const textClassName = useStyles(buttonStyles.label)
395
+ const indicatorClassName = useStyles(pendingIndicatorClass)
339
396
  const mergedClassName = [className, classNameProp].filter(Boolean).join(" ")
340
397
 
341
398
  return (
@@ -348,11 +405,104 @@ export const Button = React.forwardRef(function Button(
348
405
  onClick={attributes.onClick}
349
406
  onFocus={attributes.onFocus}
350
407
  >
351
- {renderButtonChildren(children, textClassName)}
408
+ {({ isPending }) => (
409
+ <ButtonContents isPending={isPending}>
410
+ {isPending && (
411
+ <ProgressBar
412
+ isIndeterminate
413
+ aria-label="Pending"
414
+ className={indicatorClassName}
415
+ >
416
+ <span aria-hidden="true">
417
+ <ActivityIndicator
418
+ variant="primary"
419
+ size={size === "sm" ? "2xs" : "xs"}
420
+ />
421
+ </span>
422
+ </ProgressBar>
423
+ )}
424
+ {renderButtonChildren(
425
+ isPending ? (pendingLabel ?? children) : children,
426
+ textClassName,
427
+ )}
428
+ </ButtonContents>
429
+ )}
352
430
  </RACButton>
353
431
  )
354
432
  })
355
433
 
434
+ function ButtonContents({
435
+ isPending,
436
+ children,
437
+ }: {
438
+ isPending: boolean
439
+ children: React.ReactNode
440
+ }) {
441
+ const [width, setWidth] = useState<number>()
442
+ const reduceMotion = useReducedMotion()
443
+ const className = useStyles(contentRootClass)
444
+ const transition = reduceMotion ? { duration: 0 } : pendingTransition
445
+
446
+ return (
447
+ <motion.span
448
+ className={className}
449
+ initial={false}
450
+ animate={{ width: width ?? "auto" }}
451
+ transition={transition}
452
+ >
453
+ <AnimatePresence initial={false}>
454
+ <ButtonContentLayer
455
+ key={isPending ? "pending" : "ready"}
456
+ onResize={setWidth}
457
+ transition={transition}
458
+ >
459
+ {children}
460
+ </ButtonContentLayer>
461
+ </AnimatePresence>
462
+ </motion.span>
463
+ )
464
+ }
465
+
466
+ function ButtonContentLayer({
467
+ children,
468
+ onResize,
469
+ transition,
470
+ }: {
471
+ children: React.ReactNode
472
+ onResize: (width: number) => void
473
+ transition: Transition
474
+ }) {
475
+ const ref = React.useRef<HTMLSpanElement>(null)
476
+ const isPresent = useIsPresent()
477
+ const className = useStyles(contentLayerClass)
478
+
479
+ useLayoutEffect(() => {
480
+ const node = ref.current
481
+ if (!node || !isPresent) return
482
+ const measure = () => onResize(node.getBoundingClientRect().width)
483
+ measure()
484
+ const observer = new ResizeObserver(measure)
485
+ observer.observe(node)
486
+ return () => observer.disconnect()
487
+ }, [isPresent, onResize, ref])
488
+
489
+ return (
490
+ <motion.span
491
+ ref={ref}
492
+ className={className}
493
+ data-button-content={isPresent ? "" : undefined}
494
+ aria-hidden={!isPresent || undefined}
495
+ inert={!isPresent || undefined}
496
+ initial={{ opacity: 0 }}
497
+ animate={{ opacity: 1 }}
498
+ exit={{ opacity: 0 }}
499
+ transition={transition}
500
+ >
501
+ {children}
502
+ </motion.span>
503
+ )
504
+ }
505
+
356
506
  function buttonVariantClass(
357
507
  variant: ButtonVariant,
358
508
  variantColor: ButtonVariantColor | undefined,
@@ -2,7 +2,9 @@ import { style, useStyles } from "purse-styles"
2
2
  import { Panel } from "./Panel"
3
3
  import { ActivityIndicator } from "../components/ActivityIndicator"
4
4
  import { Button } from "../components/Button"
5
+ import { Code } from "../components/Code"
5
6
  import { Prose } from "../components/Prose"
7
+ import { Text } from "../components/Text"
6
8
  import { H2, H3, P } from "../components/Typography"
7
9
  import { flex } from "../tokens/layout"
8
10
  import { text } from "../tokens/text"
@@ -40,22 +42,33 @@ export function ActivityIndicatorPage() {
40
42
  </Panel>
41
43
 
42
44
  <H3>Sizes</H3>
45
+ <P>
46
+ Use the same t-shirt sizes as icons, from <Code>2xs</Code> to{" "}
47
+ <Code>xl</Code>, or a custom CSS length. The default is 1em.
48
+ </P>
43
49
  <Panel className={rowClassName}>
44
- <ActivityIndicator size="1em" aria-label="Loading, small" />
45
- <ActivityIndicator size="20px" aria-label="Loading, medium" />
46
- <ActivityIndicator size="37px" aria-label="Loading, large" />
50
+ {(["2xs", "xs", "sm", "md", "lg", "xl"] as const).map((size) => (
51
+ <div className={columnClassName} key={size}>
52
+ <ActivityIndicator size={size} aria-label={`Loading, ${size}`} />
53
+ <Text size="xs" color="lowContrast">
54
+ {size}
55
+ </Text>
56
+ </div>
57
+ ))}
58
+ <div className={columnClassName}>
59
+ <ActivityIndicator size="37px" aria-label="Loading, custom size" />
60
+ <Text size="xs" color="lowContrast">
61
+ 37px
62
+ </Text>
63
+ </div>
47
64
  </Panel>
48
65
 
49
66
  <H3>In a button</H3>
50
67
  <Panel className={rowClassName}>
51
- <Button variant="primary">
52
- <ActivityIndicator variant="primary" aria-label="Saving" />
68
+ <Button variant="primary" isPending>
53
69
  Saving
54
70
  </Button>
55
- <Button>
56
- <ActivityIndicator aria-label="Refreshing" />
57
- Refreshing
58
- </Button>
71
+ <Button isPending>Refreshing</Button>
59
72
  </Panel>
60
73
  </Prose>
61
74
  )
@@ -1,4 +1,4 @@
1
- import { useState } from "react"
1
+ import { useEffect, useState } from "react"
2
2
  import { Button } from "../components/Button"
3
3
  import { Code } from "../components/Code"
4
4
  import {
@@ -9,6 +9,7 @@ import {
9
9
  } from "../components/Dialog"
10
10
  import { Overlay } from "../components/Overlay"
11
11
  import { Prose } from "../components/Prose"
12
+ import { Text } from "../components/Text"
12
13
  import { H2, H3, P } from "../components/Typography"
13
14
  import { Flex } from "../components/Utils"
14
15
  import { Archive, DotsHorizontal, Plus, Search } from "../icons"
@@ -19,6 +20,17 @@ import { borderColor } from "../tokens/borders"
19
20
  export function ButtonsPage() {
20
21
  const [dialogOpen, setDialogOpen] = useState(false)
21
22
  const [overlayOpen, setOverlayOpen] = useState(false)
23
+ const [isSaving, setIsSaving] = useState(false)
24
+ const [saveCount, setSaveCount] = useState(0)
25
+
26
+ useEffect(() => {
27
+ if (!isSaving) return
28
+ const timeout = setTimeout(() => {
29
+ setIsSaving(false)
30
+ setSaveCount((count) => count + 1)
31
+ }, 2000)
32
+ return () => clearTimeout(timeout)
33
+ }, [isSaving])
22
34
 
23
35
  return (
24
36
  <Prose style={{ marginBottom: "32px" }}>
@@ -80,6 +92,52 @@ export function ButtonsPage() {
80
92
  </Button>
81
93
  </Flex>
82
94
 
95
+ <H3>Pending</H3>
96
+ <P>
97
+ <Code>isPending</Code> shows an activity indicator beside the label,
98
+ keeps the button’s color and focus, and prevents repeat activation. Use{" "}
99
+ <Code>pendingLabel</Code> for visible progress text; otherwise the
100
+ original children stay visible.
101
+ </P>
102
+ <Flex row alignItems="center" gap={4} style={{ flexWrap: "wrap" }}>
103
+ <Button isPending>Submitting</Button>
104
+ <Button variant="primary" isPending pendingLabel="Saving…">
105
+ Save
106
+ </Button>
107
+ <Button variant="quiet" isPending pendingLabel="Syncing…">
108
+ Sync
109
+ </Button>
110
+ <Button
111
+ variant="primary"
112
+ variantColor="blue"
113
+ isPending
114
+ pendingLabel="Uploading…"
115
+ >
116
+ Upload
117
+ </Button>
118
+ <Button size="sm" isPending pendingLabel="Creating…">
119
+ <Plus />
120
+ Create
121
+ </Button>
122
+ </Flex>
123
+ <Flex row alignItems="center" gap={4} style={{ flexWrap: "wrap" }}>
124
+ <Button
125
+ variant="primary"
126
+ isPending={isSaving}
127
+ pendingLabel="Saving changes…"
128
+ onPress={() => setIsSaving(true)}
129
+ >
130
+ Save changes
131
+ </Button>
132
+ <Text size="sm" color="lowContrast" role="status">
133
+ {isSaving
134
+ ? "Saving for two seconds…"
135
+ : saveCount > 0
136
+ ? `Saved ${saveCount} ${saveCount === 1 ? "time" : "times"}. Try again.`
137
+ : "Click to try the pending state."}
138
+ </Text>
139
+ </Flex>
140
+
83
141
  <H3>Primary</H3>
84
142
  <P>
85
143
  <Code>variant="primary"</Code> fills with step 9 of{" "}
@@ -140,9 +198,8 @@ export function ButtonsPage() {
140
198
  <H3>Quiet</H3>
141
199
  <P>
142
200
  <Code>variant="quiet"</Code> has no fill. <Code>variantColor</Code>{" "}
143
- tints the label and icon. Hover mixes <Code>grayAlpha[9]</Code> (or
144
- that color’s alpha 9) into transparent using the shared 3.5% control
145
- wash.
201
+ tints the label and icon. Hover mixes <Code>grayAlpha[9]</Code> (or that
202
+ color’s alpha 9) into transparent using the shared 3.5% control wash.
146
203
  </P>
147
204
  <Flex row alignItems="center" gap={4} style={{ flexWrap: "wrap" }}>
148
205
  <Button variant="quiet">Button</Button>
@@ -6,7 +6,6 @@ import {
6
6
  defaultRehypePlugins,
7
7
  useIsCodeFenceIncomplete,
8
8
  } from "streamdown"
9
- import { code } from "@streamdown/code"
10
9
  import { style, useStyles } from "purse-styles"
11
10
  import { CodeBlock } from "../components/CodeBlock"
12
11
  import { proseContainerStyle } from "../components/Prose"
@@ -143,7 +142,6 @@ export function AssistantMessage({
143
142
  <Streamdown
144
143
  className={streamdownClassName}
145
144
  components={streamdownComponents}
146
- plugins={{ code }}
147
145
  rehypePlugins={rehypePlugins}
148
146
  // `animated` must stay stably enabled; only `isAnimating` toggles.
149
147
  // Flipping `animated`/`mode` with the stream resets stagger state and
@@ -1,5 +1,5 @@
1
1
  import { createHighlighterCore } from "shiki/core"
2
- import { createOnigurumaEngine } from "shiki/engine/oniguruma"
2
+ import { createJavaScriptRegexEngine } from "shiki/engine/javascript"
3
3
  import type { HighlighterCore } from "shiki"
4
4
  import type { ResolvedTheme } from "../theme/ThemeContext"
5
5
  import { mauiShikiThemeDark, mauiShikiThemeLight } from "./mauiShikiTheme"
@@ -44,7 +44,7 @@ function getHighlighter() {
44
44
  import("@shikijs/langs/json"),
45
45
  import("@shikijs/langs/bash"),
46
46
  ],
47
- engine: createOnigurumaEngine(import("shiki/wasm")),
47
+ engine: createJavaScriptRegexEngine(),
48
48
  })
49
49
  }
50
50