@supertype.ai/foundations 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +27 -23
  2. package/bin/foundations.mjs +5 -2
  3. package/dist/blocks/accordion.js +2 -1
  4. package/dist/blocks/badge.d.ts +27 -0
  5. package/dist/blocks/badge.js +86 -0
  6. package/dist/blocks/button.d.ts +32 -0
  7. package/dist/blocks/button.js +127 -0
  8. package/dist/blocks/callout.d.ts +2 -39
  9. package/dist/blocks/callout.js +13 -38
  10. package/dist/blocks/card.d.ts +5 -6
  11. package/dist/blocks/card.js +28 -12
  12. package/dist/blocks/focus.d.ts +9 -0
  13. package/dist/blocks/focus.js +9 -0
  14. package/dist/blocks/index.d.ts +3 -1
  15. package/dist/blocks/index.js +3 -1
  16. package/dist/blocks/interactive-accordion.js +2 -1
  17. package/dist/blocks/render-as.d.ts +11 -0
  18. package/dist/blocks/render-as.js +22 -0
  19. package/dist/blocks/segment.d.ts +22 -14
  20. package/dist/blocks/segment.js +23 -14
  21. package/dist/blocks/tabs.d.ts +52 -17
  22. package/dist/blocks/tabs.js +86 -37
  23. package/dist/cjs/eslint.js +49 -14
  24. package/dist/contrast.d.ts +56 -0
  25. package/dist/contrast.js +144 -12
  26. package/dist/eslint.d.ts +42 -8
  27. package/dist/eslint.js +47 -14
  28. package/dist/essay/essay.js +14 -6
  29. package/dist/essay/index.d.ts +1 -1
  30. package/dist/essay/index.js +1 -1
  31. package/dist/essay/layout.d.ts +37 -1
  32. package/dist/essay/layout.js +33 -1
  33. package/dist/essay/rail.d.ts +11 -1
  34. package/dist/essay/reading.d.ts +1 -1
  35. package/dist/essay/scroll.js +1 -1
  36. package/dist/href.d.ts +42 -0
  37. package/dist/href.js +63 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +9 -0
  40. package/dist/mdx.d.ts +26 -4
  41. package/dist/mdx.js +30 -4
  42. package/dist/tone.d.ts +130 -0
  43. package/dist/tone.js +140 -0
  44. package/dist/typography/header.d.ts +32 -2
  45. package/dist/typography/header.js +10 -5
  46. package/dist/typography/highlight.d.ts +7 -6
  47. package/dist/typography/highlight.js +15 -9
  48. package/dist/typography/paragraph.d.ts +12 -25
  49. package/dist/typography/paragraph.js +40 -29
  50. package/llms.txt +99 -22
  51. package/package.json +2 -1
  52. package/src/theme.css +143 -46
  53. package/src/tokens.css +5 -4
  54. package/src/type.css +1 -1
package/README.md CHANGED
@@ -4,8 +4,9 @@
4
4
  [![npm](https://img.shields.io/npm/v/%40supertype.ai%2Ffoundations?logo=npm&color=cb3837)](https://www.npmjs.com/package/@supertype.ai/foundations)
5
5
  [![license](https://img.shields.io/npm/l/%40supertype.ai%2Ffoundations?color=blue)](LICENSE)
6
6
 
7
- [The foundations philosophy](https://supertypeai.github.io/foundations/philosophy/) lays out the Raison d'être better, but the crux is that it is a reusable design system that binds typography primitives, content blocks, the long-form essay shell, the token and theme CSS, and the build-time tooling that keep baseline quality high (SEO, OG cards, lint rules, contrast
8
- checks) in a single package. Used by Supertype's own projects like [Viably work operating system](https://viably.app) and [supertype.ai](https://supertype.ai), it is also MIT-licensed and available for any Next.js 15+ project built atop Tailwind and Shadcn.
7
+ [The foundations philosophy](https://supertypeai.github.io/foundations/philosophy/) explains the reasoning in more detail, but the short version is simple: this is a reusable design system for typography primitives, content blocks, the long-form essay shell, token and theme CSS, and the build-time tooling that keeps the baseline consistent. That includes SEO, OG cards, lint rules, and contrast checks.
8
+
9
+ It is used in Supertype projects like [Viably work operating system](https://viably.app) and [supertype.ai](https://supertype.ai), and it is MIT-licensed for any Next.js 15+ app built on Tailwind and Shadcn.
9
10
 
10
11
  ```sh
11
12
  yarn add @supertype.ai/foundations
@@ -31,11 +32,11 @@ yarn example:install # once, to install Next, the peers and the package
31
32
  yarn example # then open http://localhost:3000
32
33
  ```
33
34
 
34
- [`examples/site`](examples/site) renders every component along with the code, and include whole-page [recipes](examples/site/app/_recipes) to copy into your project. Comes with the `dark` and `.editorial` switches.
35
+ [`examples/site`](examples/site) renders every component with the code next to it, and includes whole-page [recipes](examples/site/app/_recipes) you can copy into your project. It also includes the `dark` and `.editorial` switches.
35
36
 
36
37
  ## Initialization and Diagnostics
37
38
 
38
- This package also ships a CLI that writes the CSS for you and checks the rest:
39
+ This package includes a CLI that writes the CSS for you and checks the rest:
39
40
 
40
41
  ```sh
41
42
  npx @supertype.ai/foundations init # edits your CSS entry, prints the rest
@@ -43,7 +44,7 @@ npx @supertype.ai/foundations doctor # checks this app against everything bel
43
44
  ```
44
45
 
45
46
  `init` edits one file: the CSS entry that imports Tailwind. It adds the imports
46
- you are missing and reorders anything that is out of place. Run it with `--dry-run` first to see the patch. Everything else it prints for you to paste (the font binding, and the `llms.txt` lines for a coding agent).
47
+ you are missing and reorders anything that is out of place. Run it with `--dry-run` first to preview the patch. Everything else it prints for you to paste is the font binding and the `llms.txt` lines for a coding agent.
47
48
 
48
49
  The steps performed by `init` are written out below anyway. See [the CLI](docs/cli.md) for the full list of checks and details.
49
50
 
@@ -59,13 +60,13 @@ Peers are React 19+, Next 15+, `next-view-transitions` 0.3+ and `@base-ui/react`
59
60
  <details>
60
61
  <summary>Installing from a git tag instead</summary>
61
62
 
62
- Every release is tagged as well as published, so a commit can be installed
63
- directly useful for trying an unreleased fix. Pin a tag, never `#main`: an
64
- untagged git dependency re-resolves to a different commit on any fresh install.
63
+ Every release is tagged and published, so a commit can be installed directly
64
+ when you want to try an unreleased fix. Pin a tag rather than `#main`: an
65
+ untagged git dependency resolves to a different commit on a fresh install.
65
66
 
66
67
  ```jsonc
67
68
  // package.json
68
- "@supertype.ai/foundations": "https://github.com/supertypeai/foundations.git#v0.1.28"
69
+ "@supertype.ai/foundations": "https://github.com/supertypeai/foundations.git#v0.1.30"
69
70
  ```
70
71
 
71
72
  </details>
@@ -85,15 +86,16 @@ untagged git dependency re-resolves to a different commit on any fresh install.
85
86
  ```
86
87
 
87
88
  **The `@source` line is required.** Tailwind does not scan `node_modules` by
88
- default, so without it every class is purged and the components
89
- render with no styles at all.
89
+ default, so without it the package&rsquo;s classes get purged and the
90
+ components render without styles.
90
91
 
91
- **`theme.css` is required.** `tokens.css` names the colour roles; `theme.css` is
92
- what gives them values. Without it color utility can't be resolved, so the page renders unpainted with no error. It also carries
93
- `--secondary-ink`, `--subtle-foreground`, the four earth tones the marker
94
- highlight uses, and the `accordion-down` and `accordion-up` keyframes. Skip it
95
- only if you declare every role yourself; `foundations doctor` fails when neither
96
- is true.
92
+ **`theme.css` is required.** `tokens.css` names the colour roles, and
93
+ `theme.css` gives them values. Without it, the colour utilities cannot be
94
+ resolved, so the page renders unpainted without an obvious error. It also
95
+ carries `--secondary-ink`, `--subtle-foreground`, the four earth tones used
96
+ for marker highlights, and the `accordion-down` and `accordion-up` keyframes.
97
+ Skip it only if you declare every role yourself; `foundations doctor` fails if
98
+ neither path is true.
97
99
 
98
100
  ### 3. Bind the fonts
99
101
 
@@ -112,8 +114,10 @@ const serif = Average({ variable: "--font-average", weight: "400", subsets: ["la
112
114
  <html className={`${sans.variable} ${mono.variable} ${serif.variable} font-sans`}>
113
115
  ```
114
116
 
115
- **Bind with `.variable`, never `.className`.** A className sets `font-family` on
116
- the element itself and leaves the roles unresolved, causing a mismatch where the page renders one typeface while every `font-sans` and `font-heading` utility on it renders another.
117
+ **Bind with `.variable`, never `.className`.** A className sets `font-family`
118
+ on the element itself and leaves the roles unresolved, which causes a mismatch
119
+ where the page renders one typeface while the `font-sans` and `font-heading`
120
+ utilities render another.
117
121
 
118
122
  ### 4. Check the wiring
119
123
 
@@ -213,9 +217,9 @@ only from this package, so you can paste it into your app and it compiles.
213
217
 
214
218
  ## For coding agents
215
219
 
216
- The package ships an `llms.txt` with the public API, the rules, and the mistakes
217
- that do not produce an error. Point your agent at it once and it stops
218
- hand-writing `text-sm text-muted-foreground` where a primitive exists:
220
+ The package includes an `llms.txt` with the public API, the rules, and the
221
+ mistakes that do not produce an error. Point your agent at it once and it stops
222
+ writing `text-sm text-muted-foreground` where a primitive already exists:
219
223
 
220
224
  ```md
221
225
  <!-- CLAUDE.md, AGENTS.md, or your agent's equivalent -->
@@ -233,7 +237,7 @@ package.
233
237
  | import | contains | docs |
234
238
  | --------------------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------- |
235
239
  | `@supertype.ai/foundations` | all typography primitives, `cn` | [Typography](docs/typography.md) |
236
- | `@supertype.ai/foundations/blocks` | `Card`, `Callout`, `Steps`, `Tabs`, `Accordion`, `Disclosure`, `SEGMENT` | [Blocks](docs/blocks.md) |
240
+ | `@supertype.ai/foundations/blocks` | `Button`, `Badge`, `Card`, `Callout`, `Steps`, `TabGroup`, `Accordion`, `SEGMENT` | [Blocks](docs/blocks.md) |
237
241
  | `@supertype.ai/foundations/mdx` | `proseMdxComponents` — the MDX element map | [In MDX](docs/blocks.md#in-mdx) |
238
242
  | `@supertype.ai/foundations/essay` | the long-form shell, TOC, reading rail, post meta | [Essay](docs/essay.md) |
239
243
  | `@supertype.ai/foundations/seo` | `createSeo(...)` — metadata + JSON-LD | [Tooling](docs/tooling.md#seo-and-og-images) |
@@ -518,9 +518,9 @@ const checkContrast = async (appRoot) => {
518
518
  const cssFile = findCssEntry(appRoot);
519
519
  if (!cssFile) return out;
520
520
 
521
- let checkLegibility, checkSignals, formatFailures;
521
+ let checkLegibility, checkSignals, checkHairlines, formatFailures;
522
522
  try {
523
- ({ checkLegibility, checkSignals, formatFailures } = await import(
523
+ ({ checkLegibility, checkSignals, checkHairlines, formatFailures } = await import(
524
524
  join(pkgRoot, "dist/contrast.js")
525
525
  ));
526
526
  } catch {
@@ -533,6 +533,9 @@ const checkContrast = async (appRoot) => {
533
533
  const groups = [
534
534
  ["error", "structural ink below 4.5:1", checkLegibility(css)],
535
535
  ["warn", "mark or tinted ink below its bar", checkSignals(css)],
536
+ // A rule owes no WCAG bar, so this can never be an error. It is here because
537
+ // an app that retints --border almost always retints one theme.
538
+ ["warn", "hairline too faint to read as a rule", checkHairlines(css)],
536
539
  ];
537
540
 
538
541
  for (const [level, title, failures] of groups) {
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Children, cloneElement, isValidElement } from "react";
3
3
  import { cn } from "../cn.js";
4
+ import { inkOnSurface } from "../tone.js";
4
5
  /**
5
6
  * A disclosure group: `<details>`/`<summary>`, no JS, correct before hydration.
6
7
  *
@@ -55,5 +56,5 @@ function deriveGroupName(children) {
55
56
  return `accordion-${(hash >>> 0).toString(36)}`;
56
57
  }
57
58
  export function Disclosure({ title, children, className, ...props }) {
58
- return (_jsxs("details", { className: cn("group bg-card", className), ...props, children: [_jsxs("summary", { className: "flex cursor-pointer list-none items-center justify-between gap-4 px-4 py-3 text-sm font-medium text-foreground marker:hidden hover:bg-accent [&::-webkit-details-marker]:hidden", children: [title, _jsx("svg", { "aria-hidden": "true", className: "h-4 w-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "m6 9 6 6 6-6" }) })] }), _jsx("div", { className: "px-4 pb-4 text-sm text-muted-foreground", children: children })] }));
59
+ return (_jsxs("details", { className: cn("group bg-card", inkOnSurface("--card-foreground"), className), ...props, children: [_jsxs("summary", { className: "flex cursor-pointer list-none items-center justify-between gap-4 px-4 py-3 text-sm font-medium text-foreground marker:hidden hover:bg-accent [&::-webkit-details-marker]:hidden", children: [title, _jsx("svg", { "aria-hidden": "true", className: "h-4 w-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "m6 9 6 6 6-6" }) })] }), _jsx("div", { className: "px-4 pb-4 text-sm text-muted-foreground", children: children })] }));
59
60
  }
@@ -0,0 +1,27 @@
1
+ import type { ComponentProps, ReactElement } from "react";
2
+ import { type VariantProps } from "class-variance-authority";
3
+ import { type LinkBehavior } from "../href.js";
4
+ declare const badge: (props?: ({
5
+ tone?: "muted" | "primary" | "secondary" | "brand" | "success" | "warn" | "destructive" | null | undefined;
6
+ size?: "sm" | "xs" | null | undefined;
7
+ pill?: boolean | null | undefined;
8
+ variant?: "solid" | "soft" | "outline" | "ghost" | null | undefined;
9
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
10
+ export type BadgeLook = VariantProps<typeof badge>;
11
+ export declare function badgeVariants(props?: Parameters<typeof badge>[0]): string;
12
+ /**
13
+ * A `span`, an anchor when given an `href` — a tag pill leading to its listing —
14
+ * and whatever `render` says otherwise — cloned rather than run through a
15
+ * `useRender` hook, which is what viably's badge did. A hook would make every
16
+ * badge in the tree a client component to serve the one call site that renders
17
+ * an anchor, and a badge is a label: it should cost nothing on the server. This
18
+ * is the same mechanism `Button` uses for the same reason.
19
+ *
20
+ * The `[a]:hover` rules above light up on their own when an anchor is the parent
21
+ * or the rendered element.
22
+ */
23
+ export declare function Badge({ className, variant, tone, size, pill, render, href, external, newTab, ...props }: ComponentProps<"span"> & BadgeLook & LinkBehavior & {
24
+ render?: ReactElement;
25
+ href?: string;
26
+ }): import("react").JSX.Element;
27
+ export {};
@@ -0,0 +1,86 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cva } from "class-variance-authority";
3
+ import { cn } from "../cn.js";
4
+ import { renderAs } from "./render-as.js";
5
+ import { resolveLink } from "../href.js";
6
+ import { FOCUS_RING } from "./focus.js";
7
+ import { INK_ON_FILL, TONE, TONE_SURFACE, impliedTone } from "../tone.js";
8
+ // ---------------------------------------------------------------------------
9
+ // A label that is not a control. Same two axes as Button, and for the same
10
+ // reason: the two apps had each grown their own list, and the lists disagreed
11
+ // with each other and with the rest of the package.
12
+ //
13
+ // ssite's read `default | secondary | destructive | outline | success | warning
14
+ // | supertype | tint`. Two of those are the package's tones under invented
15
+ // spellings — `warning` for `warn`, `supertype` for `brand` — which is precisely
16
+ // the second vocabulary a design system exists to prevent. `secondary` was
17
+ // `bg-muted/80 text-foreground` against a `default` of `bg-muted
18
+ // text-muted-foreground`: a distinction no reader could name, let alone use.
19
+ //
20
+ // viably's read `default | secondary | destructive | outline | ghost | link`,
21
+ // which is a copy of the button list it was cargo-culted from — including the
22
+ // `link` variant, which no badge has ever used, because a badge is not a link.
23
+ //
24
+ // So: `variant` for how much ink, `tone` for what it means, both spelled exactly
25
+ // as Button spells them. `link` is absent because it was never real; everything
26
+ // else about the vocabulary is the same list, so knowing one component's axes is
27
+ // knowing this one's.
28
+ // ---------------------------------------------------------------------------
29
+ const badge = cva(cn("inline-flex w-fit shrink-0 items-center justify-center gap-1", "overflow-hidden border border-transparent font-medium whitespace-nowrap", cn("transition focus-visible:border-ring", FOCUS_RING), "aria-invalid:border-destructive aria-invalid:ring-destructive/20", "[&>svg]:pointer-events-none [&>svg]:size-3!", TONE_SURFACE), {
30
+ // Same cascade order as Button: `pill` beats `size` on radius.
31
+ variants: {
32
+ tone: TONE,
33
+ /**
34
+ * Two rungs, because a badge has two jobs. `sm` is the label riding beside
35
+ * a title; `xs` is the figure riding beside a toolbar control — a filter
36
+ * tally, an unread count.
37
+ *
38
+ * `leading-none` restates the default on purpose: `text-3xs` carries a
39
+ * line-height of its own, which lands after the base class and wins.
40
+ * Stating it per size is what keeps `!leading-none` out of call sites.
41
+ */
42
+ size: {
43
+ xs: "h-4 min-w-4 rounded px-1 text-3xs leading-none",
44
+ sm: "h-5 rounded-md px-2 py-0.5 text-xs leading-none",
45
+ },
46
+ pill: { true: "rounded-full", false: "" },
47
+ variant: {
48
+ solid: `bg-(--tone-fill) text-(color:--tone-ink) [a]:hover:bg-(--tone-fill-hover) ${INK_ON_FILL}`,
49
+ soft: "bg-(--tone-wash) text-(color:--tone-hue) [a]:hover:bg-(--tone-wash-hover)",
50
+ outline: "border-(color:--tone-line) text-(color:--tone-hue) [a]:hover:bg-(--tone-wash)",
51
+ ghost: "text-(color:--tone-hue) hover:bg-(--tone-wash)",
52
+ },
53
+ },
54
+ defaultVariants: { variant: "solid", size: "sm", pill: false },
55
+ });
56
+ export function badgeVariants(props = {}) {
57
+ return badge({ tone: props?.tone ?? impliedTone(props?.variant), ...props });
58
+ }
59
+ /**
60
+ * A `span`, an anchor when given an `href` — a tag pill leading to its listing —
61
+ * and whatever `render` says otherwise — cloned rather than run through a
62
+ * `useRender` hook, which is what viably's badge did. A hook would make every
63
+ * badge in the tree a client component to serve the one call site that renders
64
+ * an anchor, and a badge is a label: it should cost nothing on the server. This
65
+ * is the same mechanism `Button` uses for the same reason.
66
+ *
67
+ * The `[a]:hover` rules above light up on their own when an anchor is the parent
68
+ * or the rendered element.
69
+ */
70
+ export function Badge({ className, variant, tone, size, pill, render, href, external, newTab, ...props }) {
71
+ const resolved = tone ?? impliedTone(variant);
72
+ const classes = cn(badge({ variant, tone: resolved, size, pill, className }));
73
+ const marks = {
74
+ "data-slot": "badge",
75
+ "data-variant": variant ?? "solid",
76
+ "data-tone": resolved,
77
+ };
78
+ if (href !== undefined) {
79
+ const { Component, props: link } = resolveLink(href, { external, newTab });
80
+ return (_jsx(Component, { ...marks, ...link, className: classes, ...props }));
81
+ }
82
+ const as = renderAs(render, classes, { ...marks, ...props });
83
+ if (as)
84
+ return as;
85
+ return _jsx("span", { ...marks, className: classes, ...props });
86
+ }
@@ -0,0 +1,32 @@
1
+ import { Button as ButtonPrimitive } from "@base-ui/react/button";
2
+ import { type VariantProps } from "class-variance-authority";
3
+ import { type LinkBehavior } from "../href.js";
4
+ declare const button: (props?: ({
5
+ tone?: "muted" | "primary" | "secondary" | "brand" | "success" | "warn" | "destructive" | null | undefined;
6
+ size?: "sm" | "xs" | "md" | "lg" | "xl" | null | undefined;
7
+ icon?: boolean | null | undefined;
8
+ pill?: boolean | null | undefined;
9
+ variant?: "link" | "solid" | "soft" | "outline" | "ghost" | null | undefined;
10
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
11
+ export type ButtonLook = VariantProps<typeof button>;
12
+ /**
13
+ * The class list, for the handful of places that style someone else's element
14
+ * and cannot render a `Button` — a router `Link` inside a `not-found`, a
15
+ * calendar day cell. Takes the same props, including the implied tone.
16
+ */
17
+ export declare function buttonVariants(props?: Parameters<typeof button>[0]): string;
18
+ /**
19
+ * `href` makes the button a link — the anchor is the button, and where the href
20
+ * goes is ../href.ts's decision, not the call site's. `render={<a href="…" />}`
21
+ * did this before, and got a bare anchor: no router, so a CTA reloaded the page
22
+ * and lost the view transition, and an off-site href never grew a `rel`.
23
+ *
24
+ * Either way a non-`<button>` element bypasses the primitive on purpose: Base UI
25
+ * always stamps `type="button"` or `role="button"`, and the latter drops an
26
+ * anchor out of screen-reader link navigation. `render` remains for an element
27
+ * that is genuinely neither — a `<label>`, a menu item.
28
+ */
29
+ export declare function Button({ className, variant, tone, size, icon, pill, render, nativeButton, href, external, newTab, ...props }: ButtonPrimitive.Props & ButtonLook & LinkBehavior & {
30
+ href?: string;
31
+ }): import("react").JSX.Element;
32
+ export {};
@@ -0,0 +1,127 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { isValidElement } from "react";
3
+ import { Button as ButtonPrimitive } from "@base-ui/react/button";
4
+ import { cva } from "class-variance-authority";
5
+ import { cn } from "../cn.js";
6
+ import { renderAs } from "./render-as.js";
7
+ import { resolveLink } from "../href.js";
8
+ import { FOCUS_RING } from "./focus.js";
9
+ import { INK_ON_FILL, TONE, TONE_SURFACE, impliedTone } from "../tone.js";
10
+ // ---------------------------------------------------------------------------
11
+ // The control both apps were re-declaring. viably and ssite each carried their
12
+ // own `cva` with its own variant list — `default | secondary | accent |
13
+ // destructive | ghost | outline | link` in one, the same names minus two plus a
14
+ // `rose` in the other — and the two had already drifted on radius, on height,
15
+ // and on what `destructive` even means (a solid red fill in ssite, a tinted wash
16
+ // in viably).
17
+ //
18
+ // The fix is not a longer shared list. Those names answer two questions at once:
19
+ //
20
+ // variant — how much ink the button spends. Filled, washed, hairline, bare.
21
+ // tone — what the ink means. See ../tone.ts; Callout and TypographyLink
22
+ // take the same seven, because a component does not get to invent a
23
+ // name for a colour the package has already named. Its default is
24
+ // the one the variant implies — see `impliedTone`.
25
+ //
26
+ // `destructive` is a tone. `ghost` is a variant. A list holding both can only
27
+ // express the pairs someone thought to add, which is why neither app could write
28
+ // a quiet destructive button without a className.
29
+ //
30
+ // Everything else is a modifier, and both are boolean because both have exactly
31
+ // two states: `icon` squares the box, `pill` rounds it off. They compose — a
32
+ // round icon button is `icon pill` — which is why they are not one `shape` enum.
33
+ // ---------------------------------------------------------------------------
34
+ const button = cva(cn("inline-flex shrink-0 cursor-pointer items-center justify-center", "border border-transparent bg-clip-padding font-medium whitespace-nowrap", "transition select-none",
35
+ // The border joins the ring where a control has one; the ring itself is shared.
36
+ cn(FOCUS_RING, "focus-visible:border-ring"), "active:not-aria-[haspopup]:translate-y-px", "disabled:pointer-events-none disabled:opacity-50", "aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", TONE_SURFACE), {
37
+ // Key order is cascade order: `cva` emits these as declared and `cn` resolves
38
+ // a conflict in favour of the last one written. `pill` therefore beats
39
+ // `size` on radius, and `variant` beats `size` on the box — which is what
40
+ // lets `link` shed the height and padding of whatever size it was given.
41
+ // Reordering this object is a visual change.
42
+ variants: {
43
+ tone: TONE,
44
+ /**
45
+ * One ladder, 24px to 40px on a 4px step. `md` is the product default —
46
+ * dense rows of controls beside a table — and `lg`/`xl` the marketing
47
+ * rungs. `--radius-lg` is 10px and theme.css already labels it "buttons,
48
+ * default control"; the two rungs below borrow `md`, because a 10px radius
49
+ * on a 24px box reads as a lozenge.
50
+ */
51
+ size: {
52
+ xs: "h-6 gap-1 rounded-md px-2 text-xs [&_svg:not([class*='size-'])]:size-3",
53
+ sm: "h-7 gap-1 rounded-md px-2.5 text-[0.8rem] [&_svg:not([class*='size-'])]:size-3.5",
54
+ md: "h-8 gap-1.5 rounded-lg px-3 text-sm",
55
+ lg: "h-9 gap-2 rounded-lg px-4 text-sm",
56
+ xl: "h-10 gap-2 rounded-lg px-6 text-sm",
57
+ },
58
+ /** A square box for a lone glyph, on whichever rung you are already on. No second ladder of `icon-sm` names to keep aligned with the first. */
59
+ icon: { true: "px-0", false: "" },
60
+ /** Full-round corners. Marketing surfaces; also every filter chip. */
61
+ pill: { true: "rounded-full", false: "" },
62
+ variant: {
63
+ solid: `bg-(--tone-fill) text-(color:--tone-ink) hover:bg-(--tone-fill-hover) ${INK_ON_FILL}`,
64
+ soft: "bg-(--tone-wash) text-(color:--tone-hue) hover:bg-(--tone-wash-hover)",
65
+ outline: "border-(color:--tone-line) bg-background text-(color:--tone-hue) hover:bg-(--tone-wash)",
66
+ ghost: "text-(color:--tone-hue) hover:bg-(--tone-wash)",
67
+ // No box of its own: a button that reads as a link has to sit on the
68
+ // text baseline, not on a 32px control's centre line.
69
+ link: "h-auto gap-1 rounded-none px-0 py-0 text-(color:--tone-hue) underline-offset-4 hover:underline",
70
+ },
71
+ },
72
+ compoundVariants: [
73
+ { icon: true, size: "xs", class: "size-6" },
74
+ { icon: true, size: "sm", class: "size-7" },
75
+ { icon: true, size: "md", class: "size-8" },
76
+ { icon: true, size: "lg", class: "size-9" },
77
+ { icon: true, size: "xl", class: "size-10" },
78
+ ],
79
+ defaultVariants: {
80
+ variant: "solid",
81
+ size: "md",
82
+ icon: false,
83
+ pill: false,
84
+ },
85
+ });
86
+ /**
87
+ * The class list, for the handful of places that style someone else's element
88
+ * and cannot render a `Button` — a router `Link` inside a `not-found`, a
89
+ * calendar day cell. Takes the same props, including the implied tone.
90
+ */
91
+ export function buttonVariants(props = {}) {
92
+ return button({ tone: props?.tone ?? impliedTone(props?.variant), ...props });
93
+ }
94
+ /**
95
+ * `href` makes the button a link — the anchor is the button, and where the href
96
+ * goes is ../href.ts's decision, not the call site's. `render={<a href="…" />}`
97
+ * did this before, and got a bare anchor: no router, so a CTA reloaded the page
98
+ * and lost the view transition, and an off-site href never grew a `rel`.
99
+ *
100
+ * Either way a non-`<button>` element bypasses the primitive on purpose: Base UI
101
+ * always stamps `type="button"` or `role="button"`, and the latter drops an
102
+ * anchor out of screen-reader link navigation. `render` remains for an element
103
+ * that is genuinely neither — a `<label>`, a menu item.
104
+ */
105
+ export function Button({ className, variant, tone, size, icon, pill, render, nativeButton, href, external, newTab, ...props }) {
106
+ const resolved = tone ?? impliedTone(variant);
107
+ const classes = cn(button({ variant, tone: resolved, size, icon, pill, className }));
108
+ // The resolved axes, stamped: a child can style off its parent's tone, and a
109
+ // test can assert the ramp without asserting a class string.
110
+ const marks = {
111
+ "data-slot": "button",
112
+ "data-variant": variant ?? "solid",
113
+ "data-tone": resolved,
114
+ };
115
+ if (href !== undefined) {
116
+ const { Component, props: link } = resolveLink(href, { external, newTab });
117
+ return (_jsx(Component, { ...marks, ...link, className: classes, ...props }));
118
+ }
119
+ // `render.type !== "button"`: a plain <button/> still goes through the primitive, which
120
+ // is what supplies the native semantics.
121
+ const as = isValidElement(render) && render.type === "button"
122
+ ? null
123
+ : renderAs(render, classes, { ...marks, ...props });
124
+ if (as)
125
+ return as;
126
+ return (_jsx(ButtonPrimitive, { ...marks, className: classes, render: render, nativeButton: nativeButton ?? true, ...props }));
127
+ }
@@ -1,41 +1,5 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
- declare const TONE: {
3
- /** The default: an explanation, not a problem. */
4
- readonly muted: {
5
- readonly box: "border-border bg-muted/40";
6
- readonly title: "text-foreground";
7
- readonly icon: "text-muted-foreground";
8
- readonly rail: "bg-border";
9
- };
10
- /** Something failed and the reader needs to see that it did. */
11
- readonly destructive: {
12
- readonly box: "border-destructive/40 bg-destructive/5";
13
- readonly title: "text-destructive";
14
- readonly icon: "text-destructive";
15
- readonly rail: "bg-destructive/60";
16
- };
17
- /** A prerequisite or a footgun: the reader can still proceed, but not blindly. */
18
- readonly warn: {
19
- readonly box: "border-warn/25 bg-warn/5";
20
- readonly title: "text-warn-ink";
21
- readonly icon: "text-warn";
22
- readonly rail: "bg-warn/60";
23
- };
24
- /** A detail that rewards the reader rather than warning them. */
25
- readonly accent: {
26
- readonly box: "border-primary/25 bg-primary/5";
27
- readonly title: "text-foreground";
28
- readonly icon: "text-primary";
29
- readonly rail: "bg-primary/60";
30
- };
31
- };
32
- /**
33
- * Two densities, because this notice serves two ramps. `compact` is the product
34
- * default (12px title over 12px body, no rail) that the relay sheet and the contact
35
- * record already render. `editorial` is the docs form: body copy at reading size,
36
- * and a 3px accent rail carrying the tone so the surface itself can stay quiet.
37
- * Splitting on a prop rather than forking the component is the point of the file.
38
- */
2
+ import { type Tone } from "../tone.js";
39
3
  export declare function Callout({ icon: Icon, title, tone, density, bodyClassName, action, children, className, }: {
40
4
  /** Injected, so the package needs no icon set. Optional: a notice whose title already reads as a label
41
5
  * ("Replied into Norman's thread") gains nothing from a glyph beside it. */
@@ -43,7 +7,7 @@ export declare function Callout({ icon: Icon, title, tone, density, bodyClassNam
43
7
  className?: string;
44
8
  }>;
45
9
  title?: ReactNode;
46
- tone?: keyof typeof TONE;
10
+ tone?: Tone;
47
11
  density?: "compact" | "editorial";
48
12
  /** For the one body that is not prose — a raw delivery error, which needs mono and its own
49
13
  * line breaks preserved. */
@@ -54,4 +18,3 @@ export declare function Callout({ icon: Icon, title, tone, density, bodyClassNam
54
18
  children: ReactNode;
55
19
  className?: string;
56
20
  }): import("react").JSX.Element;
57
- export {};
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cn } from "../cn.js";
3
+ import { toneClass } from "../tone.js";
3
4
  import { TypographyCaption, TypographyLabel, TypographyMuted, TypographySmall, } from "../typography/paragraph.js";
4
5
  // ---------------------------------------------------------------------------
5
6
  // An inline notice: a titled paragraph that explains something the surface it sits in cannot say
@@ -15,47 +16,21 @@ import { TypographyCaption, TypographyLabel, TypographyMuted, TypographySmall, }
15
16
  // that just happened; these are quiet, permanent explanations sitting inside a panel, and they
16
17
  // must not announce themselves to a screen reader every time a sheet opens.
17
18
  // ---------------------------------------------------------------------------
18
- const TONE = {
19
- /** The default: an explanation, not a problem. */
20
- muted: {
21
- box: "border-border bg-muted/40",
22
- title: "text-foreground",
23
- icon: "text-muted-foreground",
24
- rail: "bg-border",
25
- },
26
- /** Something failed and the reader needs to see that it did. */
27
- destructive: {
28
- box: "border-destructive/40 bg-destructive/5",
29
- title: "text-destructive",
30
- icon: "text-destructive",
31
- rail: "bg-destructive/60",
32
- },
33
- /** A prerequisite or a footgun: the reader can still proceed, but not blindly. */
34
- warn: {
35
- box: "border-warn/25 bg-warn/5",
36
- title: "text-warn-ink",
37
- icon: "text-warn",
38
- rail: "bg-warn/60",
39
- },
40
- /** A detail that rewards the reader rather than warning them. */
41
- accent: {
42
- box: "border-primary/25 bg-primary/5",
43
- title: "text-foreground",
44
- icon: "text-primary",
45
- rail: "bg-primary/60",
46
- },
47
- };
48
19
  /**
49
- * Two densities, because this notice serves two ramps. `compact` is the product
50
- * default (12px title over 12px body, no rail) that the relay sheet and the contact
51
- * record already render. `editorial` is the docs form: body copy at reading size,
52
- * and a 3px accent rail carrying the tone so the surface itself can stay quiet.
53
- * Splitting on a prop rather than forking the component is the point of the file.
20
+ * No tone table of its own: the same seven `Button` and `TypographyLink` take.
21
+ * The four hand-tuned rows this file used to carry are gone, `accent` among them
22
+ * it was `--primary` under another name, which is why a "tip" callout and a
23
+ * "primary" one were indistinguishable. `muted` keeps its name and its values.
24
+ * See ../tone.ts.
25
+ *
26
+ * A callout is a panel, so it tints with `--tone-veil` (5%) where a control uses
27
+ * `--tone-wash` (10%). That is the only thing this file knows about colour.
54
28
  */
29
+ const BOX = "border-(color:--tone-line) bg-(--tone-veil)";
55
30
  export function Callout({ icon: Icon, title, tone = "muted", density = "compact", bodyClassName, action, children, className, }) {
56
- const t = TONE[tone];
31
+ const toned = toneClass(tone);
57
32
  if (density === "editorial") {
58
- return (_jsx("div", { className: cn("relative overflow-hidden rounded-lg border py-3.5 pl-5 pr-4", t.box, className), children: _jsxs("div", { className: "flex items-start gap-2.5", children: [Icon && _jsx(Icon, { className: cn("mt-0.5 size-4 shrink-0", t.icon) }), _jsxs("div", { className: "flex min-w-0 flex-col gap-1", children: [title && (_jsx(TypographyLabel, { className: t.title, children: title })), _jsx(TypographyMuted, { className: cn("leading-relaxed", bodyClassName), children: children }), action && (_jsx("div", { className: "mt-1 flex items-center gap-1", children: action }))] })] }) }));
33
+ return (_jsxs("div", { className: cn("relative overflow-hidden rounded-lg border py-3.5 pl-5 pr-4", toned, BOX, className), children: [_jsx("span", { "aria-hidden": true, className: "absolute inset-y-0 left-0 w-[3px] bg-(--tone-line)" }), _jsxs("div", { className: "flex items-start gap-2.5", children: [Icon && (_jsx(Icon, { className: "mt-0.5 size-4 shrink-0 text-(color:--tone-hue)" })), _jsxs("div", { className: "flex min-w-0 flex-col gap-1", children: [title && (_jsx(TypographyLabel, { className: "text-(color:--tone-hue)", children: title })), _jsx(TypographyMuted, { className: cn("leading-relaxed", bodyClassName), children: children }), action && (_jsx("div", { className: "mt-1 flex items-center gap-1", children: action }))] })] })] }));
59
34
  }
60
- return (_jsxs("div", { className: cn("rounded-md border p-3", t.box, className), children: [title && (_jsxs(TypographySmall, { className: cn("flex items-center gap-1.5 font-medium", t.title), children: [Icon && _jsx(Icon, { className: cn("size-3.5 shrink-0", t.icon) }), title] })), _jsx(TypographyCaption, { className: cn("mt-1 block leading-relaxed", bodyClassName), children: children }), action && _jsx("div", { className: "mt-2 flex items-center gap-1", children: action })] }));
35
+ return (_jsxs("div", { className: cn("rounded-md border p-3", toned, BOX, className), children: [title && (_jsxs(TypographySmall, { className: "flex items-center gap-1.5 font-medium text-(color:--tone-hue)", children: [Icon && _jsx(Icon, { className: "size-3.5 shrink-0" }), title] })), _jsx(TypographyCaption, { className: cn("mt-1 block leading-relaxed", bodyClassName), children: children }), action && _jsx("div", { className: "mt-2 flex items-center gap-1", children: action })] }));
61
36
  }
@@ -1,4 +1,5 @@
1
1
  import type { ComponentProps, ReactNode } from "react";
2
+ import { type LinkBehavior } from "../href.js";
2
3
  /** Two columns from `sm` up: a pair reads as a set rather than two panels. */
3
4
  export declare function Cards({ className, children, ...props }: ComponentProps<"div">): import("react").JSX.Element;
4
5
  export declare function CardHeader({ className, ...props }: ComponentProps<"div">): import("react").JSX.Element;
@@ -18,17 +19,15 @@ type CardShorthand = {
18
19
  title?: ReactNode;
19
20
  description?: ReactNode;
20
21
  icon?: ReactNode;
21
- /** Override the scheme sniff: an absolute URL home, or a relative one away. */
22
- external?: boolean;
23
22
  };
24
23
  /**
25
24
  * Takes either shape: `title`/`href` fills the header, or compose the slots
26
25
  * directly. Unrecognised props pass through — MDX authors reach for the whole
27
- * HTML surface. An href with a scheme leaves the app; the rest route through
28
- * the router's Link.
26
+ * HTML surface. Where the href goes is ../href.ts's call, the same one Button
27
+ * and TypographyLink make.
29
28
  */
30
- export declare function Card({ href, className, external, title, description, icon, children, ...rest }: CardShorthand & {
29
+ export declare function Card({ href, className, external, newTab, title, description, icon, children, ...rest }: CardShorthand & LinkBehavior & {
31
30
  href?: string;
32
31
  children?: ReactNode;
33
- } & Omit<ComponentProps<"a">, keyof CardShorthand | "href" | "children">): import("react").JSX.Element;
32
+ } & Omit<ComponentProps<"a">, keyof CardShorthand | keyof LinkBehavior | "href" | "children">): import("react").JSX.Element;
34
33
  export {};