@tanishqkancharla/maui 0.0.66 → 0.0.70

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
228
  return primaryClass(scale[9], darkTextOnSolid.has(color) ? scale[12] : "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
  }
@@ -73,10 +73,14 @@ const dialog = style({
73
73
  const title = style(text({ size: "lg", fontWeight: 600 }), {
74
74
  margin: 0,
75
75
  });
76
+ // A scrolling box clips on both axes. The inset, offset by a negative margin,
77
+ // keeps the rings and focus glow of full-width fields visible without moving them.
76
78
  const body = style({
77
79
  minWidth: 0,
78
80
  minHeight: 0,
79
81
  overflowY: "auto",
82
+ margin: `calc(-1 * ${spacing.value(3)})`,
83
+ padding: spacing.value(3),
80
84
  });
81
85
  const actions = style({
82
86
  display: "flex",
@@ -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],
@@ -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],
@@ -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.66",
3
+ "version": "0.0.70",
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,8 +26,8 @@ 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
32
  | `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
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 |
@@ -37,6 +38,8 @@ Primary: fill step 9, light text (`onAccent`, or step 12 on amber/lime/mint/sky/
37
38
 
38
39
  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
40
 
41
+ Text: zero padding in both sizes, including icons and pending content; retains the control height and focus ring. No background or resting shadow. Only the label/icon color changes on hover (palette step 11 → 12, or a darker custom color). Disabled and pending suppress hover changes.
42
+
40
43
  `useButton(props)` is exported for custom focus-tracked buttons; prefer `Button`.
41
44
 
42
45
  ## `Overlay`
@@ -93,5 +96,5 @@ Edge-anchored mobile nav is [`Drawer`](drawer.md), not this Dialog and not Overl
93
96
  - `modal({ size })` — RAC `Modal`; `size` is `"sm"` (default) | `"md"` | `"lg"`.
94
97
  - `dialog` — RAC `Dialog`.
95
98
  - `title` — RAC `Heading slot="title"`.
96
- - `body` — scrollable `div` inside the dialog.
99
+ - `body` — scrollable `div` inside the dialog. A 6px inset, offset by a negative margin, keeps full-width field rings and focus glows unclipped.
97
100
  - `actions` — button row `div` inside the dialog.
@@ -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`.
@@ -47,6 +47,18 @@ Buttons and form-control surfaces use `shadow.control` by default. For compound
47
47
 
48
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.
49
49
 
50
+ **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. The focus treatment includes a 1px ring and a 6px blur. Use `spacing.padding(4)` (9px) as a practical safe 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.
51
+
52
+ ```tsx
53
+ // Keep the control's visual overflow inside the scrolling boundary.
54
+ <div style={{ overflow: "auto" }}>
55
+ <Flex column padding={4} gap={4}>
56
+ <TextField aria-label="Name" />
57
+ <Select aria-label="Plan">{/* options */}</Select>
58
+ </Flex>
59
+ </div>
60
+ ```
61
+
50
62
  ## Layout utilities
51
63
 
52
64
  `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.
@@ -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,7 +292,11 @@ 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)
@@ -295,7 +308,11 @@ const coloredButtonClass = memoize(
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
  }
@@ -115,10 +115,14 @@ const title = style(text({ size: "lg", fontWeight: 600 }), {
115
115
  margin: 0,
116
116
  })
117
117
 
118
+ // A scrolling box clips on both axes. The inset, offset by a negative margin,
119
+ // keeps the rings and focus glow of full-width fields visible without moving them.
118
120
  const body = style({
119
121
  minWidth: 0,
120
122
  minHeight: 0,
121
123
  overflowY: "auto",
124
+ margin: `calc(-1 * ${spacing.value(3)})`,
125
+ padding: spacing.value(3),
122
126
  })
123
127
 
124
128
  const actions = style({
@@ -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],
@@ -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],
@@ -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>
@@ -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,