@tanishqkancharla/maui 0.0.59 → 0.0.61

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.
@@ -0,0 +1,13 @@
1
+ export type ActivityIndicatorVariant = "primary" | "accent" | "muted";
2
+ export type ActivityIndicatorProps = {
3
+ size?: string;
4
+ variant?: ActivityIndicatorVariant;
5
+ className?: string;
6
+ "aria-label"?: string;
7
+ };
8
+ /**
9
+ * iOS-style spinner: eight rounded spokes with a fading tail. The wheel
10
+ * rotates in discrete 45° steps, like UIActivityIndicatorView, so the bright
11
+ * spoke appears to hop clockwise.
12
+ */
13
+ export declare function ActivityIndicator({ size, variant, className, "aria-label": ariaLabel, }: ActivityIndicatorProps): import("react").JSX.Element;
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ import { style, useStyles } from "purse-styles";
4
+ import { colors } from "../tokens/colors";
5
+ import { cls } from "../utils/cls";
6
+ // Geometry is in a 24×24 viewBox. Spokes end this far from the center.
7
+ const VIEWBOX_CENTER = 12;
8
+ const SPOKE_OUTER_RADIUS = 9.5;
9
+ const SPOKE_COUNT = 8;
10
+ const SPOKE_LENGTH = 2.75;
11
+ const SPOKE_WIDTH = 2.5;
12
+ const MIN_SPOKE_OPACITY = 0.15;
13
+ const ROTATION_MS = 800;
14
+ const SPOKE_TOP = VIEWBOX_CENTER - SPOKE_OUTER_RADIUS;
15
+ // The top spoke leads; spokes counterclockwise from it fade toward the tail.
16
+ const spokes = Array.from({ length: SPOKE_COUNT }, (_, index) => {
17
+ const distanceBehindHead = (SPOKE_COUNT - index) % SPOKE_COUNT;
18
+ return {
19
+ angle: (index * 360) / SPOKE_COUNT,
20
+ opacity: 1 - (distanceBehindHead * (1 - MIN_SPOKE_OPACITY)) / (SPOKE_COUNT - 1),
21
+ };
22
+ });
23
+ /**
24
+ * iOS-style spinner: eight rounded spokes with a fading tail. The wheel
25
+ * rotates in discrete 45° steps, like UIActivityIndicatorView, so the bright
26
+ * spoke appears to hop clockwise.
27
+ */
28
+ export function ActivityIndicator({ size = "1em", variant = "accent", className, "aria-label": ariaLabel = "Loading", }) {
29
+ const svgRef = useRef(null);
30
+ const rootClassName = useStyles(rootClass, variantStyles[variant]);
31
+ useEffect(() => {
32
+ const animation = svgRef.current?.animate([{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }], {
33
+ duration: ROTATION_MS,
34
+ iterations: Infinity,
35
+ easing: `steps(${SPOKE_COUNT})`,
36
+ });
37
+ return () => animation?.cancel();
38
+ }, []);
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))) }) }));
40
+ }
41
+ const variantStyles = {
42
+ primary: style({
43
+ color: "currentColor",
44
+ }),
45
+ accent: style({
46
+ color: colors.accent[10],
47
+ }),
48
+ muted: style({
49
+ color: colors.gray[11],
50
+ }),
51
+ };
52
+ const rootClass = style({
53
+ display: "inline-flex",
54
+ verticalAlign: "-0.125em",
55
+ flexShrink: 0,
56
+ });
@@ -30,7 +30,7 @@ const disabledQuiet = {
30
30
  backgroundColor: "transparent",
31
31
  boxShadow: "none",
32
32
  };
33
- const buttonBaseClass = style(text({ size: "xs", fontWeight: 400, color: "highContrast" }), focusRing("&:focus-visible", buttonShadow), spacing.padding({ x: 6, y: 4 }), radius.sm, {
33
+ const buttonBaseClass = style(text({ size: "xs", fontWeight: 500, color: "highContrast" }), focusRing("&:focus-visible", buttonShadow), spacing.padding({ x: 6, y: 4 }), radius.sm, {
34
34
  display: "inline-flex",
35
35
  alignItems: "center",
36
36
  justifyContent: "center",
@@ -65,7 +65,7 @@ const buttonTextClass = style({
65
65
  minWidth: 0,
66
66
  textBox: "trim-both cap alphabetic",
67
67
  });
68
- const smallButtonClass = style(text({ size: "2xs", fontWeight: 400, color: "highContrast" }), {
68
+ const smallButtonClass = style(text({ size: "2xs", fontWeight: 500, color: "highContrast" }), {
69
69
  height: controlSize.smHeight,
70
70
  gap: spacing.value(1),
71
71
  paddingInline: spacing.value(4),
@@ -146,9 +146,9 @@ function onSolidText(color) {
146
146
  function fillShade(color, lightnessOffset, alpha = 1) {
147
147
  return `oklch(from ${color} calc(l - ${lightnessOffset}) c h / ${alpha})`;
148
148
  }
149
- /** Fill-tinted edge and lift; no neutral gray, so the shadow matches any fill. */
150
- function primaryButtonShadow(color) {
151
- return `inset 0 1px #ffffff18, inset 0 0 1px .5px #fff2, 0 0 0 .5px ${fillShade(color, 0.1)}, 0 1px 2px ${fillShade(color, 0, 0.35)}, 0 3px 8px -3px ${fillShade(color, 0, 0.6)}`;
149
+ /** Dark-only top highlight, a fill-tinted lift (half strength in dark mode), and an edge painted beneath the lift: the fill's alpha step 6 in light mode, a near-black tint of the fill in dark mode. */
150
+ function primaryButtonShadow(color, lightEdge) {
151
+ return `inset 0 .5px light-dark(transparent, rgb(255 255 255 / 0.15)), 0 1px 2px light-dark(${fillShade(color, 0, 0.35)}, ${fillShade(color, 0, 0.18)}), 0 3px 8px -3px light-dark(${fillShade(color, 0, 0.6)}, ${fillShade(color, 0, 0.3)}), 0 0 0 .5px light-dark(${lightEdge}, oklch(from ${color} 0.18 calc(c * 0.5) h))`;
152
152
  }
153
153
  /**
154
154
  * Diagonal from a slightly darker, more saturated, warmer-rotated fill to the
@@ -159,8 +159,8 @@ function primaryButtonGradient(color) {
159
159
  }
160
160
  /** Hover lightens the top of the gradient instead of replacing the fill. */
161
161
  const primaryHoverSheen = `linear-gradient(to bottom, rgb(255 255 255 / 0.12), rgb(255 255 255 / 0))`;
162
- const primaryClass = memoize((fill, textColor) => {
163
- const primaryShadow = primaryButtonShadow(fill);
162
+ const primaryClass = memoize((fill, textColor, lightEdge) => {
163
+ const primaryShadow = primaryButtonShadow(fill, lightEdge);
164
164
  const gradient = primaryButtonGradient(fill);
165
165
  return style(buttonBaseClass, focusRing("&:focus-visible", primaryShadow), {
166
166
  color: textColor,
@@ -177,13 +177,14 @@ const coloredButtonClass = memoize((variant, color) => {
177
177
  if (isCssColor(color)) {
178
178
  const fill = opaqueColor(color);
179
179
  if (variant === "primary") {
180
- return primaryClass(fill, onSolidText(fill));
180
+ // No alpha scale for a custom fill; approximate step A6 with the fill at 35%.
181
+ return primaryClass(fill, onSolidText(fill), fillShade(fill, 0, 0.35));
181
182
  }
182
183
  return quietClass(fill, darkerFill(fill), fill);
183
184
  }
184
185
  const scale = paletteFill(color);
185
186
  if (variant === "primary") {
186
- return primaryClass(scale[9], darkTextOnSolid.has(color) ? scale[12] : "white");
187
+ return primaryClass(scale[9], darkTextOnSolid.has(color) ? scale[12] : "white", paletteAlpha(color)[6]);
187
188
  }
188
189
  return quietClass(scale[11], scale[12], paletteAlpha(color)[9]);
189
190
  });
@@ -3,7 +3,7 @@ import { useCallback, useState, } from "react";
3
3
  import { AnimatePresence, motion, useReducedMotion, } from "motion/react";
4
4
  import { Dialog as AriaDialog, Heading, Modal, ModalOverlay, } from "react-aria-components";
5
5
  import { style, useStyles } from "purse-styles";
6
- import { background } from "../tokens/background";
6
+ import { colors } from "../tokens/colors";
7
7
  import { border } from "../tokens/borders";
8
8
  import { overlayMotion } from "../tokens/motion";
9
9
  import { radius } from "../tokens/radius";
@@ -43,7 +43,8 @@ const overlay = style({
43
43
  overflow: "auto",
44
44
  overscrollBehavior: "contain",
45
45
  });
46
- const modalBase = style(background.element, border([], "outline"), radius.lg, {
46
+ const modalBase = style(border([], "outline"), radius.lg, {
47
+ backgroundColor: colors.gray[2],
47
48
  width: "min(440px, calc(100vw - 32px))",
48
49
  maxWidth: "100%",
49
50
  maxHeight: "calc(100dvh - 32px)",
@@ -4,7 +4,7 @@ export type LoadingScreenProps = {
4
4
  };
5
5
  /**
6
6
  * Full-size loading state. A `progressLabel` on the first render fades the
7
- * Thinking indicator and label in together. Without a label, the indicator
7
+ * activity indicator and label in together. Without a label, the indicator
8
8
  * waits 2s before fading in; a label that arrives later animates in and
9
9
  * shifts the indicator so the pair stays centered.
10
10
  */
@@ -7,9 +7,9 @@ import { radius } from "../tokens/radius";
7
7
  import { spacing } from "../tokens/spacing";
8
8
  import { text } from "../tokens/text";
9
9
  import { cls } from "../utils/cls";
10
+ import { ActivityIndicator } from "./ActivityIndicator";
10
11
  import { Crossfade } from "./Crossfade";
11
12
  import { Text } from "./Text";
12
- import { Thinking } from "./Thinking";
13
13
  const INDICATOR_DELAY_MS = 2000;
14
14
  function resolvedLabel(progressLabel) {
15
15
  const trimmed = progressLabel?.trim();
@@ -17,7 +17,7 @@ function resolvedLabel(progressLabel) {
17
17
  }
18
18
  /**
19
19
  * Full-size loading state. A `progressLabel` on the first render fades the
20
- * Thinking indicator and label in together. Without a label, the indicator
20
+ * activity indicator and label in together. Without a label, the indicator
21
21
  * waits 2s before fading in; a label that arrives later animates in and
22
22
  * shifts the indicator so the pair stays centered.
23
23
  */
@@ -53,7 +53,7 @@ export function LoadingScreen({ progressLabel, className, }) {
53
53
  }, INDICATOR_DELAY_MS);
54
54
  return () => window.clearTimeout(timeoutId);
55
55
  }, [hadLabelAtStart, label, showIndicator]);
56
- return (_jsx("div", { className: cls(rootClassName, className), role: "status", "aria-live": "polite", "aria-busy": "true", children: _jsx(AnimatePresence, { children: showIndicator ? (_jsxs(motion.div, { className: stackClassName, initial: { opacity: 0 }, animate: { opacity: 1 }, transition: transition, children: [_jsx(Thinking, { variant: "accent", size: "0.8em", "aria-label": label ?? "Loading" }), _jsx(AnimatePresence, { initial: false, children: showLabel && label ? (_jsx(motion.div, { className: labelSlotClassName, initial: reduceMotion
56
+ return (_jsx("div", { className: cls(rootClassName, className), role: "status", "aria-live": "polite", "aria-busy": "true", children: _jsx(AnimatePresence, { children: showIndicator ? (_jsxs(motion.div, { className: stackClassName, initial: { opacity: 0 }, animate: { opacity: 1 }, transition: transition, children: [_jsx(ActivityIndicator, { size: "1.25em", "aria-label": label ?? "Loading" }), _jsx(AnimatePresence, { initial: false, children: showLabel && label ? (_jsx(motion.div, { className: labelSlotClassName, initial: reduceMotion
57
57
  ? { opacity: 0 }
58
58
  : { opacity: 0, height: 0 }, animate: reduceMotion
59
59
  ? { opacity: 1 }
package/dist/maui.d.ts CHANGED
@@ -35,6 +35,7 @@ export { Overlay } from "./components/Overlay";
35
35
  export { Drawer, drawerStyles, type DrawerProps, type DrawerSide, } from "./components/Drawer";
36
36
  export { Table, TableHeader, TableHead, TableBody, TableFooter, TableRow, TableCell, TableCaption, tableStyles, type TableAlign, type TableProps, type TableHeaderProps, type TableHeadProps, type TableBodyProps, type TableFooterProps, type TableRowProps, type TableCellProps, } from "./components/Table";
37
37
  export { Prose, useProseSize, proseContainerStyle, proseMaxWidth } from "./components/Prose";
38
+ export { ActivityIndicator, type ActivityIndicatorProps, type ActivityIndicatorVariant, } from "./components/ActivityIndicator";
38
39
  export { Thinking, type ThinkingProps, type ThinkingVariant, } from "./components/Thinking";
39
40
  export { Crossfade, type CrossfadeProps, type CrossfadeDirection, } from "./components/Crossfade";
40
41
  export { LoadingScreen, type LoadingScreenProps, } from "./components/LoadingScreen";
package/dist/maui.js CHANGED
@@ -38,6 +38,7 @@ export { Overlay } from "./components/Overlay";
38
38
  export { Drawer, drawerStyles, } from "./components/Drawer";
39
39
  export { Table, TableHeader, TableHead, TableBody, TableFooter, TableRow, TableCell, TableCaption, tableStyles, } from "./components/Table";
40
40
  export { Prose, useProseSize, proseContainerStyle, proseMaxWidth } from "./components/Prose";
41
+ export { ActivityIndicator, } from "./components/ActivityIndicator";
41
42
  export { Thinking, } from "./components/Thinking";
42
43
  export { Crossfade, } from "./components/Crossfade";
43
44
  export { LoadingScreen, } from "./components/LoadingScreen";
@@ -11,7 +11,7 @@ const appSurface = defineVars({
11
11
  const elementSurface = defineVars({
12
12
  element: {
13
13
  default: "#ffffff",
14
- [DARK_THEME]: colors.gray[2],
14
+ [DARK_THEME]: colors.gray[3],
15
15
  },
16
16
  });
17
17
  /** Shared interactive surface washes. */
@@ -1,7 +1,7 @@
1
1
  export declare const shadowVars: import("purse-styles").VariableGroup<{
2
2
  readonly control: {
3
- readonly default: "inset 0 1px #fff8, inset 0 0 1px 1px #fff8, 0 .5px 1px #00000018, 0 0 0 .5px rgb(0 0 0 / 8%), 0 0 4px -1px #0001";
4
- readonly ":root[data-theme=\"dark\"]": "inset 0 1px #ffffff08, 0 0 0 .5px rgb(255 255 255 / 8%), 0 1px .5px #00000018, 0 0 3px -1px #000a";
3
+ readonly default: `0 .5px 1px #00000018, 0 0 4px -1px #0001, 0 0 0 .5px ${string}`;
4
+ readonly ":root[data-theme=\"dark\"]": "inset 0 .5px rgb(255 255 255 / 0.09), 0 1px .5px #00000018, 0 0 3px -1px #0005, 0 0 0 .5px rgb(0 0 0 / 0.8)";
5
5
  };
6
6
  readonly subtle: `rgba(var(--${string}), 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, var(--${string})) 0px 1px 1px -0.5px, rgba(0, 0, 0, var(--${string})) 0px 3px 3px -1.5px`;
7
7
  readonly medium: `rgba(var(--${string}), 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, var(--${string})) 0px 1px 1px -0.5px, rgba(0, 0, 0, var(--${string})) 0px 3px 3px -1.5px, rgba(0, 0, 0, var(--${string})) 0px 6px 6px -3px`;
@@ -1,5 +1,6 @@
1
1
  import { defineVars, style } from "purse-styles";
2
2
  import { DARK_THEME } from "../theme/dataTheme";
3
+ import { colors } from "./colors";
3
4
  const shadowParams = defineVars({
4
5
  foregroundRgb: {
5
6
  default: "0, 0, 0",
@@ -14,8 +15,10 @@ const rgb = shadowParams.foregroundRgb;
14
15
  const blur = shadowParams.blurOpacity;
15
16
  export const shadowVars = defineVars({
16
17
  control: {
17
- default: `inset 0 1px #fff8, inset 0 0 1px 1px #fff8, 0 .5px 1px #00000018, 0 0 0 .5px rgb(0 0 0 / 8%), 0 0 4px -1px #0001`,
18
- [DARK_THEME]: `inset 0 1px #ffffff08, 0 0 0 .5px rgb(255 255 255 / 8%), 0 1px .5px #00000018, 0 0 3px -1px #000a`,
18
+ // 0.5px edge listed last, so it paints beneath the drop: light reads as a
19
+ // gray A6 border; dark is near-black so the control looks cut into the surface.
20
+ default: `0 .5px 1px #00000018, 0 0 4px -1px #0001, 0 0 0 .5px ${colors.grayAlpha[6]}`,
21
+ [DARK_THEME]: `inset 0 .5px rgb(255 255 255 / 0.09), 0 1px .5px #00000018, 0 0 3px -1px #0005, 0 0 0 .5px rgb(0 0 0 / 0.8)`,
19
22
  },
20
23
  subtle: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px`,
21
24
  medium: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px, rgba(0, 0, 0, ${blur}) 0px 6px 6px -3px`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanishqkancharla/maui",
3
- "version": "0.0.59",
3
+ "version": "0.0.61",
4
4
  "description": "Maui design system",
5
5
  "type": "module",
6
6
  "main": "./dist/maui.js",
@@ -30,6 +30,7 @@ Use Maui components. When one can't do what you need, build from the matching `r
30
30
  | Prose | [prose.md](references/components/prose.md) (`Prose`, `H1`–`H4`, `P`, lists, `Label`, `Link`) |
31
31
  | Editor / MarkdownEditor | [editor.md](references/components/editor.md) (optional TipTap peers; separate imports) |
32
32
  | Thinking | [thinking.md](references/components/thinking.md) |
33
+ | Activity indicator | [activity-indicator.md](references/components/activity-indicator.md) |
33
34
  | Crossfade | [crossfade.md](references/components/crossfade.md) |
34
35
  | Loading screen | [loading-screen.md](references/components/loading-screen.md) |
35
36
  | Text | [text.md](references/components/text.md) |
@@ -0,0 +1,16 @@
1
+ # Activity indicator
2
+
3
+ Gallery: `/components/activity-indicator`. Import from `"maui"`.
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"`.
6
+
7
+ On an accent fill, such as a primary button, pass `variant="primary"` so the spinner takes the button text color.
8
+
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
+
11
+ ```tsx
12
+ <Button variant="primary">
13
+ <ActivityIndicator variant="primary" aria-label="Saving" />
14
+ Saving
15
+ </Button>
16
+ ```
@@ -30,7 +30,7 @@ Styled React Aria `Button` (default `type="button"`). Consumes `ButtonContext`,
30
30
  | `onClick` | Still supported. React Aria also exposes `onPress` |
31
31
  | `children` | Text is wrapped for cap-height trim; SVG icons sit beside text. Icon-only needs `aria-label` |
32
32
 
33
- Primary: fill step 9, light text (`onAccent`, or step 12 on amber/lime/mint/sky/yellow). Custom CSS fill drops alpha; text is white or near-black from lightness. The fill is a diagonal gradient (`to top left`) from the fill shifted by OKLCH `l − 0.027`, `c × 1.05`, `h − 6.8` to the fill itself. The shadow keeps the white inset highlight and tints a 0.5px edge (`l − 0.1`) and a soft lift from the fill — no neutral gray. Hover layers a white-to-transparent sheen over the gradient; disabled drops the gradient. Quiet: no fill; `variantColor` tints the label/icon. Hover/press mix `grayAlpha[9]` (or that color’s alpha 9, or the hex) into transparent at 6% light / 9% dark; press is 2×. Hover fills snap.
33
+ Primary: fill step 9, light text (`onAccent`, or step 12 on amber/lime/mint/sky/yellow). Custom CSS fill drops alpha; text is white or near-black from lightness. The fill is a diagonal gradient (`to top left`) from the fill shifted by OKLCH `l − 0.027`, `c × 1.05`, `h − 6.8` to the fill itself. In dark mode the shadow has a 0.5px white top highlight at 15% (`shadow.control` uses 9%); light mode has none. The shadow tints a soft lift from the fill, at half strength in dark mode, and adds a 0.5px edge painted beneath the lift: the fill color’s alpha step 6 in light mode (a custom CSS fill uses the fill at 35%); in dark mode a near-black tint of the fill (OKLCH lightness 0.18, half chroma) — no neutral gray. Hover layers a white-to-transparent sheen over the gradient; disabled drops the gradient. Quiet: no fill; `variantColor` tints the label/icon. Hover/press mix `grayAlpha[9]` (or that color’s alpha 9, or the hex) into transparent at 6% light / 9% dark; press is 2×. Hover fills snap.
34
34
 
35
35
  While a menu or overlay is open, the trigger keeps the active fill via RAC `data-pressed` / `aria-expanded` (same tokens as `:active`: `elementActive`, or primary step 10).
36
36
 
@@ -1,8 +1,8 @@
1
1
  # Loading screen
2
2
 
3
- Gallery: `/components/loading-screen`. Import from `"maui"`. Uses [Thinking](thinking.md) and [Crossfade](crossfade.md).
3
+ Gallery: `/components/loading-screen`. Import from `"maui"`. Uses [Activity indicator](activity-indicator.md) and [Crossfade](crossfade.md).
4
4
 
5
- Fills available width and height (`flex: 1`, `width/height: 100%`). Centered `Thinking` (accent, `0.8em`), with `spacing.value(12)` (24px at the default scale) above the label.
5
+ Fills available width and height (`flex: 1`, `width/height: 100%`). Centered `ActivityIndicator` (accent, `1.25em`), with `spacing.value(12)` (24px at the default scale) above the label.
6
6
 
7
7
  ```tsx
8
8
  <LoadingScreen />
@@ -11,7 +11,7 @@ Fills available width and height (`flex: 1`, `width/height: 100%`). Centered `Th
11
11
 
12
12
  | First paint | Later `progressLabel` |
13
13
  | --- | --- |
14
- | Label present | Thinking + label fade in together. Later labels Crossfade `up`. Label punctuation is preserved; nothing is appended |
15
- | No label | Thinking waits **2s**, then fades in. A label before 2s fades both in immediately. A label after 2s animates in and shifts Thinking so the pair stays centered |
14
+ | Label present | Indicator + label fade in together. Later labels Crossfade `up`. Label punctuation is preserved; nothing is appended |
15
+ | No label | The indicator waits **2s**, then fades in. A label before 2s fades both in immediately. A label after 2s animates in and shifts the indicator so the pair stays centered |
16
16
 
17
17
  `role="status"` `aria-busy` `aria-live="polite"`.
@@ -4,6 +4,8 @@ Gallery: `/components/thinking`. Import from `"maui"`.
4
4
 
5
5
  3×3 Game of Life, text-sized. `size` is a CSS length (default `1em`). `variant`: `"primary"` (currentColor) | `"accent"` | `"muted"`.
6
6
 
7
+ Use Thinking only while waiting on LLM inference, such as a model drafting a reply or an agent working. For ordinary loading (fetching, saving, syncing), use [Activity indicator](activity-indicator.md).
8
+
7
9
  ```tsx
8
10
  <Flex row alignItems="center" gap={4}>
9
11
  <Thinking size="0.75em" variant="muted" />
@@ -178,7 +178,7 @@ shadowVars.control | .subtle | .medium | .strong // raw box-shadow strings
178
178
  tintedSubtle(color) // subtle ring+blur tinted from a fill
179
179
  ```
180
180
 
181
- `shadow.control` uses inset highlights and a compact outer edge. Every elevation
181
+ `shadow.control` is a small drop over a 0.5px edge: gray A6 (`grayAlpha[6]`) in light mode, 80% black in dark mode so the control looks cut into the surface. Dark mode also adds a 0.5px top highlight. Every elevation
182
182
  already includes a 1px ring. **Do not also `border()` the same element.**
183
183
 
184
184
  `border(sides, color)`:
@@ -42,6 +42,7 @@ import {
42
42
  import { Text } from "../../components/Text"
43
43
  import { TabBar } from "../../components/TabBar"
44
44
  import { Thinking } from "../../components/Thinking"
45
+ import { ActivityIndicator } from "../../components/ActivityIndicator"
45
46
  import { Crossfade } from "../../components/Crossfade"
46
47
  import { LoadingScreen } from "../../components/LoadingScreen"
47
48
  import { Tooltip } from "../../components/Tooltip"
@@ -529,6 +530,16 @@ export const catalog: CatalogComponent[] = [
529
530
  { name: "className" },
530
531
  ],
531
532
  },
533
+ {
534
+ name: "ActivityIndicator",
535
+ info: "iOS-style spinner. Eight spokes with a fading tail rotate in 45° steps. Accent by default.",
536
+ attributes: [
537
+ { name: "size", info: "CSS length, default 1em" },
538
+ { name: "variant", values: ["accent", "primary", "muted"] },
539
+ { name: "aria-label", info: "Default Loading" },
540
+ { name: "className" },
541
+ ],
542
+ },
532
543
  {
533
544
  name: "Crossfade",
534
545
  info: "When contentKey changes, fades previous children out and the next in. Omit direction for an in-place opacity fade. Pass up, down, left, or right to slide. contentKey is required.",
@@ -544,11 +555,11 @@ export const catalog: CatalogComponent[] = [
544
555
  },
545
556
  {
546
557
  name: "LoadingScreen",
547
- info: "Full-size loading state. Optional progressLabel is shown in accent, medium weight, with trailing .... Small accent Thinking. Delays 2s when no label at start; label changes Crossfade up.",
558
+ info: "Full-size loading state. Optional progressLabel is shown in accent, medium weight, with trailing .... Accent ActivityIndicator. Delays 2s when no label at start; label changes Crossfade up.",
548
559
  attributes: [
549
560
  {
550
561
  name: "progressLabel",
551
- info: "Optional status under the Thinking indicator",
562
+ info: "Optional status under the activity indicator",
552
563
  },
553
564
  { name: "className" },
554
565
  ],
@@ -600,6 +611,7 @@ export const previewScope: Record<string, unknown> = {
600
611
  Text,
601
612
  TabBar,
602
613
  Thinking,
614
+ ActivityIndicator,
603
615
  Crossfade,
604
616
  LoadingScreen,
605
617
  Tooltip,
@@ -0,0 +1,112 @@
1
+ import { useEffect, useRef } from "react"
2
+ import { style, useStyles } from "purse-styles"
3
+ import { colors } from "../tokens/colors"
4
+ import { cls } from "../utils/cls"
5
+
6
+ // Geometry is in a 24×24 viewBox. Spokes end this far from the center.
7
+ const VIEWBOX_CENTER = 12
8
+ const SPOKE_OUTER_RADIUS = 9.5
9
+ const SPOKE_COUNT = 8
10
+ const SPOKE_LENGTH = 2.75
11
+ const SPOKE_WIDTH = 2.5
12
+ const MIN_SPOKE_OPACITY = 0.15
13
+ const ROTATION_MS = 800
14
+
15
+ const SPOKE_TOP = VIEWBOX_CENTER - SPOKE_OUTER_RADIUS
16
+
17
+ // The top spoke leads; spokes counterclockwise from it fade toward the tail.
18
+ const spokes = Array.from({ length: SPOKE_COUNT }, (_, index) => {
19
+ const distanceBehindHead = (SPOKE_COUNT - index) % SPOKE_COUNT
20
+ return {
21
+ angle: (index * 360) / SPOKE_COUNT,
22
+ opacity:
23
+ 1 - (distanceBehindHead * (1 - MIN_SPOKE_OPACITY)) / (SPOKE_COUNT - 1),
24
+ }
25
+ })
26
+
27
+ export type ActivityIndicatorVariant = "primary" | "accent" | "muted"
28
+
29
+ export type ActivityIndicatorProps = {
30
+ size?: string
31
+ variant?: ActivityIndicatorVariant
32
+ className?: string
33
+ "aria-label"?: string
34
+ }
35
+
36
+ /**
37
+ * iOS-style spinner: eight rounded spokes with a fading tail. The wheel
38
+ * rotates in discrete 45° steps, like UIActivityIndicatorView, so the bright
39
+ * spoke appears to hop clockwise.
40
+ */
41
+ export function ActivityIndicator({
42
+ size = "1em",
43
+ variant = "accent",
44
+ className,
45
+ "aria-label": ariaLabel = "Loading",
46
+ }: ActivityIndicatorProps) {
47
+ const svgRef = useRef<SVGSVGElement>(null)
48
+ const rootClassName = useStyles(rootClass, variantStyles[variant])
49
+
50
+ useEffect(() => {
51
+ const animation = svgRef.current?.animate(
52
+ [{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
53
+ {
54
+ duration: ROTATION_MS,
55
+ iterations: Infinity,
56
+ easing: `steps(${SPOKE_COUNT})`,
57
+ },
58
+ )
59
+ return () => animation?.cancel()
60
+ }, [])
61
+
62
+ return (
63
+ <span
64
+ role="status"
65
+ aria-label={ariaLabel}
66
+ className={cls(rootClassName, className)}
67
+ style={{ width: size, height: size }}
68
+ >
69
+ <svg
70
+ ref={svgRef}
71
+ aria-hidden="true"
72
+ focusable="false"
73
+ viewBox="0 0 24 24"
74
+ width="100%"
75
+ height="100%"
76
+ >
77
+ {spokes.map(({ angle, opacity }) => (
78
+ <line
79
+ key={angle}
80
+ x1={VIEWBOX_CENTER}
81
+ y1={SPOKE_TOP}
82
+ x2={VIEWBOX_CENTER}
83
+ y2={SPOKE_TOP + SPOKE_LENGTH}
84
+ stroke="currentColor"
85
+ strokeWidth={SPOKE_WIDTH}
86
+ strokeLinecap="round"
87
+ opacity={opacity}
88
+ transform={`rotate(${angle} ${VIEWBOX_CENTER} ${VIEWBOX_CENTER})`}
89
+ />
90
+ ))}
91
+ </svg>
92
+ </span>
93
+ )
94
+ }
95
+
96
+ const variantStyles = {
97
+ primary: style({
98
+ color: "currentColor",
99
+ }),
100
+ accent: style({
101
+ color: colors.accent[10],
102
+ }),
103
+ muted: style({
104
+ color: colors.gray[11],
105
+ }),
106
+ } as const
107
+
108
+ const rootClass = style({
109
+ display: "inline-flex",
110
+ verticalAlign: "-0.125em",
111
+ flexShrink: 0,
112
+ })
@@ -44,7 +44,7 @@ const disabledQuiet = {
44
44
  } as const
45
45
 
46
46
  const buttonBaseClass = style(
47
- text({ size: "xs", fontWeight: 400, color: "highContrast" }),
47
+ text({ size: "xs", fontWeight: 500, color: "highContrast" }),
48
48
  focusRing("&:focus-visible", buttonShadow),
49
49
  spacing.padding({ x: 6, y: 4 }),
50
50
  radius.sm,
@@ -87,7 +87,7 @@ const buttonTextClass = style({
87
87
  })
88
88
 
89
89
  const smallButtonClass = style(
90
- text({ size: "2xs", fontWeight: 400, color: "highContrast" }),
90
+ text({ size: "2xs", fontWeight: 500, color: "highContrast" }),
91
91
  {
92
92
  height: controlSize.smHeight,
93
93
  gap: spacing.value(1),
@@ -193,9 +193,9 @@ function fillShade(color: string, lightnessOffset: number, alpha = 1) {
193
193
  return `oklch(from ${color} calc(l - ${lightnessOffset}) c h / ${alpha})`
194
194
  }
195
195
 
196
- /** Fill-tinted edge and lift; no neutral gray, so the shadow matches any fill. */
197
- function primaryButtonShadow(color: string) {
198
- return `inset 0 1px #ffffff18, inset 0 0 1px .5px #fff2, 0 0 0 .5px ${fillShade(color, 0.1)}, 0 1px 2px ${fillShade(color, 0, 0.35)}, 0 3px 8px -3px ${fillShade(color, 0, 0.6)}`
196
+ /** Dark-only top highlight, a fill-tinted lift (half strength in dark mode), and an edge painted beneath the lift: the fill's alpha step 6 in light mode, a near-black tint of the fill in dark mode. */
197
+ function primaryButtonShadow(color: string, lightEdge: string) {
198
+ return `inset 0 .5px light-dark(transparent, rgb(255 255 255 / 0.15)), 0 1px 2px light-dark(${fillShade(color, 0, 0.35)}, ${fillShade(color, 0, 0.18)}), 0 3px 8px -3px light-dark(${fillShade(color, 0, 0.6)}, ${fillShade(color, 0, 0.3)}), 0 0 0 .5px light-dark(${lightEdge}, oklch(from ${color} 0.18 calc(c * 0.5) h))`
199
199
  }
200
200
 
201
201
  /**
@@ -209,27 +209,30 @@ function primaryButtonGradient(color: string) {
209
209
  /** Hover lightens the top of the gradient instead of replacing the fill. */
210
210
  const primaryHoverSheen = `linear-gradient(to bottom, rgb(255 255 255 / 0.12), rgb(255 255 255 / 0))`
211
211
 
212
- const primaryClass = memoize((fill: string, textColor: string) => {
213
- const primaryShadow = primaryButtonShadow(fill)
214
- const gradient = primaryButtonGradient(fill)
215
- return style(buttonBaseClass, focusRing("&:focus-visible", primaryShadow), {
216
- color: textColor,
217
- backgroundColor: fill,
218
- backgroundImage: gradient,
219
- boxShadow: primaryShadow,
220
- "&:hover:not(:disabled)": {
221
- backgroundImage: `${primaryHoverSheen}, ${gradient}`,
222
- },
223
- "&:disabled": disabledRaised,
224
- })
225
- })
212
+ const primaryClass = memoize(
213
+ (fill: string, textColor: string, lightEdge: string) => {
214
+ const primaryShadow = primaryButtonShadow(fill, lightEdge)
215
+ const gradient = primaryButtonGradient(fill)
216
+ return style(buttonBaseClass, focusRing("&:focus-visible", primaryShadow), {
217
+ color: textColor,
218
+ backgroundColor: fill,
219
+ backgroundImage: gradient,
220
+ boxShadow: primaryShadow,
221
+ "&:hover:not(:disabled)": {
222
+ backgroundImage: `${primaryHoverSheen}, ${gradient}`,
223
+ },
224
+ "&:disabled": disabledRaised,
225
+ })
226
+ },
227
+ )
226
228
 
227
229
  const coloredButtonClass = memoize(
228
230
  (variant: "primary" | "quiet", color: ButtonVariantColor) => {
229
231
  if (isCssColor(color)) {
230
232
  const fill = opaqueColor(color)
231
233
  if (variant === "primary") {
232
- return primaryClass(fill, onSolidText(fill))
234
+ // No alpha scale for a custom fill; approximate step A6 with the fill at 35%.
235
+ return primaryClass(fill, onSolidText(fill), fillShade(fill, 0, 0.35))
233
236
  }
234
237
 
235
238
  return quietClass(fill, darkerFill(fill), fill)
@@ -240,6 +243,7 @@ const coloredButtonClass = memoize(
240
243
  return primaryClass(
241
244
  scale[9],
242
245
  darkTextOnSolid.has(color) ? scale[12] : "white",
246
+ paletteAlpha(color)[6],
243
247
  )
244
248
  }
245
249
 
@@ -17,7 +17,7 @@ import {
17
17
  ModalOverlay,
18
18
  } from "react-aria-components"
19
19
  import { style, useStyles } from "purse-styles"
20
- import { background } from "../tokens/background"
20
+ import { colors } from "../tokens/colors"
21
21
  import { border } from "../tokens/borders"
22
22
  import { overlayMotion } from "../tokens/motion"
23
23
  import { radius } from "../tokens/radius"
@@ -81,7 +81,8 @@ const overlay = style({
81
81
  overscrollBehavior: "contain",
82
82
  })
83
83
 
84
- const modalBase = style(background.element, border([], "outline"), radius.lg, {
84
+ const modalBase = style(border([], "outline"), radius.lg, {
85
+ backgroundColor: colors.gray[2],
85
86
  width: "min(440px, calc(100vw - 32px))",
86
87
  maxWidth: "100%",
87
88
  maxHeight: "calc(100dvh - 32px)",
@@ -10,9 +10,9 @@ import { radius } from "../tokens/radius"
10
10
  import { spacing } from "../tokens/spacing"
11
11
  import { text } from "../tokens/text"
12
12
  import { cls } from "../utils/cls"
13
+ import { ActivityIndicator } from "./ActivityIndicator"
13
14
  import { Crossfade } from "./Crossfade"
14
15
  import { Text } from "./Text"
15
- import { Thinking } from "./Thinking"
16
16
 
17
17
  const INDICATOR_DELAY_MS = 2000
18
18
 
@@ -28,7 +28,7 @@ function resolvedLabel(progressLabel: string | undefined) {
28
28
 
29
29
  /**
30
30
  * Full-size loading state. A `progressLabel` on the first render fades the
31
- * Thinking indicator and label in together. Without a label, the indicator
31
+ * activity indicator and label in together. Without a label, the indicator
32
32
  * waits 2s before fading in; a label that arrives later animates in and
33
33
  * shifts the indicator so the pair stays centered.
34
34
  */
@@ -87,9 +87,8 @@ export function LoadingScreen({
87
87
  animate={{ opacity: 1 }}
88
88
  transition={transition}
89
89
  >
90
- <Thinking
91
- variant="accent"
92
- size="0.8em"
90
+ <ActivityIndicator
91
+ size="1.25em"
93
92
  aria-label={label ?? "Loading"}
94
93
  />
95
94
  <AnimatePresence initial={false}>
package/src/maui.ts CHANGED
@@ -139,6 +139,11 @@ export {
139
139
  type TableCellProps,
140
140
  } from "./components/Table"
141
141
  export { Prose, useProseSize, proseContainerStyle, proseMaxWidth } from "./components/Prose"
142
+ export {
143
+ ActivityIndicator,
144
+ type ActivityIndicatorProps,
145
+ type ActivityIndicatorVariant,
146
+ } from "./components/ActivityIndicator"
142
147
  export {
143
148
  Thinking,
144
149
  type ThinkingProps,
@@ -0,0 +1,81 @@
1
+ import { style, useStyles } from "purse-styles"
2
+ import { Panel } from "./Panel"
3
+ import { ActivityIndicator } from "../components/ActivityIndicator"
4
+ import { Button } from "../components/Button"
5
+ import { Prose } from "../components/Prose"
6
+ import { H2, H3, P } from "../components/Typography"
7
+ import { flex } from "../tokens/layout"
8
+ import { text } from "../tokens/text"
9
+
10
+ export function ActivityIndicatorPage() {
11
+ const pageClassName = useStyles(pageClass)
12
+ const rowClassName = useStyles(rowClass)
13
+ const columnClassName = useStyles(columnClass)
14
+ const inlineExampleClassName = useStyles(inlineExampleClass)
15
+
16
+ return (
17
+ <Prose className={pageClassName}>
18
+ <H2>Activity indicator</H2>
19
+ <P>
20
+ An iOS-style spinner. Eight rounded spokes fade from the leading spoke
21
+ to the tail, and the wheel rotates in 45° steps so the bright spoke hops
22
+ clockwise. It is text-sized and accent-colored by default. Use it for
23
+ loading; use Thinking when waiting on LLM inference.
24
+ </P>
25
+
26
+ <H3>Variants</H3>
27
+ <Panel className={columnClassName}>
28
+ <p className={inlineExampleClassName}>
29
+ <ActivityIndicator variant="accent" aria-label="Loading, accent" />
30
+ Loading messages
31
+ </p>
32
+ <p className={inlineExampleClassName}>
33
+ <ActivityIndicator variant="primary" aria-label="Loading, primary" />
34
+ Syncing calendar
35
+ </p>
36
+ <p className={inlineExampleClassName}>
37
+ <ActivityIndicator variant="muted" aria-label="Loading, muted" />
38
+ Fetching attachments
39
+ </p>
40
+ </Panel>
41
+
42
+ <H3>Sizes</H3>
43
+ <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" />
47
+ </Panel>
48
+
49
+ <H3>In a button</H3>
50
+ <Panel className={rowClassName}>
51
+ <Button variant="primary">
52
+ <ActivityIndicator variant="primary" aria-label="Saving" />
53
+ Saving
54
+ </Button>
55
+ <Button>
56
+ <ActivityIndicator aria-label="Refreshing" />
57
+ Refreshing
58
+ </Button>
59
+ </Panel>
60
+ </Prose>
61
+ )
62
+ }
63
+
64
+ const pageClass = style({
65
+ maxWidth: "1200px",
66
+ paddingBottom: "32px",
67
+ })
68
+
69
+ const rowClass = style(flex({ alignItems: "center", gap: 6 }))
70
+
71
+ const columnClass = style(flex({ direction: "column", gap: 6 }))
72
+
73
+ const inlineExampleClass = style(
74
+ text({ size: "md", fontWeight: 400, color: "highContrast" }),
75
+ {
76
+ display: "flex",
77
+ alignItems: "center",
78
+ gap: "6px",
79
+ margin: 0,
80
+ },
81
+ )
@@ -62,11 +62,11 @@ export function BackgroundColorTokenPage() {
62
62
  <Code>background.element</Code>
63
63
  </TableCell>
64
64
  <TableCell>
65
- <Code>#ffffff</Code> / <Code>colors.gray[2]</Code>
65
+ <Code>#ffffff</Code> / <Code>colors.gray[3]</Code>
66
66
  </TableCell>
67
67
  <TableCell>
68
68
  Inputs and slightly raised controls. Matches app white in
69
- light; one step lighter than the canvas in dark.
69
+ light; two steps lighter than the canvas in dark.
70
70
  </TableCell>
71
71
  </TableRow>
72
72
  <TableRow>
@@ -86,8 +86,11 @@ export function ButtonsPage() {
86
86
  <Code>variantColor</Code> (a palette name, default <Code>"accent"</Code>
87
87
  ). Pass a hex or <Code>rgb()</Code> string to use that color as the fill
88
88
  (alpha is dropped). The fill is a subtle diagonal gradient from a
89
- slightly darker, warmer shade of that color, and the edge and lift
90
- shadows are tinted from it. Hover adds a white sheen at the top.
89
+ slightly darker, warmer shade of that color, and the lift shadow is
90
+ tinted from it; the lift is half as strong in dark mode. The 0.5px edge
91
+ is alpha step 6 of that color in light mode and a near-black tint of the
92
+ fill in dark mode. Dark mode adds a 0.5px white top highlight (15% on
93
+ primary, 9% on default buttons). Hover adds a white sheen at the top.
91
94
  </P>
92
95
  <Flex row alignItems="center" gap={4} style={{ flexWrap: "wrap" }}>
93
96
  <Button variant="primary">Save</Button>
@@ -98,7 +98,7 @@ export function LoadingScreenPage() {
98
98
  Fills whatever width and height it is given, with{" "}
99
99
  <Code>radius.lg</Code> on the surface. Pass{" "}
100
100
  <Code>progressLabel</Code> to show a status under a small, accent
101
- Thinking indicator. The label is accent, medium weight, and keeps the
101
+ activity indicator. The label is accent, medium weight, and keeps the
102
102
  punctuation you provide. Label changes Crossfade up. With no label at
103
103
  mount, the indicator waits 2s before fading in.
104
104
  </P>
@@ -136,7 +136,7 @@ export function LoadingScreenPage() {
136
136
 
137
137
  <H3>No label — wait 2 seconds</H3>
138
138
  <P>
139
- Mounts empty. After 2 seconds the Thinking indicator fades in,
139
+ Mounts empty. After 2 seconds the activity indicator fades in,
140
140
  centered, with no label.
141
141
  </P>
142
142
  <Flex column gap={4}>
@@ -1,6 +1,5 @@
1
1
  import type React from "react"
2
2
  import { Suspense, useEffect, useState } from "react"
3
- import { useFilter } from "react-aria"
4
3
  import {
5
4
  Link as WouterLink,
6
5
  Redirect,
@@ -13,6 +12,7 @@ import {
13
12
  import { defineVars, style, useStyles } from "purse-styles"
14
13
  import { Button } from "../components/Button"
15
14
  import { Drawer } from "../components/Drawer"
15
+ import { FuzzyString } from "../components/FuzzyString"
16
16
  import { SearchField } from "../components/Input"
17
17
  import { Select, SelectItem } from "../components/Select"
18
18
  import { H3, Label } from "../components/Typography"
@@ -26,8 +26,10 @@ import { text } from "../tokens/text"
26
26
  import { type ThemePreference, useTheme } from "../theme/ThemeContext"
27
27
  import { isScalePreference, useScale } from "../theme/ScaleContext"
28
28
  import { LARGE_SCALE } from "../theme/dataScale"
29
+ import { fuzzyMatch } from "../utils/fuzzyMatch"
29
30
  import { galleryCompactMedia } from "./galleryCompact"
30
31
  import { ReadmePage } from "./ReadmePage"
32
+ import { ActivityIndicatorPage } from "./ActivityIndicatorPage"
31
33
  import { AvatarPage } from "./AvatarPage"
32
34
  import { BadgePage } from "./BadgePage"
33
35
  import { BackgroundColorTokenPage } from "./BackgroundColorTokenPage"
@@ -152,6 +154,11 @@ const navigation: NavEntry[] = [
152
154
  },
153
155
  { label: "Editor", path: "/components/editor", page: EditorPage },
154
156
  { label: "Thinking", path: "/components/thinking", page: ThinkingPage },
157
+ {
158
+ label: "Activity indicator",
159
+ path: "/components/activity-indicator",
160
+ page: ActivityIndicatorPage,
161
+ },
155
162
  {
156
163
  label: "Crossfade",
157
164
  path: "/components/crossfade",
@@ -363,19 +370,18 @@ function MauiNavigation() {
363
370
  const emptyClassName = useStyles(navEmptyClass)
364
371
  const { preference, setPreference } = useTheme()
365
372
  const { preference: scale, setPreference: setScale } = useScale()
366
- const { contains } = useFilter({ sensitivity: "base" })
367
373
  const normalizedQuery = query.trim()
374
+ const matches = (label: string) =>
375
+ fuzzyMatch(normalizedQuery, label) !== undefined
368
376
  const filteredNavigation = normalizedQuery
369
377
  ? navigation.flatMap<NavEntry>((entry) => {
370
378
  if (!isNavGroup(entry)) {
371
- return contains(entry.label, normalizedQuery) ? [entry] : []
379
+ return matches(entry.label) ? [entry] : []
372
380
  }
373
381
 
374
- const children = contains(entry.label, normalizedQuery)
382
+ const children = matches(entry.label)
375
383
  ? entry.children
376
- : entry.children.filter((item) =>
377
- contains(item.label, normalizedQuery),
378
- )
384
+ : entry.children.filter((item) => matches(item.label))
379
385
  return children.length > 0 ? [{ ...entry, children }] : []
380
386
  })
381
387
  : navigation
@@ -459,15 +465,21 @@ function MauiNavigation() {
459
465
  {filteredNavigation.map((entry) =>
460
466
  isNavGroup(entry) ? (
461
467
  <li className={groupClassName} key={entry.label}>
462
- <Label>{entry.label}</Label>
468
+ <Label>
469
+ <HighlightedLabel label={entry.label} query={normalizedQuery} />
470
+ </Label>
463
471
  <ul className={childrenClassName}>
464
472
  {entry.children.map((item) => (
465
- <NavLink key={item.path} item={item} />
473
+ <NavLink
474
+ key={item.path}
475
+ item={item}
476
+ query={normalizedQuery}
477
+ />
466
478
  ))}
467
479
  </ul>
468
480
  </li>
469
481
  ) : (
470
- <NavLink key={entry.path} item={entry} />
482
+ <NavLink key={entry.path} item={entry} query={normalizedQuery} />
471
483
  ),
472
484
  )}
473
485
  {filteredNavigation.length === 0 ? (
@@ -480,7 +492,12 @@ function MauiNavigation() {
480
492
  )
481
493
  }
482
494
 
483
- function NavLink(props: { item: NavItem }) {
495
+ function HighlightedLabel(props: { label: string; query: string }) {
496
+ const match = props.query ? fuzzyMatch(props.query, props.label) : undefined
497
+ return match ? <FuzzyString match={match} /> : props.label
498
+ }
499
+
500
+ function NavLink(props: { item: NavItem; query: string }) {
484
501
  const className = useStyles(navLinkClass)
485
502
  const [selected] = useRoute(props.item.path)
486
503
 
@@ -491,7 +508,7 @@ function NavLink(props: { item: NavItem }) {
491
508
  href={props.item.path}
492
509
  aria-current={selected ? "page" : undefined}
493
510
  >
494
- {props.item.label}
511
+ <HighlightedLabel label={props.item.label} query={props.query} />
495
512
  </WouterLink>
496
513
  </li>
497
514
  )
@@ -6,7 +6,7 @@ import { H2, H3, Li, Link, P, Ul } from "../components/Typography"
6
6
  export function ReadmePage() {
7
7
  return (
8
8
  <Prose style={{ marginBottom: "32px" }}>
9
- <H2>Maui</H2>
9
+ <H2>README</H2>
10
10
  <P>
11
11
  Maui is an open-source, TypeScript-first design system styled with{" "}
12
12
  <Link href="https://www.npmjs.com/package/purse-styles">
@@ -27,7 +27,8 @@ export function ShadowTokenPage() {
27
27
  <Prose style={{ marginBottom: "32px" }}>
28
28
  <H2>Shadows</H2>
29
29
  <P>
30
- Controls use inset highlights with a compact outer edge. Other surfaces
30
+ Controls use a small drop over a 0.5px edge: gray A6 in light mode,
31
+ near-black in dark mode, plus a top highlight in dark mode. Other surfaces
31
32
  use Craft&apos;s three-level elevation stack: a
32
33
  foreground-colored 1px ring plus progressively deeper black blur layers.
33
34
  Blur opacity is 0.06 in light mode and 0.12 in dark mode.
@@ -13,7 +13,7 @@ const appSurface = defineVars({
13
13
  const elementSurface = defineVars({
14
14
  element: {
15
15
  default: "#ffffff",
16
- [DARK_THEME]: colors.gray[2],
16
+ [DARK_THEME]: colors.gray[3],
17
17
  },
18
18
  })
19
19
 
@@ -1,5 +1,6 @@
1
1
  import { defineVars, style } from "purse-styles"
2
2
  import { DARK_THEME } from "../theme/dataTheme"
3
+ import { colors } from "./colors"
3
4
 
4
5
  const shadowParams = defineVars({
5
6
  foregroundRgb: {
@@ -17,8 +18,10 @@ const blur = shadowParams.blurOpacity
17
18
 
18
19
  export const shadowVars = defineVars({
19
20
  control: {
20
- default: `inset 0 1px #fff8, inset 0 0 1px 1px #fff8, 0 .5px 1px #00000018, 0 0 0 .5px rgb(0 0 0 / 8%), 0 0 4px -1px #0001`,
21
- [DARK_THEME]: `inset 0 1px #ffffff08, 0 0 0 .5px rgb(255 255 255 / 8%), 0 1px .5px #00000018, 0 0 3px -1px #000a`,
21
+ // 0.5px edge listed last, so it paints beneath the drop: light reads as a
22
+ // gray A6 border; dark is near-black so the control looks cut into the surface.
23
+ default: `0 .5px 1px #00000018, 0 0 4px -1px #0001, 0 0 0 .5px ${colors.grayAlpha[6]}`,
24
+ [DARK_THEME]: `inset 0 .5px rgb(255 255 255 / 0.09), 0 1px .5px #00000018, 0 0 3px -1px #0005, 0 0 0 .5px rgb(0 0 0 / 0.8)`,
22
25
  },
23
26
  subtle: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px`,
24
27
  medium: `rgba(${rgb}, 0.08) 0px 0px 0px 1px, rgba(0, 0, 0, ${blur}) 0px 1px 1px -0.5px, rgba(0, 0, 0, ${blur}) 0px 3px 3px -1.5px, rgba(0, 0, 0, ${blur}) 0px 6px 6px -3px`,