@tanishqkancharla/maui 0.0.67 → 0.0.72

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.
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import { type ButtonProps as RACButtonProps } from "react-aria-components";
3
3
  import { type ColorName } from "../tokens/colors";
4
- export type ButtonVariant = "default" | "quiet" | "primary";
4
+ export type ButtonVariant = "default" | "quiet" | "primary" | "text";
5
5
  export type ButtonSize = "default" | "sm";
6
6
  /**
7
7
  * @internal Shared shell under every Button variant (no fill, no hover).
@@ -132,6 +132,11 @@ const buttonClass = style(buttonBaseClass, {
132
132
  backgroundColor: backgroundColor.elementHover,
133
133
  },
134
134
  });
135
+ const textButtonSpacingClass = style({
136
+ "&, &:has(> svg), &:has([data-button-content] > svg), &:has(> svg:first-child:not(:only-child)), &:has([data-button-content] > svg:first-child:not(:only-child)), &:has(> svg:last-child:not(:only-child)), &:has([data-button-content] > svg:last-child:not(:only-child)), &:has(> svg:only-child), &:has([data-button-content] > svg:only-child), &[data-pending]": {
137
+ padding: 0,
138
+ },
139
+ });
135
140
  function paletteAlpha(name) {
136
141
  return colors[`${name}Alpha`];
137
142
  }
@@ -141,7 +146,9 @@ const quietClass = memoize((color, hoverColor, wash) => style(buttonBaseClass, f
141
146
  boxShadow: "none",
142
147
  "&:hover:not(:disabled):not([data-pending])": {
143
148
  color: hoverColor,
144
- backgroundColor: surfaceWash(wash, surfaceMixPercent.hover),
149
+ backgroundColor: wash
150
+ ? surfaceWash(wash, surfaceMixPercent.hover)
151
+ : "transparent",
145
152
  },
146
153
  "&:disabled": disabledQuiet,
147
154
  }));
@@ -214,13 +221,13 @@ const coloredButtonClass = memoize((variant, color) => {
214
221
  // No alpha scale for a custom fill; approximate step A6 with the fill at 35%.
215
222
  return primaryClass(fill, onSolidText(fill), fillShade(fill, 0, 0.35));
216
223
  }
217
- return quietClass(fill, darkerFill(fill), fill);
224
+ return quietClass(fill, darkerFill(fill), variant === "quiet" ? fill : undefined);
218
225
  }
219
226
  const scale = paletteFill(color);
220
227
  if (variant === "primary") {
221
- return primaryClass(scale[9], darkTextOnSolid.has(color) ? scale[12] : "white", paletteAlpha(color)[6]);
228
+ return primaryClass(scale[9], darkTextOnSolid.has(color) ? onSolidText(scale[9]) : "white", paletteAlpha(color)[6]);
222
229
  }
223
- return quietClass(scale[11], scale[12], paletteAlpha(color)[9]);
230
+ return quietClass(scale[11], scale[12], variant === "quiet" ? paletteAlpha(color)[9] : undefined);
224
231
  });
225
232
  export function useButton(props, forwardedRef) {
226
233
  const ref = useObjectRef(forwardedRef);
@@ -243,7 +250,7 @@ export function useButton(props, forwardedRef) {
243
250
  }
244
251
  /** `root` goes on the RAC `Button` (native `:hover` / `:focus-visible` / `:disabled`; icons are direct `> svg` children); `label` goes on a `<span>` wrapping each text run. */
245
252
  export const buttonStyles = {
246
- root: memoize(({ variant = "default", size = "default", variantColor, } = {}) => style(buttonVariantClass(variant, variantColor), size === "sm" && smallButtonClass)),
253
+ root: memoize(({ variant = "default", size = "default", variantColor, } = {}) => style(buttonVariantClass(variant, variantColor), size === "sm" && smallButtonClass, variant === "text" && textButtonSpacingClass)),
247
254
  label: buttonTextClass,
248
255
  };
249
256
  export const Button = React.forwardRef(function Button(props, forwardedRef) {
@@ -282,6 +289,9 @@ function buttonVariantClass(variant, variantColor) {
282
289
  if (variant === "primary") {
283
290
  return coloredButtonClass("primary", variantColor ?? "accent");
284
291
  }
292
+ if (variant === "text") {
293
+ return coloredButtonClass("text", variantColor ?? "gray");
294
+ }
285
295
  if (variant === "quiet" && variantColor) {
286
296
  return coloredButtonClass("quiet", variantColor);
287
297
  }
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { EditorContent } from "@tiptap/react";
3
3
  import { style, useStyles } from "purse-styles";
4
4
  import { colors } from "../tokens/colors";
5
- import { proseHtml } from "../tokens/prose";
5
+ import { proseHtml, proseRhythm } from "../tokens/prose";
6
6
  import { cls } from "../utils/cls";
7
7
  /**
8
8
  * Maui-styled TipTap surface. Create the editor with TipTap's useEditor;
@@ -11,7 +11,7 @@ import { cls } from "../utils/cls";
11
11
  * through TipTap's editorProps.attributes.
12
12
  */
13
13
  export function Editor({ size = "md", className, ...props }) {
14
- const surfaceClassName = useStyles(editorClass, proseHtml(size));
14
+ const surfaceClassName = useStyles(editorClass, proseHtml(size), proseRhythm(size, "& > .ProseMirror"));
15
15
  return (_jsx(EditorContent, { ...props, className: cls("maui-editor-prose", surfaceClassName, className) }));
16
16
  }
17
17
  const editorClass = style({
@@ -23,7 +23,7 @@ const editorClass = style({
23
23
  // proseHtml styles descendants; its block rhythm belongs on the document.
24
24
  display: "flex",
25
25
  flexDirection: "column",
26
- gap: "inherit",
26
+ gap: 0,
27
27
  },
28
28
  "& .ProseMirror p.is-editor-empty:first-child::before": {
29
29
  color: colors.gray[9],
@@ -24,9 +24,6 @@ const inputClass = style(inputText, focusRing("&:focus-visible", shadowVars.cont
24
24
  color: colors.gray[12],
25
25
  border: "none",
26
26
  background: backgroundColor.element,
27
- "&:hover:not(:disabled)": {
28
- background: backgroundColor.elementHover,
29
- },
30
27
  "&:disabled": {
31
28
  color: colors.gray[8],
32
29
  background: colors.gray[2],
@@ -228,9 +225,6 @@ const numberInputClass = style(text({ size: "sm", fontWeight: 400, color: "highC
228
225
  border: "none",
229
226
  outline: "none",
230
227
  background: "transparent",
231
- "&:hover:not(:disabled)": {
232
- background: backgroundColor.elementHover,
233
- },
234
228
  "&::placeholder": {
235
229
  fontStyle: "italic",
236
230
  color: colors.gray[8],
@@ -32,9 +32,12 @@ const item = style(spacing.padding({ x: 4, y: 2 }), text({ size: "sm", fontWeigh
32
32
  background: backgroundColor.elementHover,
33
33
  },
34
34
  "&[data-selected]": {
35
- color: colors.accent[9],
35
+ color: colors.accent[11],
36
36
  fontWeight: 500,
37
37
  },
38
+ "&[data-selected]:not([data-disabled]):is(:hover, [data-hovered], [data-focused])": {
39
+ color: colors.accent[12],
40
+ },
38
41
  "&[data-selected]::after": {
39
42
  content: '"✓"',
40
43
  position: "absolute",
@@ -28,7 +28,7 @@ const trigger = style(text({ size: "sm", fontWeight: 400, color: "highContrast"
28
28
  border: "none",
29
29
  textAlign: "left",
30
30
  background: backgroundColor.element,
31
- "&:hover, &[data-pressed]": {
31
+ "&[data-pressed]": {
32
32
  background: backgroundColor.elementHover,
33
33
  },
34
34
  "&[aria-expanded='true']": {
@@ -44,7 +44,7 @@ const thumb = style(focusRing("&[data-focus-visible]", shadowVars.control), {
44
44
  width: sliderSize.thumb,
45
45
  height: sliderSize.thumb,
46
46
  borderRadius: "100%",
47
- background: colors.gray[12],
47
+ background: "#ffffff",
48
48
  boxShadow: shadowVars.control,
49
49
  "&[data-dragging]": {
50
50
  background: colors.accent[11],
@@ -69,7 +69,7 @@ const row = style({
69
69
  "&[data-selection-mode][data-selected] td": {
70
70
  backgroundColor: colors.accent[3],
71
71
  },
72
- "&[data-disabled]": {
72
+ "&[data-disabled] td": {
73
73
  color: colors.gray[8],
74
74
  },
75
75
  });
@@ -105,11 +105,11 @@ const selectionCheck = style({
105
105
  height: "10px",
106
106
  paddingTop: "0.5px",
107
107
  paddingLeft: "0.5px",
108
- fill: colors.gray[3],
108
+ fill: "white",
109
109
  opacity: 0,
110
110
  transform: "scale(0)",
111
111
  });
112
- const selectionCheckbox = style(flex({ alignItems: "center", justifyContent: "center" }), focusRing(`&[data-focus-visible] ${selectionBox}`, shadowVars.control), {
112
+ const selectionCheckbox = style(flex({ alignItems: "center", justifyContent: "center" }), focusRing(`&[data-focus-visible] ${selectionBox}`, shadowVars.control), focusRing(`&[data-selected][data-focus-visible] ${selectionBox}, &[data-indeterminate][data-focus-visible] ${selectionBox}`, shadowVars.selectedControl), {
113
113
  position: "relative",
114
114
  width: "fit-content",
115
115
  cursor: "default",
@@ -129,6 +129,7 @@ const selectionCheckbox = style(flex({ alignItems: "center", justifyContent: "ce
129
129
  },
130
130
  [`&[data-selected] ${selectionBox}, &[data-indeterminate] ${selectionBox}`]: {
131
131
  backgroundColor: colors.accent[9],
132
+ boxShadow: shadowVars.selectedControl,
132
133
  },
133
134
  [`&[data-hovered][data-selected] ${selectionBox}, &[data-hovered][data-indeterminate] ${selectionBox}`]: {
134
135
  backgroundColor: surfaceWash(colors.gray[12], surfaceMixPercent.hover, colors.accent[9]),
@@ -13,7 +13,10 @@ export const navigationItem = style(spacing.padding({ x: 4, y: 2 }), text({ size
13
13
  background: backgroundColor.elementHover,
14
14
  },
15
15
  "&[aria-current='page']": {
16
- color: colors.accent[9],
16
+ color: colors.accent[11],
17
17
  fontWeight: 500,
18
18
  },
19
+ "&[aria-current='page']:hover": {
20
+ color: colors.accent[12],
21
+ },
19
22
  });
@@ -4,15 +4,15 @@
4
4
  * rhythm tuned for sustained reading. The `Prose` container picks a size and
5
5
  * typography components inside it opt into the matching prose treatment.
6
6
  *
7
- * Sizes and spacing are ported from the Tailwind Typography plugin
7
+ * Type sizes and heading spacing are ported from the Tailwind Typography plugin
8
8
  * (https://github.com/tailwindlabs/tailwindcss-typography): `sm` -> 14px base,
9
9
  * `md` -> Tailwind's `base` (16px), `lg` -> 18px. Tailwind's em-relative values
10
10
  * are resolved to px here so they compose with Maui's px-based tokens.
11
11
  *
12
12
  * On the Large platform scale (coarse pointers) block font sizes and line
13
13
  * heights step up ~1.2x — the same idea as the `text` tokens — so reading
14
- * content grows with the rest of the UI on touch devices. Block gaps tighten
15
- * separately (see `blockGaps`).
14
+ * content grows with the rest of the UI on touch devices. Paragraph gaps stay
15
+ * at 10px on both scales.
16
16
  */
17
17
  export type ProseSize = "sm" | "md" | "lg";
18
18
  export declare const prose: (size: ProseSize) => {
@@ -32,7 +32,7 @@ export declare const prose: (size: ProseSize) => {
32
32
  * `marginBottom` becomes the "hug" gap after a heading; heading `marginTop`
33
33
  * becomes the larger break before it.
34
34
  */
35
- export declare const proseRhythm: (size: ProseSize) => import("purse-styles").StyleElement;
35
+ export declare const proseRhythm: (size: ProseSize, selector?: unknown) => import("purse-styles").StyleElement;
36
36
  /**
37
37
  * Element styles + vertical rhythm for HTML rendered outside React typography
38
38
  * components (TipTap's ProseMirror tree, Streamdown markdown output).
@@ -10,7 +10,6 @@ const scaledPx = (normal, large) => defineVars({
10
10
  }).px;
11
11
  const metrics = {
12
12
  sm: {
13
- blockGap: 16,
14
13
  paragraph: { fontSize: scaledPx(14, 17), lineHeight: scaledPx(24, 29) },
15
14
  h1: {
16
15
  fontSize: scaledPx(30, 36),
@@ -36,11 +35,10 @@ const metrics = {
36
35
  marginTop: 20,
37
36
  marginBottom: 8,
38
37
  },
39
- listPadding: 22,
38
+ listPadding: 20,
40
39
  listItemGap: 4,
41
40
  },
42
41
  md: {
43
- blockGap: 20,
44
42
  paragraph: { fontSize: scaledPx(16, 19), lineHeight: scaledPx(28, 34) },
45
43
  h1: {
46
44
  fontSize: scaledPx(36, 43),
@@ -66,11 +64,10 @@ const metrics = {
66
64
  marginTop: 24,
67
65
  marginBottom: 8,
68
66
  },
69
- listPadding: 26,
67
+ listPadding: 24,
70
68
  listItemGap: 8,
71
69
  },
72
70
  lg: {
73
- blockGap: 24,
74
71
  paragraph: { fontSize: scaledPx(18, 22), lineHeight: scaledPx(32, 38) },
75
72
  h1: {
76
73
  fontSize: scaledPx(48, 58),
@@ -96,16 +93,11 @@ const metrics = {
96
93
  marginTop: 32,
97
94
  marginBottom: 8,
98
95
  },
99
- listPadding: 28,
96
+ listPadding: 26,
100
97
  listItemGap: 12,
101
98
  },
102
99
  };
103
- // Slightly tighter reading rhythm on the Large platform scale.
104
- const blockGaps = defineVars({
105
- sm: { default: `${metrics.sm.blockGap}px`, [LARGE_SCALE]: "12px" },
106
- md: { default: `${metrics.md.blockGap}px`, [LARGE_SCALE]: "16px" },
107
- lg: { default: `${metrics.lg.blockGap}px`, [LARGE_SCALE]: "20px" },
108
- });
100
+ const blockGap = "10px";
109
101
  // Heading weights follow Tailwind's descending scale, capped at Maui's
110
102
  // heaviest weight (700).
111
103
  const headingColor = colors.gray[12];
@@ -163,23 +155,23 @@ const notHeading = ":not(h1):not(h2):not(h3):not(h4)";
163
155
  * `marginBottom` becomes the "hug" gap after a heading; heading `marginTop`
164
156
  * becomes the larger break before it.
165
157
  */
166
- export const proseRhythm = memoize((size) => {
158
+ export const proseRhythm = memoize((size, selector = "&") => {
167
159
  const m = metrics[size];
168
160
  return style({
169
- "& > * + *": {
170
- marginTop: blockGaps[size],
161
+ [`${selector} > * + *`]: {
162
+ marginTop: blockGap,
171
163
  },
172
164
  // Gap after a heading: hug it to the content that follows.
173
- "& > h1 + *": { marginTop: px(m.h1.marginBottom) },
174
- "& > h2 + *": { marginTop: px(m.h2.marginBottom) },
175
- "& > h3 + *": { marginTop: px(m.h3.marginBottom) },
176
- "& > h4 + *": { marginTop: px(m.h4.marginBottom) },
165
+ [`${selector} > h1 + *`]: { marginTop: px(m.h1.marginBottom) },
166
+ [`${selector} > h2 + *`]: { marginTop: px(m.h2.marginBottom) },
167
+ [`${selector} > h3 + *`]: { marginTop: px(m.h3.marginBottom) },
168
+ [`${selector} > h4 + *`]: { marginTop: px(m.h4.marginBottom) },
177
169
  // Break before a heading (only when it opens a new section, i.e. it
178
170
  // isn't already hugging a preceding heading).
179
- [`& > ${notHeading} + h1`]: { marginTop: px(m.h1.marginTop) },
180
- [`& > ${notHeading} + h2`]: { marginTop: px(m.h2.marginTop) },
181
- [`& > ${notHeading} + h3`]: { marginTop: px(m.h3.marginTop) },
182
- [`& > ${notHeading} + h4`]: { marginTop: px(m.h4.marginTop) },
171
+ [`${selector} > ${notHeading} + h1`]: { marginTop: px(m.h1.marginTop) },
172
+ [`${selector} > ${notHeading} + h2`]: { marginTop: px(m.h2.marginTop) },
173
+ [`${selector} > ${notHeading} + h3`]: { marginTop: px(m.h3.marginTop) },
174
+ [`${selector} > ${notHeading} + h4`]: { marginTop: px(m.h4.marginTop) },
183
175
  });
184
176
  });
185
177
  /**
@@ -194,7 +186,7 @@ export const proseHtml = memoize((size) => {
194
186
  return style({
195
187
  display: "flex",
196
188
  flexDirection: "column",
197
- gap: blockGaps[size],
189
+ gap: blockGap,
198
190
  "& p": {
199
191
  ...block(m.paragraph),
200
192
  fontWeight: 400,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanishqkancharla/maui",
3
- "version": "0.0.67",
3
+ "version": "0.0.72",
4
4
  "description": "Maui design system",
5
5
  "type": "module",
6
6
  "main": "./dist/maui.js",
@@ -10,6 +10,7 @@ Styled React Aria `Button` (default `type="button"`). Consumes `ButtonContext`,
10
10
  <Button>Save</Button>
11
11
  <Button variant="quiet">Cancel</Button>
12
12
  <Button variant="quiet" variantColor="accent">Quiet accent</Button>
13
+ <Button variant="text">Text action</Button>
13
14
  <Button variant="primary" variantColor="blue">Create</Button>
14
15
  <Button variant="primary" variantColor="#6366f1">Indigo</Button>
15
16
  <Button size="sm">Compact</Button>
@@ -25,17 +26,19 @@ Styled React Aria `Button` (default `type="button"`). Consumes `ButtonContext`,
25
26
  | Prop | Notes |
26
27
  | --- | --- |
27
28
  | `size` | `"default"` (28px) \| `"sm"` (24px with 10px text and 14px icons). Large/touch scale keeps both at a 40px target |
28
- | `variant` | `"default"` (raised element) \| `"quiet"` (no shadow, transparent) \| `"primary"` (solid fill) |
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
+ | `variant` | `"default"` (raised element) \| `"quiet"` (transparent, hover wash) \| `"text"` (transparent, color-only hover) \| `"primary"` (solid fill) |
30
+ | `variantColor` | Palette name or opaque `#hex` / `rgb(...)`. Primary defaults to `"accent"`; quiet and text default to gray and tint the label/icon when set |
30
31
  | `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
+ | `isPending` | Shows a current-color indicator beside visible content. Blocks activation and suppresses hover fill while retaining focusability and variant color. Controlled by the caller, not by returned promises |
32
33
  | `pendingLabel` | Optional visible string replacing children while pending (e.g. `"Saving…"`). Omit to keep children; provide it to replace an icon with progress text |
33
34
  | `onClick` | Still supported. React Aria also exposes `onPress` |
34
35
  | `children` | Text is wrapped for cap-height trim; SVG icons sit beside text. Icon-only needs `aria-label` |
35
36
 
36
- 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.
37
+ Use primary for an emphasized action, default for a raised neutral control, quiet for a low-emphasis action, and text for an inline-style action without a hover fill. Let Button manage its palette and state colors rather than rebuilding the fill or shadow. For exact treatment, inspect `src/components/Button.tsx` in the installed package as described in [design conventions](../design_conventions.md).
37
38
 
38
- 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).
39
+ Prefer plain labels (optionally alongside icons) so Button controls their foreground; see the [composition rule](../design_conventions.md#composition).
40
+
41
+ The text variant removes horizontal spacing and surface chrome while keeping the control height and focus ring. Use it when an action should align directly with surrounding text; hover changes the foreground, not the fill.
39
42
 
40
43
  `useButton(props)` is exported for custom focus-tracked buttons; prefer `Button`.
41
44
 
@@ -64,7 +64,7 @@ Controlled: `selected` + `onChange`. **`label` is required.**
64
64
 
65
65
  **TextField** — `textFieldStyles`
66
66
  - `root` — field wrapper around the input and error.
67
- - `input({ size, keyboardHint })` — RAC `Input`; `:hover`, `:disabled`, `aria-invalid`, `:focus-visible`, `::placeholder`.
67
+ - `input({ size, keyboardHint })` — RAC `Input`; `:disabled`, `aria-invalid`, `:focus-visible`, `::placeholder`.
68
68
  - `frame` — relative wrapper around the input when a keyboard hint is shown.
69
69
  - `keyboardHint` — Maui `Kbd variant="quiet"` inside `frame`.
70
70
  - `error` — RAC `FieldError`.
@@ -76,7 +76,7 @@ Controlled: `selected` + `onChange`. **`label` is required.**
76
76
 
77
77
  **NumberField** — `numberFieldStyles`
78
78
  - `root` — RAC `Group`; `:has(:focus-visible)`, `:has(input:disabled)`, `:has(input[aria-invalid='true'])`; styles the stepper `button`s inside a `.number-stepper` wrapper, so it needs that markup.
79
- - `input` — RAC `Input`; `:hover`, `:disabled`, `::placeholder`.
79
+ - `input` — RAC `Input`; `:disabled`, `::placeholder`.
80
80
 
81
81
  **QuietTextField** — `quietTextFieldStyles`
82
82
  - `input({ keyboardHint })` — RAC `Input`; `:hover`, `:disabled`, `aria-invalid`, `:focus-visible`, `::placeholder`.
@@ -23,7 +23,7 @@ Exactly two children on `MenuTrigger`: **trigger**, then **menu**. Use Maui `But
23
23
  </MenuTrigger>
24
24
  ```
25
25
 
26
- `isDisabled` on the trigger button blocks open. Escape closes and focus returns to the trigger (`aria-expanded` tracks open state). While open, the Maui `Button` keeps its active fill from RAC `data-pressed` / `aria-expanded`.
26
+ `isDisabled` on the trigger button blocks open. Escape closes and focus returns to the trigger (`aria-expanded` tracks open state).
27
27
 
28
28
  `placement` defaults to `"bottom start"`. Popover uses `shadow.strong` via `CollectionPopover`.
29
29
 
@@ -1,6 +1,6 @@
1
1
  # Maui design conventions
2
2
 
3
- Prefer the Maui skill for composition knowledge: how to assemble tokens, components, patterns, and apps. Read every matching reference linked from `SKILL.md` before designing or implementing UI, and reuse its structure, tokens, and interactions. Reach for package source under `"maui/src/patterns"`, `"maui/src/apps"`, or an installed `node_modules` package only when the references do not cover a needed detail.
3
+ Use the matching skill references for Maui's design choices, composition rules, and reusable token patterns. Inspect the installed package's source for tactical questions about props, defaults, state styling, or exact values. Source ships under `node_modules/maui/src/` with the documented npm alias, or `node_modules/@tanishqkancharla/maui/src/` without it. Follow components into their token definitions as needed; current implementation is not a guarantee of contrast or design intent.
4
4
 
5
5
  Patterns, demo apps, and the gallery `Panel` preview frame are not package components. `Panel` is not on the `"maui"` barrel, in `src/components`, or in the JSX editor catalog. Rebuild reference recipes with barrel exports such as `Button`, `Flex`, and `text(...)`.
6
6
 
@@ -24,32 +24,73 @@ State styles key off React Aria attributes (`data-hovered`, `data-selected`, `da
24
24
 
25
25
  ## Composition
26
26
 
27
- - Use Maui's `Text` component instead of raw `<span>` elements for inline text. Set typography through its `size`, `fontWeight`, `color`, `monospace`, and `tabular` props; see [Text](components/text.md). Keep semantic elements such as headings, paragraphs, labels, and links for their respective roles.
27
+ - Use Maui's `Text` component for standalone inline typography; see [Text](components/text.md). `Text` and `text(...)` set an explicit color by default, overriding inherited variant, selected, or disabled colors. Prefer plain Button labels so the Button controls its foreground. Keep semantic elements such as headings, paragraphs, labels, and links for their respective roles.
28
28
  - Hover backgrounds have no transitions. Hover fills such as `backgroundColor.elementHover`, quiet-button washes, and list or row highlights snap instantly. Do not animate `background` or `background-color` on hover with `motion.standard(...)` or a CSS `transition`. Other motion, including tooltips and transforms, is allowed.
29
29
  - Simple apps default to a prose column: `style(proseContainerStyle, { width: "100%", marginInline: "auto" })`. `sizing.contentWidth` is that same style. Use it for single-column tools, settings, forms, and reading layouts. Multi-pane or full-bleed apps such as inboxes, calendars, and IDEs are exceptions.
30
30
  - Design an intentional empty composition for every list, inbox, search result, or collection. Include copy and, when useful, an action. Do not leave a blank panel.
31
31
 
32
+ ## Decorative edge strips
33
+
34
+ When a rounded surface uses a colored edge for emphasis, use a full-height rectangular strip inside a `position: relative; overflow: hidden` container. Keep the radius on the container, not the strip: a CSS left border follows the corners and curls inward. This is a composition recipe for custom UI, not a new component or a change to existing component borders. Keep using `border()` for outlines and dividers.
35
+
36
+ ```tsx
37
+ const surface = style(radius.md, spacing.padding({ all: 6 }), {
38
+ position: "relative",
39
+ overflow: "hidden",
40
+ backgroundColor: colors.orange[3],
41
+ })
42
+ const strip = style({
43
+ position: "absolute",
44
+ insetBlock: 0,
45
+ left: 0,
46
+ width: "3px",
47
+ backgroundColor: colors.orange[9],
48
+ pointerEvents: "none",
49
+ })
50
+
51
+ function DecoratedSurface() {
52
+ const surfaceClass = useStyles(surface)
53
+ const stripClass = useStyles(strip)
54
+ return (
55
+ <div className={surfaceClass}>
56
+ <div aria-hidden="true" className={stripClass} />
57
+ <Text>Surface content</Text>
58
+ </div>
59
+ )
60
+ }
61
+ ```
62
+
32
63
  ## Shadows
33
64
 
34
- Use `shadow.control` for interactive control surfaces. It combines two inset
35
- highlights with a compact outer edge. Use the three-level elevation scale for
36
- other raised surfaces:
65
+ Use `shadow.control` for interactive control surfaces. Use the three-level elevation scale for other raised surfaces:
37
66
 
38
67
  - `shadow.subtle` — cards and ordinary raised surfaces
39
- - `shadow.medium` — tooltips and larger floating panels
68
+ - `shadow.medium` — larger floating panels
40
69
  - `shadow.strong` — dropdowns, popovers, and dominant overlays
41
70
 
42
- All three include a 1px ring. Do not also apply `border()`, `borderColor.outline`, or another ring to the same element.
71
+ These shadows include their own edge or ring. Do not also apply `border()`, `borderColor.outline`, or another ring to the same element. Not every floating component uses elevation: reuse its exported styles rather than inferring a shadow from its placement.
43
72
 
44
73
  Buttons and form-control surfaces use `shadow.control` by default. For compound controls, apply it once to the outer control boundary instead of to every internal button or segment.
45
74
 
46
75
  ## Focus
47
76
 
48
- `focusRing()` applies a theme-aware Radix blue shadow with a `blueAlpha[8]` hard edge and `blueAlpha[5]` glow. Do not add a competing outline or box shadow for keyboard focus.
77
+ Use `focusRing()` for Maui's theme-aware keyboard-focus treatment rather than adding a competing outline or box shadow.
78
+
79
+ **Control safe area.** Input and form-control shadows paint outside the component's layout box; reserve room for the resting, hovered, and keyboard-focused states, even when hover does not change the background. Use `spacing.padding(4)` as a starting inset between controls and clipping edges, and leave gaps between neighboring controls. Keep overflow visible on immediate wrappers; when a panel, rounded container, or scroll viewport must clip, put the safe inset inside that clipping boundary, not outside it as margin. Check the first and last controls at scroll edges, including checkbox, radio, switch, and slider focus states. Do not remove shadows or focus rings to fit a tight layout.
80
+
81
+ ```tsx
82
+ // Keep the control's visual overflow inside the scrolling boundary.
83
+ <div style={{ overflow: "auto" }}>
84
+ <Flex column padding={4} gap={4}>
85
+ <TextField aria-label="Name" />
86
+ <Select aria-label="Plan">{/* options */}</Select>
87
+ </Flex>
88
+ </div>
89
+ ```
49
90
 
50
91
  ## Layout utilities
51
92
 
52
- `Flex` and `Gap` take spacing scale steps (`1 | 2 | 3 | 4 | 6 | 8 | 12 | 16`), not raw pixels. For example, `<Flex row gap={4} padding={4}>` has a 9px gap and 9px inset.
93
+ `Flex` and `Gap` take spacing scale steps, not raw pixels. For example, `<Flex row gap={4} padding={4}>` uses the same token for its gap and inset.
53
94
 
54
95
  Put padding on `Flex` with `padding` or `p`, `px`, `py`, `pt`, `pr`, `pb`, and `pl`; there is no `Padding` component. `alignItems` and `justifyContent` use the same tokens as `flex()` (`start`, `center`, `end`, and others; `justifyContent` also supports `between`, `around`, and `evenly`). `background` is a surface token such as `app`, `element`, or `accent`. `Spacer` grows to fill leftover flex space. `Divider` is a horizontal rule.
55
96
 
@@ -56,12 +56,14 @@ function Row({ children }: { children: React.ReactNode }) {
56
56
 
57
57
  `colors` is Radix Scales with a brand `accent` (teal in light, violet in dark). Each scale has steps **1–12**. Alpha scales are `colors.grayAlpha`, `colors.accentAlpha`, `colors.blueAlpha`, …
58
58
 
59
+ The scale roles below are a starting point, not a contrast guarantee or a state recipe for every component. Use text roles for labels rather than copying a solid fill's step. Check the actual foreground/background pair in light and dark themes, including selected and disabled states; preserve non-color cues for state and status.
60
+
59
61
  | Steps | Use |
60
62
  | --- | --- |
61
63
  | 1–2 | App / subtle surfaces |
62
64
  | 3–5 | Interactive washes |
63
65
  | 6–8 | Borders / strong lines |
64
- | 9–10 | Solid fills (9 rest, 10 hover) |
66
+ | 9–10 | Solid fills |
65
67
  | 11–12 | Text (11 secondary, 12 primary) |
66
68
 
67
69
  ```ts
@@ -74,6 +76,8 @@ colors.blueAlpha[8]
74
76
 
75
77
  `colorNames` is `"accent"` plus every Radix palette name. `paletteNames` omits `"accent"`. `"blue"` as a `variantColor` is the Radix blue scale, not CSS `blue`.
76
78
 
79
+ Brand accent, semantic status, and decorative identity serve different roles. Prefer a component's variant or tone before assembling custom colors; follow the matching component's source when composing similar UI. For example, [Banner](components/banner.md) owns status tones, while [Avatar](components/avatar.md) uses paired decorative colors, not status meanings.
80
+
77
81
  Prefer semantic surfaces over raw scale steps for chrome:
78
82
 
79
83
  ```ts
@@ -81,14 +85,14 @@ import { background, backgroundColor } from "maui"
81
85
 
82
86
  backgroundColor.app // page
83
87
  backgroundColor.element // raised control / card
84
- backgroundColor.elementHover // 3.5% gray-12 wash over element
85
- backgroundColor.elementActive // 7% wash
88
+ backgroundColor.elementHover // hovered element surface
89
+ backgroundColor.elementActive // active element surface
86
90
  background.app // style objects of the same values
87
91
  background.element
88
92
  background.accent // accent[9]
89
93
  ```
90
94
 
91
- Hover/active washes use `color-mix` in oklch (`surfaceWash`, transparent by default). **Do not transition `background` / `background-color` on hover.** Quiet `Button` uses `surfaceMixPercent` (6% light / 9% dark, press 2×) with Alpha[9]. Raised `elementHover` is still 3.5% gray-12 over the element surface.
95
+ Alpha washes depend on their backdrop. A quiet control's transparent overlay is not interchangeable with `elementHover`, which is precomposed over the element surface. Reuse the matching component styles rather than recreating its wash. Hover fills snap instantly; see [design conventions](design_conventions.md#composition).
92
96
 
93
97
  ## Text
94
98
 
@@ -115,7 +119,7 @@ text({
115
119
 
116
120
  Sizes step up on the **Large platform scale** (`:root[data-scale="large"]`, driven by `(pointer: coarse)` under the system preference): `md` is 14px/22px on desktop and 17px/26px on coarse pointers. See `useScale` / `ScaleProvider`.
117
121
 
118
- - `lowContrast` → `gray[11]`, `highContrast` → `gray[12]`, `accent` → `accent[11]`, `onAccent` → white.
122
+ - `lowContrast` → `gray[11]`, `highContrast` → `gray[12]`, `accent` → `accent[11]`, `onAccent` → white. `onAccent` is literal white, not an adaptive foreground for every solid fill.
119
123
  - UI sans: `fontFamily` (system ui-sans-serif stack). Mono: Commit Mono with `ss05` smart kerning (`monoFontStyle` / `monospace`).
120
124
  - `tabular: true` → `font-variant-numeric: tabular-nums`. NumberField, TableHead, and TableCell turn this on by default. Body copy and Prose stay off.
121
125
  - `baseTextStyle` is md / 400 / highContrast — already on `html, body`.
@@ -178,13 +182,12 @@ shadowVars.control | .subtle | .medium | .strong // raw box-shadow strings
178
182
  tintedSubtle(color) // subtle ring+blur tinted from a fill
179
183
  ```
180
184
 
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
- already includes a 1px ring. **Do not also `border()` the same element.**
185
+ For elevation choice, compound control boundaries, and avoiding doubled rings, follow the [shadow conventions](design_conventions.md#shadows).
183
186
 
184
187
  `border(sides, color)`:
185
188
 
186
189
  - `sides`: `[]` for all sides, or `"top" | "right" | "bottom" | "left"`
187
- - `color`: `"border"` (gray-12 @ 5%) | `"outline"` (10%) | `"accent"` (`accent[8]`)
190
+ - `color`: `"border"` (subtle separator) | `"outline"` (stronger edge) | `"accent"` (accent edge)
188
191
 
189
192
  `borderColor.border` / `borderColor.outline` are the raw color strings (hairlines, `borderBottom`).
190
193
 
@@ -195,20 +198,14 @@ already includes a 1px ring. **Do not also `border()` the same element.**
195
198
  ## Motion
196
199
 
197
200
  ```ts
198
- motionDurationMs // 80
199
- motionStreamDurationMs // 80 — Streamdown word fade
200
- motionEasing // "ease-in-out"
201
- motion.standard("opacity", "transform") // 80ms ease-in-out on those properties
202
- overlayMotion // shared tooltip, dialog, backdrop, and reduced timings
201
+ motion.standard("opacity", "transform") // shared transition style
202
+ motionDurationMs // standard duration
203
+ motionStreamDurationMs // streaming text duration
204
+ motionEasing // standard easing
205
+ overlayMotion // overlay and reduced-motion presets
203
206
  ```
204
207
 
205
- Use `motion.standard` for opacity, transform, box-shadow, color. **Never** for hover `background` / `background-color`.
206
-
207
- `overlayMotion` defines tooltip enter (120ms entrance curve), tooltip exit
208
- (80ms ease-in), dialog enter (200ms entrance curve), dialog exit (120ms
209
- ease-in), backdrop (100ms), and opacity-only reduced motion (80ms). Crossfade
210
- enter is a 0.3s spring (`bounce` 0.2); exit uses `motionDurationMs` /
211
- ease-in-out. Streaming markdown uses `motionStreamDurationMs`.
208
+ Use `motion.standard` for opacity, transform, box-shadow, and color, not hover fills; see [design conventions](design_conventions.md#composition). Reuse `overlayMotion` for custom overlay enter/exit and reduced-motion handling; inspect the matching component for how it applies those presets.
212
209
 
213
210
  ## Sizing and icons
214
211
 
@@ -229,7 +226,7 @@ Stroke/fill is `currentColor`. Pass `size` on the icon component; optional `widt
229
226
 
230
227
  ## Avatar tokens
231
228
 
232
- `avatar.green` / `avatar.orange` / `avatar.pink` each have `background` and `foreground`. The `Avatar` component also uses `accent[4]`/`[11]`. You rarely need these tokens directly.
229
+ `avatar.green` / `avatar.orange` / `avatar.pink` each provide a paired `background` and `foreground`; keep the pair together when using these decorative tokens. Prefer `Avatar` for identity UI rather than choosing its colors manually.
233
230
 
234
231
  ## Prose tokens
235
232
 
@@ -1463,7 +1463,7 @@ const miniDayClass = style(
1463
1463
  text({ size: "xs", fontWeight: 400, color: "highContrast" }),
1464
1464
  radius.circle,
1465
1465
  focusRing(),
1466
- motion.standard("background-color", "color"),
1466
+ motion.standard("color"),
1467
1467
  {
1468
1468
  display: "grid",
1469
1469
  placeItems: "center",
@@ -264,11 +264,11 @@ export const catalog: CatalogComponent[] = [
264
264
  },
265
265
  {
266
266
  name: "Button",
267
- info: "Button. default is raised, quiet is borderless, primary fills with accent 9. Works as a MenuTrigger child.",
267
+ info: "Button. default is raised, quiet adds a hover wash, text changes only color on hover, primary fills with accent 9. Works as a MenuTrigger child.",
268
268
  html: true,
269
269
  attributes: [
270
270
  { name: "size", values: ["default", "sm"] },
271
- { name: "variant", values: ["default", "quiet", "primary"] },
271
+ { name: "variant", values: ["default", "quiet", "primary", "text"] },
272
272
  {
273
273
  name: "variantColor",
274
274
  info: "Palette name (colors[name], primary step 9) or an opaque hex / rgb() fill. Quiet stays transparent; color tints the label. Hover mixes Alpha[9] at 6%/9%.",
@@ -23,6 +23,13 @@ describe("collectJsxDiagnosticsFromSource", () => {
23
23
  expect(diagnostics).toEqual([])
24
24
  })
25
25
 
26
+ test("accepts a text Button", () => {
27
+ const diagnostics = collectJsxDiagnosticsFromSource(
28
+ `<Button variant="text" variantColor="blue">Connect</Button>`,
29
+ )
30
+ expect(diagnostics).toEqual([])
31
+ })
32
+
26
33
  test("accepts a small Button", () => {
27
34
  const diagnostics = collectJsxDiagnosticsFromSource(
28
35
  `<Button size="sm">Compact</Button>`,
@@ -54,7 +61,7 @@ describe("collectJsxDiagnosticsFromSource", () => {
54
61
  )
55
62
  expect(diagnostics).toHaveLength(1)
56
63
  expect(diagnostics[0]?.message).toBe(
57
- `Type '"solid"' is not assignable to type '"default" | "quiet" | "primary"'.`,
64
+ `Type '"solid"' is not assignable to type '"default" | "quiet" | "primary" | "text"'.`,
58
65
  )
59
66
  })
60
67
 
@@ -66,9 +73,9 @@ describe("collectJsxDiagnosticsFromSource", () => {
66
73
  </Flex>`,
67
74
  )
68
75
  expect(formatTypeErrorBanners(diagnostics)).toEqual([
69
- `Line 2: Type '"solid"' is not assignable to type '"default" | "quiet" | "primary"'.`,
76
+ `Line 2: Type '"solid"' is not assignable to type '"default" | "quiet" | "primary" | "text"'.`,
70
77
  `Line 2: Property 'disabled' does not exist on Button.`,
71
- `Line 3: Type '"ghost"' is not assignable to type '"default" | "quiet" | "primary"'.`,
78
+ `Line 3: Type '"ghost"' is not assignable to type '"default" | "quiet" | "primary" | "text"'.`,
72
79
  ])
73
80
  })
74
81
 
@@ -30,7 +30,7 @@ import { spacing } from "../tokens/spacing"
30
30
  import { text } from "../tokens/text"
31
31
  import { memoize } from "../utils/memoize"
32
32
 
33
- export type ButtonVariant = "default" | "quiet" | "primary"
33
+ export type ButtonVariant = "default" | "quiet" | "primary" | "text"
34
34
  export type ButtonSize = "default" | "sm"
35
35
 
36
36
  const buttonShadow = shadowVars.control
@@ -173,18 +173,27 @@ const buttonClass = style(buttonBaseClass, {
173
173
  },
174
174
  })
175
175
 
176
+ const textButtonSpacingClass = style({
177
+ "&, &:has(> svg), &:has([data-button-content] > svg), &:has(> svg:first-child:not(:only-child)), &:has([data-button-content] > svg:first-child:not(:only-child)), &:has(> svg:last-child:not(:only-child)), &:has([data-button-content] > svg:last-child:not(:only-child)), &:has(> svg:only-child), &:has([data-button-content] > svg:only-child), &[data-pending]":
178
+ {
179
+ padding: 0,
180
+ },
181
+ })
182
+
176
183
  function paletteAlpha(name: ColorName): ColorScale {
177
184
  return colors[`${name}Alpha`]
178
185
  }
179
186
 
180
- const quietClass = memoize((color: string, hoverColor: string, wash: string) =>
187
+ const quietClass = memoize((color: string, hoverColor: string, wash?: string) =>
181
188
  style(buttonBaseClass, focusRing("&:focus-visible"), {
182
189
  color,
183
190
  backgroundColor: "transparent",
184
191
  boxShadow: "none",
185
192
  "&:hover:not(:disabled):not([data-pending])": {
186
193
  color: hoverColor,
187
- backgroundColor: surfaceWash(wash, surfaceMixPercent.hover),
194
+ backgroundColor: wash
195
+ ? surfaceWash(wash, surfaceMixPercent.hover)
196
+ : "transparent",
188
197
  },
189
198
  "&:disabled": disabledQuiet,
190
199
  }),
@@ -275,7 +284,7 @@ const primaryClass = memoize(
275
284
  )
276
285
 
277
286
  const coloredButtonClass = memoize(
278
- (variant: "primary" | "quiet", color: ButtonVariantColor) => {
287
+ (variant: "primary" | "quiet" | "text", color: ButtonVariantColor) => {
279
288
  if (isCssColor(color)) {
280
289
  const fill = opaqueColor(color)
281
290
  if (variant === "primary") {
@@ -283,19 +292,27 @@ const coloredButtonClass = memoize(
283
292
  return primaryClass(fill, onSolidText(fill), fillShade(fill, 0, 0.35))
284
293
  }
285
294
 
286
- return quietClass(fill, darkerFill(fill), fill)
295
+ return quietClass(
296
+ fill,
297
+ darkerFill(fill),
298
+ variant === "quiet" ? fill : undefined,
299
+ )
287
300
  }
288
301
 
289
302
  const scale = paletteFill(color)
290
303
  if (variant === "primary") {
291
304
  return primaryClass(
292
305
  scale[9],
293
- darkTextOnSolid.has(color) ? scale[12] : "white",
306
+ darkTextOnSolid.has(color) ? onSolidText(scale[9]) : "white",
294
307
  paletteAlpha(color)[6],
295
308
  )
296
309
  }
297
310
 
298
- return quietClass(scale[11], scale[12], paletteAlpha(color)[9])
311
+ return quietClass(
312
+ scale[11],
313
+ scale[12],
314
+ variant === "quiet" ? paletteAlpha(color)[9] : undefined,
315
+ )
299
316
  },
300
317
  )
301
318
 
@@ -365,6 +382,7 @@ export const buttonStyles = {
365
382
  style(
366
383
  buttonVariantClass(variant, variantColor),
367
384
  size === "sm" && smallButtonClass,
385
+ variant === "text" && textButtonSpacingClass,
368
386
  ),
369
387
  ),
370
388
  label: buttonTextClass,
@@ -510,6 +528,9 @@ function buttonVariantClass(
510
528
  if (variant === "primary") {
511
529
  return coloredButtonClass("primary", variantColor ?? "accent")
512
530
  }
531
+ if (variant === "text") {
532
+ return coloredButtonClass("text", variantColor ?? "gray")
533
+ }
513
534
  if (variant === "quiet" && variantColor) {
514
535
  return coloredButtonClass("quiet", variantColor)
515
536
  }
@@ -1,7 +1,7 @@
1
1
  import { EditorContent, type EditorContentProps } from "@tiptap/react"
2
2
  import { style, useStyles } from "purse-styles"
3
3
  import { colors } from "../tokens/colors"
4
- import { proseHtml, type ProseSize } from "../tokens/prose"
4
+ import { proseHtml, proseRhythm, type ProseSize } from "../tokens/prose"
5
5
  import { cls } from "../utils/cls"
6
6
 
7
7
  export type EditorProps = Omit<EditorContentProps, "size"> & {
@@ -15,7 +15,11 @@ export type EditorProps = Omit<EditorContentProps, "size"> & {
15
15
  * through TipTap's editorProps.attributes.
16
16
  */
17
17
  export function Editor({ size = "md", className, ...props }: EditorProps) {
18
- const surfaceClassName = useStyles(editorClass, proseHtml(size))
18
+ const surfaceClassName = useStyles(
19
+ editorClass,
20
+ proseHtml(size),
21
+ proseRhythm(size, "& > .ProseMirror"),
22
+ )
19
23
  return (
20
24
  <EditorContent
21
25
  {...props}
@@ -33,7 +37,7 @@ const editorClass = style({
33
37
  // proseHtml styles descendants; its block rhythm belongs on the document.
34
38
  display: "flex",
35
39
  flexDirection: "column",
36
- gap: "inherit",
40
+ gap: 0,
37
41
  },
38
42
  "& .ProseMirror p.is-editor-empty:first-child::before": {
39
43
  color: colors.gray[9],
@@ -46,9 +46,6 @@ const inputClass = style(
46
46
  color: colors.gray[12],
47
47
  border: "none",
48
48
  background: backgroundColor.element,
49
- "&:hover:not(:disabled)": {
50
- background: backgroundColor.elementHover,
51
- },
52
49
  "&:disabled": {
53
50
  color: colors.gray[8],
54
51
  background: colors.gray[2],
@@ -376,9 +373,6 @@ const numberInputClass = style(
376
373
  border: "none",
377
374
  outline: "none",
378
375
  background: "transparent",
379
- "&:hover:not(:disabled)": {
380
- background: backgroundColor.elementHover,
381
- },
382
376
  "&::placeholder": {
383
377
  fontStyle: "italic",
384
378
  color: colors.gray[8],
@@ -41,9 +41,12 @@ const item = style(
41
41
  background: backgroundColor.elementHover,
42
42
  },
43
43
  "&[data-selected]": {
44
- color: colors.accent[9],
44
+ color: colors.accent[11],
45
45
  fontWeight: 500,
46
46
  },
47
+ "&[data-selected]:not([data-disabled]):is(:hover, [data-hovered], [data-focused])": {
48
+ color: colors.accent[12],
49
+ },
47
50
  "&[data-selected]::after": {
48
51
  content: '"✓"',
49
52
  position: "absolute",
@@ -46,7 +46,7 @@ const trigger = style(
46
46
  border: "none",
47
47
  textAlign: "left",
48
48
  background: backgroundColor.element,
49
- "&:hover, &[data-pressed]": {
49
+ "&[data-pressed]": {
50
50
  background: backgroundColor.elementHover,
51
51
  },
52
52
  "&[aria-expanded='true']": {
@@ -63,7 +63,7 @@ const thumb = style(focusRing("&[data-focus-visible]", shadowVars.control), {
63
63
  width: sliderSize.thumb,
64
64
  height: sliderSize.thumb,
65
65
  borderRadius: "100%",
66
- background: colors.gray[12],
66
+ background: "#ffffff",
67
67
  boxShadow: shadowVars.control,
68
68
  "&[data-dragging]": {
69
69
  background: colors.accent[11],
@@ -111,7 +111,7 @@ const row = style({
111
111
  "&[data-selection-mode][data-selected] td": {
112
112
  backgroundColor: colors.accent[3],
113
113
  },
114
- "&[data-disabled]": {
114
+ "&[data-disabled] td": {
115
115
  color: colors.gray[8],
116
116
  },
117
117
  })
@@ -172,7 +172,7 @@ const selectionCheck = style({
172
172
  height: "10px",
173
173
  paddingTop: "0.5px",
174
174
  paddingLeft: "0.5px",
175
- fill: colors.gray[3],
175
+ fill: "white",
176
176
  opacity: 0,
177
177
  transform: "scale(0)",
178
178
  })
@@ -180,6 +180,10 @@ const selectionCheck = style({
180
180
  const selectionCheckbox = style(
181
181
  flex({ alignItems: "center", justifyContent: "center" }),
182
182
  focusRing(`&[data-focus-visible] ${selectionBox}`, shadowVars.control),
183
+ focusRing(
184
+ `&[data-selected][data-focus-visible] ${selectionBox}, &[data-indeterminate][data-focus-visible] ${selectionBox}`,
185
+ shadowVars.selectedControl,
186
+ ),
183
187
  {
184
188
  position: "relative",
185
189
  width: "fit-content",
@@ -201,6 +205,7 @@ const selectionCheckbox = style(
201
205
  [`&[data-selected] ${selectionBox}, &[data-indeterminate] ${selectionBox}`]:
202
206
  {
203
207
  backgroundColor: colors.accent[9],
208
+ boxShadow: shadowVars.selectedControl,
204
209
  },
205
210
  [`&[data-hovered][data-selected] ${selectionBox}, &[data-hovered][data-indeterminate] ${selectionBox}`]:
206
211
  {
@@ -18,8 +18,11 @@ export const navigationItem = style(
18
18
  background: backgroundColor.elementHover,
19
19
  },
20
20
  "&[aria-current='page']": {
21
- color: colors.accent[9],
21
+ color: colors.accent[11],
22
22
  fontWeight: 500,
23
23
  },
24
+ "&[aria-current='page']:hover": {
25
+ color: colors.accent[12],
26
+ },
24
27
  },
25
28
  )
@@ -36,11 +36,11 @@ const borderExamples = [
36
36
  use: "Pane headers and horizontal section breaks.",
37
37
  },
38
38
  {
39
- label: 'border(["left"], "accent") + borderLeftWidth',
39
+ label: "Clipped left accent",
40
40
  sides: ["left"] as BorderSide[],
41
41
  color: "accent" as const,
42
42
  edgeWidth: "2px",
43
- use: "Blockquotes and left-edge emphasis bars.",
43
+ use: "Decorative emphasis on rounded surfaces.",
44
44
  },
45
45
  ] as const
46
46
 
@@ -119,17 +119,46 @@ export function BordersTokenPage() {
119
119
  </TableBody>
120
120
  </Table>
121
121
 
122
- <CodeBlock lang="typescript">{`const panel = style(
122
+ <CodeBlock lang="tsx">{`const panel = style(
123
123
  background.element,
124
124
  border([], "outline"),
125
125
  radius.md,
126
126
  )
127
127
 
128
- const blockquote = style(
129
- border(["left"], "accent"),
130
- { borderLeftWidth: "2px" },
131
- spacing.padding({ left: 12 }),
132
- )`}</CodeBlock>
128
+ const accentedSurface = style(
129
+ radius.md,
130
+ {
131
+ position: "relative",
132
+ overflow: "hidden",
133
+ padding: "16px",
134
+ backgroundColor: colors.gray[2],
135
+ },
136
+ )
137
+ const strip = style({
138
+ position: "absolute",
139
+ insetBlock: 0,
140
+ left: 0,
141
+ width: "2px",
142
+ backgroundColor: colors.accent[8],
143
+ pointerEvents: "none",
144
+ })
145
+
146
+ function DecoratedSurface() {
147
+ const surfaceClass = useStyles(accentedSurface)
148
+ const stripClass = useStyles(strip)
149
+ return (
150
+ <div className={surfaceClass}>
151
+ <div aria-hidden="true" className={stripClass} />
152
+ <Text>Surface content</Text>
153
+ </div>
154
+ )
155
+ }`}</CodeBlock>
156
+ <P>
157
+ For left-edge emphasis, clip a full-height rectangular accent inside
158
+ the rounded container instead of using a left border. The container
159
+ keeps its rounding without curling the accent inward. This is a
160
+ composition recipe, not a change to existing component borders.
161
+ </P>
133
162
 
134
163
  <Panel style={{ marginTop: "16px" }}>
135
164
  <div
@@ -166,20 +195,13 @@ function BorderExample(props: {
166
195
  color: BorderColor
167
196
  edgeWidth?: string
168
197
  }) {
198
+ const leftAccent = props.sides.length === 1 && props.sides[0] === "left"
169
199
  const className = useStyles(
170
- border(props.sides, props.color),
171
- props.edgeWidth
172
- ? style(
173
- Object.fromEntries(
174
- props.sides.map((side) => [
175
- `border${capitalize(side)}Width`,
176
- props.edgeWidth,
177
- ]),
178
- ),
179
- )
180
- : undefined,
200
+ !leftAccent && border(props.sides, props.color),
181
201
  radius.md,
182
202
  style({
203
+ position: "relative",
204
+ overflow: "hidden",
183
205
  background: colors.gray[2],
184
206
  padding: "16px",
185
207
  textAlign: "center",
@@ -190,15 +212,26 @@ function BorderExample(props: {
190
212
 
191
213
  return (
192
214
  <div>
193
- <div className={className}>{props.label}</div>
215
+ <div className={className}>
216
+ {leftAccent && (
217
+ <div
218
+ aria-hidden="true"
219
+ style={{
220
+ position: "absolute",
221
+ insetBlock: 0,
222
+ left: 0,
223
+ width: props.edgeWidth ?? "1px",
224
+ backgroundColor: colors.accent[8],
225
+ pointerEvents: "none",
226
+ }}
227
+ />
228
+ )}
229
+ {props.label}
230
+ </div>
194
231
  </div>
195
232
  )
196
233
  }
197
234
 
198
- function capitalize(value: string) {
199
- return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`
200
- }
201
-
202
235
  function SingleEdgeExample() {
203
236
  const className = useStyles(
204
237
  border(["bottom"], "border"),
@@ -35,7 +35,7 @@ export function ButtonsPage() {
35
35
  return (
36
36
  <Prose style={{ marginBottom: "32px" }}>
37
37
  <H2>Buttons</H2>
38
- <H3>Text</H3>
38
+ <H3>Default</H3>
39
39
  <Flex row alignItems="center" gap={4}>
40
40
  <Button>Button</Button>
41
41
  <Button onClick={() => setDialogOpen(true)}>Open Dialog</Button>
@@ -221,6 +221,35 @@ export function ButtonsPage() {
221
221
  </Button>
222
222
  </Flex>
223
223
 
224
+ <H3>Text</H3>
225
+ <P>
226
+ <Code>variant="text"</Code> shows only the label and icon, with no
227
+ padding, background, or shadow. Hover changes only their color. Use{" "}
228
+ <Code>variantColor</Code> to tint them.
229
+ </P>
230
+ <Flex row alignItems="center" gap={4} style={{ flexWrap: "wrap" }}>
231
+ <Button variant="text">Text button</Button>
232
+ <Button variant="text" variantColor="blue">
233
+ <Plus />
234
+ Create
235
+ </Button>
236
+ <Button variant="text" aria-label="More text actions">
237
+ <DotsHorizontal />
238
+ </Button>
239
+ <Button variant="text" variantColor="#6366f1">
240
+ Custom color
241
+ </Button>
242
+ <Button variant="text" size="sm">
243
+ Small text
244
+ </Button>
245
+ <Button variant="text" isDisabled>
246
+ Disabled text
247
+ </Button>
248
+ <Button variant="text" isPending>
249
+ Pending text
250
+ </Button>
251
+ </Flex>
252
+
224
253
  <H3>Icons with text</H3>
225
254
  <Flex row alignItems="center" gap={4}>
226
255
  <Button>
@@ -142,7 +142,7 @@ const itemClass = style(navigationItem, focusRing("&:focus-visible"), {
142
142
  backgroundColor: "transparent",
143
143
  color: colors.gray[12],
144
144
  "&[aria-current='page']": {
145
- color: colors.accent[9],
145
+ color: colors.accent[11],
146
146
  fontWeight: 500,
147
147
  backgroundColor: "transparent",
148
148
  },
@@ -11,15 +11,15 @@ import { fontFamily, monoFontStyle } from "./text"
11
11
  * rhythm tuned for sustained reading. The `Prose` container picks a size and
12
12
  * typography components inside it opt into the matching prose treatment.
13
13
  *
14
- * Sizes and spacing are ported from the Tailwind Typography plugin
14
+ * Type sizes and heading spacing are ported from the Tailwind Typography plugin
15
15
  * (https://github.com/tailwindlabs/tailwindcss-typography): `sm` -> 14px base,
16
16
  * `md` -> Tailwind's `base` (16px), `lg` -> 18px. Tailwind's em-relative values
17
17
  * are resolved to px here so they compose with Maui's px-based tokens.
18
18
  *
19
19
  * On the Large platform scale (coarse pointers) block font sizes and line
20
20
  * heights step up ~1.2x — the same idea as the `text` tokens — so reading
21
- * content grows with the rest of the UI on touch devices. Block gaps tighten
22
- * separately (see `blockGaps`).
21
+ * content grows with the rest of the UI on touch devices. Paragraph gaps stay
22
+ * at 10px on both scales.
23
23
  */
24
24
  export type ProseSize = "sm" | "md" | "lg"
25
25
 
@@ -37,7 +37,6 @@ type HeadingMetric = BlockMetric & {
37
37
  }
38
38
 
39
39
  type ProseMetric = {
40
- blockGap: number
41
40
  paragraph: BlockMetric
42
41
  h1: HeadingMetric
43
42
  h2: HeadingMetric
@@ -55,7 +54,6 @@ const scaledPx = (normal: number, large: number) =>
55
54
 
56
55
  const metrics: Record<ProseSize, ProseMetric> = {
57
56
  sm: {
58
- blockGap: 16,
59
57
  paragraph: { fontSize: scaledPx(14, 17), lineHeight: scaledPx(24, 29) },
60
58
  h1: {
61
59
  fontSize: scaledPx(30, 36),
@@ -81,11 +79,10 @@ const metrics: Record<ProseSize, ProseMetric> = {
81
79
  marginTop: 20,
82
80
  marginBottom: 8,
83
81
  },
84
- listPadding: 22,
82
+ listPadding: 20,
85
83
  listItemGap: 4,
86
84
  },
87
85
  md: {
88
- blockGap: 20,
89
86
  paragraph: { fontSize: scaledPx(16, 19), lineHeight: scaledPx(28, 34) },
90
87
  h1: {
91
88
  fontSize: scaledPx(36, 43),
@@ -111,11 +108,10 @@ const metrics: Record<ProseSize, ProseMetric> = {
111
108
  marginTop: 24,
112
109
  marginBottom: 8,
113
110
  },
114
- listPadding: 26,
111
+ listPadding: 24,
115
112
  listItemGap: 8,
116
113
  },
117
114
  lg: {
118
- blockGap: 24,
119
115
  paragraph: { fontSize: scaledPx(18, 22), lineHeight: scaledPx(32, 38) },
120
116
  h1: {
121
117
  fontSize: scaledPx(48, 58),
@@ -141,17 +137,12 @@ const metrics: Record<ProseSize, ProseMetric> = {
141
137
  marginTop: 32,
142
138
  marginBottom: 8,
143
139
  },
144
- listPadding: 28,
140
+ listPadding: 26,
145
141
  listItemGap: 12,
146
142
  },
147
143
  }
148
144
 
149
- // Slightly tighter reading rhythm on the Large platform scale.
150
- const blockGaps = defineVars({
151
- sm: { default: `${metrics.sm.blockGap}px`, [LARGE_SCALE]: "12px" },
152
- md: { default: `${metrics.md.blockGap}px`, [LARGE_SCALE]: "16px" },
153
- lg: { default: `${metrics.lg.blockGap}px`, [LARGE_SCALE]: "20px" },
154
- })
145
+ const blockGap = "10px"
155
146
 
156
147
  // Heading weights follow Tailwind's descending scale, capped at Maui's
157
148
  // heaviest weight (700).
@@ -216,24 +207,24 @@ const notHeading = ":not(h1):not(h2):not(h3):not(h4)"
216
207
  * `marginBottom` becomes the "hug" gap after a heading; heading `marginTop`
217
208
  * becomes the larger break before it.
218
209
  */
219
- export const proseRhythm = memoize((size: ProseSize) => {
210
+ export const proseRhythm = memoize((size: ProseSize, selector = "&") => {
220
211
  const m = metrics[size]
221
212
 
222
213
  return style({
223
- "& > * + *": {
224
- marginTop: blockGaps[size],
214
+ [`${selector} > * + *`]: {
215
+ marginTop: blockGap,
225
216
  },
226
217
  // Gap after a heading: hug it to the content that follows.
227
- "& > h1 + *": { marginTop: px(m.h1.marginBottom) },
228
- "& > h2 + *": { marginTop: px(m.h2.marginBottom) },
229
- "& > h3 + *": { marginTop: px(m.h3.marginBottom) },
230
- "& > h4 + *": { marginTop: px(m.h4.marginBottom) },
218
+ [`${selector} > h1 + *`]: { marginTop: px(m.h1.marginBottom) },
219
+ [`${selector} > h2 + *`]: { marginTop: px(m.h2.marginBottom) },
220
+ [`${selector} > h3 + *`]: { marginTop: px(m.h3.marginBottom) },
221
+ [`${selector} > h4 + *`]: { marginTop: px(m.h4.marginBottom) },
231
222
  // Break before a heading (only when it opens a new section, i.e. it
232
223
  // isn't already hugging a preceding heading).
233
- [`& > ${notHeading} + h1`]: { marginTop: px(m.h1.marginTop) },
234
- [`& > ${notHeading} + h2`]: { marginTop: px(m.h2.marginTop) },
235
- [`& > ${notHeading} + h3`]: { marginTop: px(m.h3.marginTop) },
236
- [`& > ${notHeading} + h4`]: { marginTop: px(m.h4.marginTop) },
224
+ [`${selector} > ${notHeading} + h1`]: { marginTop: px(m.h1.marginTop) },
225
+ [`${selector} > ${notHeading} + h2`]: { marginTop: px(m.h2.marginTop) },
226
+ [`${selector} > ${notHeading} + h3`]: { marginTop: px(m.h3.marginTop) },
227
+ [`${selector} > ${notHeading} + h4`]: { marginTop: px(m.h4.marginTop) },
237
228
  })
238
229
  })
239
230
 
@@ -250,7 +241,7 @@ export const proseHtml = memoize((size: ProseSize) => {
250
241
  return style({
251
242
  display: "flex",
252
243
  flexDirection: "column",
253
- gap: blockGaps[size],
244
+ gap: blockGap,
254
245
  "& p": {
255
246
  ...block(m.paragraph),
256
247
  fontWeight: 400,