@supertype.ai/foundations 0.1.29 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.29"
69
+ "@supertype.ai/foundations": "https://github.com/supertypeai/foundations.git#v0.1.31"
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 -->
@@ -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 { INK_ON_CARD } 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", INK_ON_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
60
  }
@@ -0,0 +1,27 @@
1
+ import type { ComponentProps } from "react";
2
+ import { type LinkBehavior } from "../href.js";
3
+ /**
4
+ * An anchor whose destination is this package's decision rather than the call
5
+ * site's — and nothing else.
6
+ *
7
+ * `Button`, `Badge`, `Card` and `TypographyLink` all take an `href` and hand it
8
+ * to ../href.ts. What was left over was every anchor that is none of those: a
9
+ * thumbnail, a chip, a tooltip trigger, a footer row, an icon in a dialog
10
+ * header. One consumer had thirty-six, each writing `target="_blank"
11
+ * rel="noopener noreferrer"` out by hand, five of them missing the `rel`, four
12
+ * of them putting the pair on a router `Link` — which asks for a client
13
+ * navigation and a new tab in the same breath.
14
+ *
15
+ * No styling, deliberately. `TypographyLink` is the inline link and brings a
16
+ * weight, an ink and an underline with it, which is why it could not take these:
17
+ * they wrap something already drawn, and the only thing they have in common is
18
+ * where they go.
19
+ *
20
+ * Pass `external` for a same-origin path that is not a route — an `/api/…`
21
+ * redirect, a file endpoint. `Link` prefetches on viewport entry, so a signed-URL
22
+ * endpoint would mint one for a link nobody clicked. Call-site props land after
23
+ * the resolved ones, so a link needing a `rel` of its own can still say so.
24
+ */
25
+ export declare function Anchor({ href, external, newTab, ...props }: Omit<ComponentProps<"a">, "href"> & LinkBehavior & {
26
+ href: string;
27
+ }): import("react").JSX.Element;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { resolveLink } from "../href.js";
3
+ /**
4
+ * An anchor whose destination is this package's decision rather than the call
5
+ * site's — and nothing else.
6
+ *
7
+ * `Button`, `Badge`, `Card` and `TypographyLink` all take an `href` and hand it
8
+ * to ../href.ts. What was left over was every anchor that is none of those: a
9
+ * thumbnail, a chip, a tooltip trigger, a footer row, an icon in a dialog
10
+ * header. One consumer had thirty-six, each writing `target="_blank"
11
+ * rel="noopener noreferrer"` out by hand, five of them missing the `rel`, four
12
+ * of them putting the pair on a router `Link` — which asks for a client
13
+ * navigation and a new tab in the same breath.
14
+ *
15
+ * No styling, deliberately. `TypographyLink` is the inline link and brings a
16
+ * weight, an ink and an underline with it, which is why it could not take these:
17
+ * they wrap something already drawn, and the only thing they have in common is
18
+ * where they go.
19
+ *
20
+ * Pass `external` for a same-origin path that is not a route — an `/api/…`
21
+ * redirect, a file endpoint. `Link` prefetches on viewport entry, so a signed-URL
22
+ * endpoint would mint one for a link nobody clicked. Call-site props land after
23
+ * the resolved ones, so a link needing a `rel` of its own can still say so.
24
+ */
25
+ export function Anchor({ href, external, newTab, ...props }) {
26
+ const { Component, props: link } = resolveLink(href, { external, newTab });
27
+ return _jsx(Component, { ...link, ...props });
28
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ComponentProps, ReactElement } from "react";
2
2
  import { type VariantProps } from "class-variance-authority";
3
+ import { type LinkBehavior } from "../href.js";
3
4
  declare const badge: (props?: ({
4
5
  tone?: "muted" | "primary" | "secondary" | "brand" | "success" | "warn" | "destructive" | null | undefined;
5
6
  size?: "sm" | "xs" | null | undefined;
@@ -9,7 +10,8 @@ declare const badge: (props?: ({
9
10
  export type BadgeLook = VariantProps<typeof badge>;
10
11
  export declare function badgeVariants(props?: Parameters<typeof badge>[0]): string;
11
12
  /**
12
- * A `span` unless `render` says otherwise cloned rather than run through a
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
13
15
  * `useRender` hook, which is what viably's badge did. A hook would make every
14
16
  * badge in the tree a client component to serve the one call site that renders
15
17
  * an anchor, and a badge is a label: it should cost nothing on the server. This
@@ -18,7 +20,10 @@ export declare function badgeVariants(props?: Parameters<typeof badge>[0]): stri
18
20
  * The `[a]:hover` rules above light up on their own when an anchor is the parent
19
21
  * or the rendered element.
20
22
  */
21
- export declare function Badge({ className, variant, tone, size, pill, render, ...props }: ComponentProps<"span"> & BadgeLook & {
23
+ export declare function Badge({ className, variant, tone, size, pill, render, href, external, newTab, ...props }: ComponentProps<"span"> & BadgeLook & LinkBehavior & {
22
24
  render?: ReactElement;
25
+ href?: string;
26
+ /** A link that is not a navigation. See Button, which documents the pairing with `external`. */
27
+ download?: ComponentProps<"a">["download"];
23
28
  }): import("react").JSX.Element;
24
29
  export {};
@@ -2,8 +2,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cva } from "class-variance-authority";
3
3
  import { cn } from "../cn.js";
4
4
  import { renderAs } from "./render-as.js";
5
+ import { resolveLink } from "../href.js";
5
6
  import { FOCUS_RING } from "./focus.js";
6
- import { TONE, TONE_SURFACE, impliedTone } from "../tone.js";
7
+ import { INK_ON_FILL, TONE, TONE_SURFACE, impliedTone } from "../tone.js";
7
8
  // ---------------------------------------------------------------------------
8
9
  // A label that is not a control. Same two axes as Button, and for the same
9
10
  // reason: the two apps had each grown their own list, and the lists disagreed
@@ -44,7 +45,7 @@ const badge = cva(cn("inline-flex w-fit shrink-0 items-center justify-center gap
44
45
  },
45
46
  pill: { true: "rounded-full", false: "" },
46
47
  variant: {
47
- solid: "bg-(--tone-fill) text-(color:--tone-ink) [a]:hover:bg-(--tone-fill-hover)",
48
+ solid: `bg-(--tone-fill) text-(color:--tone-ink) [a]:hover:bg-(--tone-fill-hover) ${INK_ON_FILL}`,
48
49
  soft: "bg-(--tone-wash) text-(color:--tone-hue) [a]:hover:bg-(--tone-wash-hover)",
49
50
  outline: "border-(color:--tone-line) text-(color:--tone-hue) [a]:hover:bg-(--tone-wash)",
50
51
  ghost: "text-(color:--tone-hue) hover:bg-(--tone-wash)",
@@ -56,7 +57,8 @@ export function badgeVariants(props = {}) {
56
57
  return badge({ tone: props?.tone ?? impliedTone(props?.variant), ...props });
57
58
  }
58
59
  /**
59
- * A `span` unless `render` says otherwise cloned rather than run through a
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
60
62
  * `useRender` hook, which is what viably's badge did. A hook would make every
61
63
  * badge in the tree a client component to serve the one call site that renders
62
64
  * an anchor, and a badge is a label: it should cost nothing on the server. This
@@ -65,7 +67,7 @@ export function badgeVariants(props = {}) {
65
67
  * The `[a]:hover` rules above light up on their own when an anchor is the parent
66
68
  * or the rendered element.
67
69
  */
68
- export function Badge({ className, variant, tone, size, pill, render, ...props }) {
70
+ export function Badge({ className, variant, tone, size, pill, render, href, external, newTab, ...props }) {
69
71
  const resolved = tone ?? impliedTone(variant);
70
72
  const classes = cn(badge({ variant, tone: resolved, size, pill, className }));
71
73
  const marks = {
@@ -73,6 +75,10 @@ export function Badge({ className, variant, tone, size, pill, render, ...props }
73
75
  "data-variant": variant ?? "solid",
74
76
  "data-tone": resolved,
75
77
  };
78
+ if (href !== undefined) {
79
+ const { Component, props: link } = resolveLink(href, { external, newTab });
80
+ return (_jsx(Component, { ...marks, ...link, className: classes, ...props }));
81
+ }
76
82
  const as = renderAs(render, classes, { ...marks, ...props });
77
83
  if (as)
78
84
  return as;
@@ -1,5 +1,7 @@
1
+ import { type ComponentProps } from "react";
1
2
  import { Button as ButtonPrimitive } from "@base-ui/react/button";
2
3
  import { type VariantProps } from "class-variance-authority";
4
+ import { type LinkBehavior } from "../href.js";
3
5
  declare const button: (props?: ({
4
6
  tone?: "muted" | "primary" | "secondary" | "brand" | "success" | "warn" | "destructive" | null | undefined;
5
7
  size?: "sm" | "xs" | "md" | "lg" | "xl" | null | undefined;
@@ -15,10 +17,29 @@ export type ButtonLook = VariantProps<typeof button>;
15
17
  */
16
18
  export declare function buttonVariants(props?: Parameters<typeof button>[0]): string;
17
19
  /**
18
- * A non-`<button>` render element bypasses the primitive on purpose: Base UI
20
+ * `href` makes the button a link the anchor is the button, and where the href
21
+ * goes is ../href.ts's decision, not the call site's. `render={<a href="…" />}`
22
+ * did this before, and got a bare anchor: no router, so a CTA reloaded the page
23
+ * and lost the view transition, and an off-site href never grew a `rel`.
24
+ *
25
+ * Either way a non-`<button>` element bypasses the primitive on purpose: Base UI
19
26
  * always stamps `type="button"` or `role="button"`, and the latter drops an
20
- * anchor out of screen-reader link navigation. Cloning gives it the classes and
21
- * nothing else.
27
+ * anchor out of screen-reader link navigation. `render` remains for an element
28
+ * that is genuinely neither — a `<label>`, a menu item.
22
29
  */
23
- export declare function Button({ className, variant, tone, size, icon, pill, render, nativeButton, ...props }: ButtonPrimitive.Props & ButtonLook): import("react").JSX.Element;
30
+ export declare function Button({ className, variant, tone, size, icon, pill, render, nativeButton, href, external, newTab, ...props }: ButtonPrimitive.Props & ButtonLook & LinkBehavior & {
31
+ href?: string;
32
+ /**
33
+ * The one anchor attribute the `href` branch has to name itself. A download
34
+ * is a link that is not a navigation, so it is the case `href` alone cannot
35
+ * express — and `render={<a download />}`, which is how every call site said
36
+ * it before, is exactly what `linkRules()` now flags. Card already takes it,
37
+ * off `ComponentProps<"a">`; Button and Badge are anchors here too.
38
+ *
39
+ * Pair it with `external` for a same-origin route. `Link` steps aside on the
40
+ * click, but it still prefetches the href on viewport entry, which for an
41
+ * export endpoint means running the export to throw the rows away.
42
+ */
43
+ download?: ComponentProps<"a">["download"];
44
+ }): import("react").JSX.Element;
24
45
  export {};
@@ -4,8 +4,9 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button";
4
4
  import { cva } from "class-variance-authority";
5
5
  import { cn } from "../cn.js";
6
6
  import { renderAs } from "./render-as.js";
7
+ import { resolveLink } from "../href.js";
7
8
  import { FOCUS_RING } from "./focus.js";
8
- import { TONE, TONE_SURFACE, impliedTone } from "../tone.js";
9
+ import { INK_ON_FILL, TONE, TONE_SURFACE, impliedTone } from "../tone.js";
9
10
  // ---------------------------------------------------------------------------
10
11
  // The control both apps were re-declaring. viably and ssite each carried their
11
12
  // own `cva` with its own variant list — `default | secondary | accent |
@@ -59,7 +60,7 @@ cn(FOCUS_RING, "focus-visible:border-ring"), "active:not-aria-[haspopup]:transla
59
60
  /** Full-round corners. Marketing surfaces; also every filter chip. */
60
61
  pill: { true: "rounded-full", false: "" },
61
62
  variant: {
62
- solid: "bg-(--tone-fill) text-(color:--tone-ink) hover:bg-(--tone-fill-hover)",
63
+ solid: `bg-(--tone-fill) text-(color:--tone-ink) hover:bg-(--tone-fill-hover) ${INK_ON_FILL}`,
63
64
  soft: "bg-(--tone-wash) text-(color:--tone-hue) hover:bg-(--tone-wash-hover)",
64
65
  outline: "border-(color:--tone-line) bg-background text-(color:--tone-hue) hover:bg-(--tone-wash)",
65
66
  ghost: "text-(color:--tone-hue) hover:bg-(--tone-wash)",
@@ -91,12 +92,17 @@ export function buttonVariants(props = {}) {
91
92
  return button({ tone: props?.tone ?? impliedTone(props?.variant), ...props });
92
93
  }
93
94
  /**
94
- * A non-`<button>` render element bypasses the primitive on purpose: Base UI
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
95
101
  * always stamps `type="button"` or `role="button"`, and the latter drops an
96
- * anchor out of screen-reader link navigation. Cloning gives it the classes and
97
- * nothing else.
102
+ * anchor out of screen-reader link navigation. `render` remains for an element
103
+ * that is genuinely neither — a `<label>`, a menu item.
98
104
  */
99
- export function Button({ className, variant, tone, size, icon, pill, render, nativeButton, ...props }) {
105
+ export function Button({ className, variant, tone, size, icon, pill, render, nativeButton, href, external, newTab, ...props }) {
100
106
  const resolved = tone ?? impliedTone(variant);
101
107
  const classes = cn(button({ variant, tone: resolved, size, icon, pill, className }));
102
108
  // The resolved axes, stamped: a child can style off its parent's tone, and a
@@ -106,6 +112,10 @@ export function Button({ className, variant, tone, size, icon, pill, render, nat
106
112
  "data-variant": variant ?? "solid",
107
113
  "data-tone": resolved,
108
114
  };
115
+ if (href !== undefined) {
116
+ const { Component, props: link } = resolveLink(href, { external, newTab });
117
+ return (_jsx(Component, { ...marks, ...link, className: classes, ...props }));
118
+ }
109
119
  // `render.type !== "button"`: a plain <button/> still goes through the primitive, which
110
120
  // is what supplies the native semantics.
111
121
  const as = isValidElement(render) && render.type === "button"
@@ -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 {};
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { cn } from "../cn.js";
3
3
  import { FOCUS_RING } from "./focus.js";
4
- import { toneClass } from "../tone.js";
5
- import { Link } from "next-view-transitions";
4
+ import { INK_ON_CARD, toneClass } from "../tone.js";
5
+ import { resolveLink } from "../href.js";
6
6
  /** Two columns from `sm` up: a pair reads as a set rather than two panels. */
7
7
  export function Cards({ className, children, ...props }) {
8
8
  return (_jsx("div", { className: cn("my-6 grid gap-4 sm:grid-cols-2", className), ...props, children: children }));
@@ -12,7 +12,7 @@ export function Cards({ className, children, ...props }) {
12
12
  * grid and `overflow-hidden` clips a bleed image cleanly. Padding is vertical
13
13
  * only — the horizontal inset belongs to the slots, so bands can run edge to edge.
14
14
  */
15
- const CARD_CLASS = "flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-border " +
15
+ const CARD_CLASS = `flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-border ${INK_ON_CARD} ` +
16
16
  "has-[>img:first-child]:pt-0 " +
17
17
  "*:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl";
18
18
  /**
@@ -49,10 +49,10 @@ export function CardContent({ className, ...props }) {
49
49
  /**
50
50
  * Takes either shape: `title`/`href` fills the header, or compose the slots
51
51
  * directly. Unrecognised props pass through — MDX authors reach for the whole
52
- * HTML surface. An href with a scheme leaves the app; the rest route through
53
- * the router's Link.
52
+ * HTML surface. Where the href goes is ../href.ts's call, the same one Button
53
+ * and TypographyLink make.
54
54
  */
55
- export function Card({ href, className, external, title, description, icon, children, ...rest }) {
55
+ export function Card({ href, className, external, newTab, title, description, icon, children, ...rest }) {
56
56
  const header = title || description || icon ? (_jsxs(CardHeader, { children: [icon || title ? (
57
57
  // The icon sits on the title's line and is its mark; stacked, it read as a
58
58
  // decoration the title happened to follow. `gap-2` is a gap between two
@@ -67,10 +67,6 @@ export function Card({ href, className, external, title, description, icon, chil
67
67
  if (!href) {
68
68
  return (_jsx("div", { className: cn(CARD_CLASS, className), ...shared, ...rest, children: body }));
69
69
  }
70
- const leavesApp = external ?? /^[a-z][a-z0-9+.-]*:/i.test(href);
71
- const classes = cn(CARD_CLASS, CARD_LINK_CLASS, className);
72
- if (leavesApp) {
73
- return (_jsx("a", { href: href, className: classes, target: "_blank", rel: "noopener noreferrer", ...shared, ...rest, children: body }));
74
- }
75
- return (_jsx(Link, { href: href, className: classes, ...shared, ...rest, children: body }));
70
+ const { Component, props: link } = resolveLink(href, { external, newTab });
71
+ return (_jsx(Component, { className: cn(CARD_CLASS, CARD_LINK_CLASS, className), ...link, ...shared, ...rest, children: body }));
76
72
  }
@@ -1,6 +1,7 @@
1
1
  export { Cards, Card, CardHeader, CardTitle, CardDescription, CardContent, } from "./card.js";
2
2
  export { Disclosure, DisclosureGroup } from "./accordion.js";
3
3
  export { Accordion, AccordionItem, AccordionTrigger, AccordionContent, } from "./interactive-accordion.js";
4
+ export { Anchor } from "./anchor.js";
4
5
  export { Callout } from "./callout.js";
5
6
  export { Button, buttonVariants, type ButtonLook } from "./button.js";
6
7
  export { Badge, badgeVariants, type BadgeLook } from "./badge.js";
@@ -1,6 +1,7 @@
1
1
  export { Cards, Card, CardHeader, CardTitle, CardDescription, CardContent, } from "./card.js";
2
2
  export { Disclosure, DisclosureGroup } from "./accordion.js";
3
3
  export { Accordion, AccordionItem, AccordionTrigger, AccordionContent, } from "./interactive-accordion.js";
4
+ export { Anchor } from "./anchor.js";
4
5
  export { Callout } from "./callout.js";
5
6
  export { Button, buttonVariants } from "./button.js";
6
7
  export { Badge, badgeVariants } from "./badge.js";
@@ -36,17 +36,17 @@ export type TabItem = {
36
36
  * An element and not a component, which this briefly also took. `TabGroup` is a client
37
37
  * component, so a component reference handed to it from a server page is a function
38
38
  * crossing the RSC boundary, which React refuses at render; an element is already
39
- * rendered and crosses fine. One accepted shape also spares the slot a branch, and it
40
- * is the shape `Card`'s `icon` has always taken.
39
+ * rendered and crosses fine. One accepted shape also spares the slot a branch, and
40
+ * matches what `Card`'s `icon` has always taken.
41
41
  */
42
42
  icon?: ReactNode;
43
43
  content: ReactNode;
44
44
  };
45
45
  /**
46
46
  * The declarative shorthand: the tabs as data. `TabGroup` is to `Tabs` what
47
- * `DisclosureGroup` is to `Accordion`, and it is the shape to reach for an app that
48
- * rebuilds it over the primitives ends up re-adding the icon, the change handler and the
49
- * stable value by hand.
47
+ * `DisclosureGroup` is to `Accordion`: the shape to reach for. An app that rebuilds it
48
+ * over the primitives ends up re-adding the icon, the change handler and the stable value
49
+ * by hand.
50
50
  *
51
51
  * Everything below the adapter is the same component the product surfaces use, so a tab
52
52
  * strip in the docs and one on a dashboard behave identically. The positional
@@ -100,9 +100,9 @@ function TabIconSlot({ icon, position, }) {
100
100
  }
101
101
  /**
102
102
  * The declarative shorthand: the tabs as data. `TabGroup` is to `Tabs` what
103
- * `DisclosureGroup` is to `Accordion`, and it is the shape to reach for an app that
104
- * rebuilds it over the primitives ends up re-adding the icon, the change handler and the
105
- * stable value by hand.
103
+ * `DisclosureGroup` is to `Accordion`: the shape to reach for. An app that rebuilds it
104
+ * over the primitives ends up re-adding the icon, the change handler and the stable value
105
+ * by hand.
106
106
  *
107
107
  * Everything below the adapter is the same component the product surfaces use, so a tab
108
108
  * strip in the docs and one on a dashboard behave identically. The positional
@@ -9,6 +9,7 @@ exports.colourRules = colourRules;
9
9
  exports.themeOverrideRules = themeOverrideRules;
10
10
  exports.surfaceAsInkRules = surfaceAsInkRules;
11
11
  exports.renamedTokenRules = renamedTokenRules;
12
+ exports.linkRules = linkRules;
12
13
  exports.typographyRules = typographyRules;
13
14
  exports.designRules = designRules;
14
15
  exports.designConfig = designConfig;
@@ -48,22 +49,49 @@ function themeOverrideRules() {
48
49
  return rule(`/(^| )dark:(${COLOUR_PREFIX})-(${TOKEN})($| )/`, "A `dark:` override on a token means the token is wrong — fix it in theme.css, where one change covers every call site, rather than here. Alpha variants (dark:bg-destructive/20) stay legal: those tune a wash's density, not the token.");
49
50
  }
50
51
  /**
51
- * `--muted` is a fill at L92%, so `text-muted` is ~1.1:1 — invisible, and it
52
- * shipped at 17 sites. `text-background` is absent: inverse ink is a real role.
52
+ * `--muted` is a fill at L92%, so `text-muted` lands at ~1.1:1. Invisible, and
53
+ * it shipped at 17 sites. `text-background` stays legal: inverse ink is a real
54
+ * role.
53
55
  */
54
56
  function surfaceAsInkRules() {
55
57
  return rule("/(^| )(dark:|hover:|focus:|group-hover:)*text-(muted|card|popover|input)($| )/", "That is a surface token, not an ink — as text it has no defined contrast (text-muted measures ~1.1:1 on a light page). Use text-muted-foreground for secondary ink, text-subtle-foreground for tertiary, or text-card-foreground on a card.");
56
58
  }
57
59
  /**
58
60
  * `-foreground` means the label printed on a fill; `-ink` means the hue as
59
- * words. `warn-foreground` and the eight categorical `-foreground` tokens were
60
- * always inks, under the other name. The old spellings still resolve, so nothing
61
- * breaks on the day of the rename; this is what stops them surviving it.
61
+ * words. The eight categorical `-foreground` tokens were always inks, under the
62
+ * other name. The old spellings still resolve, so nothing breaks on the day of
63
+ * the rename; this is what stops them surviving it.
64
+ *
65
+ * `warn` left this list when the status tones gained real on-fill labels:
66
+ * `--warn-foreground` now means what its name says, the ink printed on the warn
67
+ * fill, and `Button tone="warn" variant="solid"` is what reads it.
62
68
  */
63
- const RENAMED_INKS = "warn|terracotta|ochre|moss|fern|sage|stone|fig|cocoa";
69
+ const RENAMED_INKS = "terracotta|ochre|moss|fern|sage|stone|fig|cocoa";
64
70
  function renamedTokenRules() {
65
71
  return rule(`/(^| )(dark:|hover:|focus:|group-hover:)*(text|bg|border|ring|fill|stroke|decoration)-(${RENAMED_INKS})-foreground($| )/`, "That is the deprecated name for the same hue's `-ink`. In this package `-foreground` is the label printed on a fill and `-ink` is the hue used as words, and none of these hues has a printed-on label — they are checked at 4.5:1 against the page, and printing one on its own fill measures about 1.2:1. Use `-ink`.");
66
72
  }
73
+ /**
74
+ * `render={<a href="…" />}` on a component that takes an `href`. It reads as a
75
+ * styling choice and is a routing one: the cloned anchor skips the router, so
76
+ * the page fully reloads and the view transition is lost, and an off-site href
77
+ * never grows a `rel`. Button, Badge and Card each decide internal vs external
78
+ * from the href itself, so the anchor is never needed and cannot be right more
79
+ * often than the one shared rule is.
80
+ *
81
+ * Narrow on both axes, so it never fires on a line that is correct. Only those
82
+ * three components — `RailLink` deliberately takes a router element through
83
+ * `render`, because its module has to stay importable without Next. And only a
84
+ * bare `<a>`: `render={<Link/>}` is redundant beside `href` but it still routes,
85
+ * so it is not a bug.
86
+ */
87
+ function linkRules() {
88
+ return [
89
+ {
90
+ selector: 'JSXOpeningElement[name.name=/^(Button|Badge|Card)$/] > JSXAttribute[name.name="render"] > JSXExpressionContainer > JSXElement > JSXOpeningElement[name.name="a"]',
91
+ message: "Pass `href` instead of rendering an anchor. A cloned <a> bypasses the router (full page load, no view transition) and gets no rel on an off-site href; `href` routes through the package's one rule. `render` is for an element that is not a link.",
92
+ },
93
+ ];
94
+ }
67
95
  function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2xs 11 / text-xs 12 / text-sm 14 / text-base 16 and up", pairing = false, axis = false, } = {}) {
68
96
  return [
69
97
  // Alpha ink composites against whatever surface it lands on, so its
@@ -109,7 +137,7 @@ function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2xs 11 /
109
137
  ? [
110
138
  {
111
139
  selector: 'JSXElement:has(>JSXOpeningElement[name.name="TypographyP"]) ~ JSXElement > JSXOpeningElement[name.name="TypographyProseList"]',
112
- message: "A ui paragraph over a prose list splits one passage across two rungs. Promote the paragraph with TypographyProse, or drop the list to the paragraph's rung with TypographyList variant=\"ui\".",
140
+ message: 'A ui paragraph over a prose list splits one passage across two rungs. Promote the paragraph with TypographyProse, or drop the list to the paragraph\'s rung with TypographyList variant="ui".',
113
141
  },
114
142
  ]
115
143
  : []),
@@ -122,6 +150,7 @@ function designRules({ accents, typography = true, ...type } = {}) {
122
150
  return [
123
151
  ...colourRules({ accents }),
124
152
  ...(typography ? typographyRules(type) : []),
153
+ ...linkRules(),
125
154
  ...themeOverrideRules(),
126
155
  ...surfaceAsInkRules(),
127
156
  ...renamedTokenRules(),
@@ -145,10 +174,7 @@ function designConfig({ files = ["**/*.{ts,tsx,js,jsx}"], ...options } = {}) {
145
174
  name: "@supertype.ai/foundations/design",
146
175
  files,
147
176
  rules: {
148
- "no-restricted-syntax": [
149
- "error",
150
- ...designRules(options),
151
- ],
177
+ "no-restricted-syntax": ["error", ...designRules(options)],
152
178
  },
153
179
  },
154
180
  ];