@supertype.ai/foundations 0.1.30 → 0.1.32

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
@@ -66,7 +66,7 @@ untagged git dependency resolves to a different commit on a fresh install.
66
66
 
67
67
  ```jsonc
68
68
  // package.json
69
- "@supertype.ai/foundations": "https://github.com/supertypeai/foundations.git#v0.1.30"
69
+ "@supertype.ai/foundations": "https://github.com/supertypeai/foundations.git#v0.1.32"
70
70
  ```
71
71
 
72
72
  </details>
@@ -1,7 +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
+ import { INK_ON_CARD } from "../tone.js";
5
5
  /**
6
6
  * A disclosure group: `<details>`/`<summary>`, no JS, correct before hydration.
7
7
  *
@@ -56,5 +56,5 @@ function deriveGroupName(children) {
56
56
  return `accordion-${(hash >>> 0).toString(36)}`;
57
57
  }
58
58
  export function Disclosure({ title, children, className, ...props }) {
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
+ 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 })] }));
60
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
+ }
@@ -23,5 +23,7 @@ export declare function badgeVariants(props?: Parameters<typeof badge>[0]): stri
23
23
  export declare function Badge({ className, variant, tone, size, pill, render, href, external, newTab, ...props }: ComponentProps<"span"> & BadgeLook & LinkBehavior & {
24
24
  render?: ReactElement;
25
25
  href?: string;
26
+ /** A link that is not a navigation. See Button, which documents the pairing with `external`. */
27
+ download?: ComponentProps<"a">["download"];
26
28
  }): import("react").JSX.Element;
27
29
  export {};
@@ -1,3 +1,4 @@
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";
3
4
  import { type LinkBehavior } from "../href.js";
@@ -6,7 +7,7 @@ declare const button: (props?: ({
6
7
  size?: "sm" | "xs" | "md" | "lg" | "xl" | null | undefined;
7
8
  icon?: boolean | null | undefined;
8
9
  pill?: boolean | null | undefined;
9
- variant?: "link" | "solid" | "soft" | "outline" | "ghost" | null | undefined;
10
+ variant?: "solid" | "link" | "soft" | "outline" | "ghost" | null | undefined;
10
11
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
11
12
  export type ButtonLook = VariantProps<typeof button>;
12
13
  /**
@@ -28,5 +29,17 @@ export declare function buttonVariants(props?: Parameters<typeof button>[0]): st
28
29
  */
29
30
  export declare function Button({ className, variant, tone, size, icon, pill, render, nativeButton, href, external, newTab, ...props }: ButtonPrimitive.Props & ButtonLook & LinkBehavior & {
30
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"];
31
44
  }): import("react").JSX.Element;
32
45
  export {};
@@ -62,7 +62,7 @@ cn(FOCUS_RING, "focus-visible:border-ring"), "active:not-aria-[haspopup]:transla
62
62
  variant: {
63
63
  solid: `bg-(--tone-fill) text-(color:--tone-ink) hover:bg-(--tone-fill-hover) ${INK_ON_FILL}`,
64
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)",
65
+ outline: "border-(color:--tone-line) text-(color:--tone-hue) hover:bg-(--tone-wash)",
66
66
  ghost: "text-(color:--tone-hue) hover:bg-(--tone-wash)",
67
67
  // No box of its own: a button that reads as a link has to sit on the
68
68
  // text baseline, not on a 32px control's centre line.
@@ -12,6 +12,10 @@ import { TypographyCaption, TypographyLabel, TypographyMuted, TypographySmall, }
12
12
  // already drifted on the radius. Hand-rolled type styles are also exactly what the project's own
13
13
  // guidance forbids, and six copies is how a rule like that gets broken without anyone deciding to.
14
14
  //
15
+ // The body is a slot, so it renders as a div in both densities. A caller passes a list, a
16
+ // pair of paragraphs or a mono block of delivery errors, none of which may sit inside a <p>:
17
+ // the parser closes it early and React reports a hydration error on a callout that looks fine.
18
+ //
15
19
  // Deliberately not a shadcn Alert. Alert is a page-level, role="alert" affordance for something
16
20
  // that just happened; these are quiet, permanent explanations sitting inside a panel, and they
17
21
  // must not announce themselves to a screen reader every time a sheet opens.
@@ -30,7 +34,7 @@ const BOX = "border-(color:--tone-line) bg-(--tone-veil)";
30
34
  export function Callout({ icon: Icon, title, tone = "muted", density = "compact", bodyClassName, action, children, className, }) {
31
35
  const toned = toneClass(tone);
32
36
  if (density === "editorial") {
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 }))] })] })] }));
37
+ 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, { as: "div", className: cn("leading-relaxed", bodyClassName), children: children }), action && (_jsx("div", { className: "mt-1 flex items-center gap-1", children: action }))] })] })] }));
34
38
  }
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 })] }));
39
+ 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, { as: "div", className: cn("mt-1 leading-relaxed", bodyClassName), children: children }), action && _jsx("div", { className: "mt-2 flex items-center gap-1", children: action })] }));
36
40
  }
@@ -1,7 +1,7 @@
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 { inkOnSurface, toneClass } from "../tone.js";
4
+ import { INK_ON_CARD, toneClass } from "../tone.js";
5
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 }) {
@@ -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 ${inkOnSurface("--card-foreground")} ` +
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
  /**
@@ -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";
@@ -45,6 +45,13 @@ export interface LegibilityFailure {
45
45
  surface: string;
46
46
  ratio: number;
47
47
  required: number;
48
+ /**
49
+ * The token the pair was *supposed* to use, set only when it was undeclared
50
+ * and the cascade fell through to the next link of its `var()` chain. Without
51
+ * it a report reads as though the app chose `--primary-foreground` for its
52
+ * brand fill, when in truth it chose nothing and CSS chose for it.
53
+ */
54
+ via?: string;
48
55
  }
49
56
  /**
50
57
  * Every ink on every surface, both themes. Missing or non-literal tokens are
package/dist/contrast.js CHANGED
@@ -3,6 +3,12 @@
3
3
  * and `.dark` separately measures an intention, and ssite shipped a `.dark` at a
4
4
  * healthy 15.7:1 while the page rendered white on white. Build-time only.
5
5
  */
6
+ // The tone vocabulary, for the pairs it names. Value import, not just a type:
7
+ // this file measures what `TONE` declares rather than keeping a second list of
8
+ // it. tone.js resolves no React and imports nothing at runtime, so the bare-Node
9
+ // contract this entry point owes still holds — `check-candidates` and the CLI
10
+ // both import it from plain Node.
11
+ import { TONE } from "./tone.js";
6
12
  /**
7
13
  * Specificity over what token blocks use. `:root` and `.dark` both score 1 —
8
14
  * the tie above. `:not(…)` contributes its argument's score, per spec.
@@ -290,17 +296,89 @@ const TERTIARY = ["--subtle-foreground"];
290
296
  * tone="warn" variant="solid"` renders one, and white on amber measured 2.44:1
291
297
  * on the dark theme for as long as the pair went unnamed here.
292
298
  */
293
- const ON_FILL = [
294
- ["--primary", "--primary-foreground"],
295
- ["--secondary", "--secondary-foreground"],
296
- ["--destructive", "--destructive-foreground"],
297
- ["--success", "--success-foreground"],
298
- ["--warn", "--warn-foreground"],
299
+ const ON_SURFACE = [
299
300
  ["--accent", "--accent-foreground"],
300
301
  ["--card", "--card-foreground"],
301
302
  ["--popover", "--popover-foreground"],
302
303
  ["--sidebar", "--sidebar-foreground"],
303
304
  ];
305
+ /**
306
+ * Every cut a tone names, read off `TONE` instead of restated here.
307
+ *
308
+ * The tone rows used to be five hand-written pairs in the list above, which is
309
+ * the arrangement the comment on `tokenCuts` warns about: a palette checked
310
+ * against one taxonomy and declared from another drifts, and `brand` is the
311
+ * proof. It was in `TONE` from the day the vocabulary landed and never in this
312
+ * file, so the one tone whose tokens an app supplies was the one tone nothing
313
+ * measured.
314
+ *
315
+ * Parsed rather than shared as data because `TONE` has to stay a table of
316
+ * literal class strings — Tailwind scans this package as text and generates
317
+ * only the classes it can read, so a row assembled from a record would style
318
+ * nothing. Parsing the literal keeps one declaration; a second table would be
319
+ * the drift all over again.
320
+ */
321
+ const TONE_CUT = /\[--tone-(fill|ink|hue):([^\]]*)\]/g;
322
+ /**
323
+ * A cut's `var()` fallback chain, outermost first: `var(--brand,var(--primary))`
324
+ * is `["--brand", "--primary"]`. The order is the order CSS tries them in.
325
+ */
326
+ const toneChains = (classes) => {
327
+ const chains = { fill: [], ink: [], hue: [] };
328
+ for (const [, cut, value] of classes.matchAll(TONE_CUT))
329
+ chains[cut] = [...value.matchAll(/--[a-z0-9-]+/g)].map((m) => m[0]);
330
+ return chains;
331
+ };
332
+ const TONE_CHAINS = Object.entries(TONE).map(([tone, classes]) => [tone, toneChains(classes)]);
333
+ /** The pair a tone names, for the taxonomy `tokenCuts` reports. */
334
+ const ON_FILL = [
335
+ ...TONE_CHAINS.map(([, c]) => [c.fill[0], c.ink[0]]),
336
+ ...ON_SURFACE,
337
+ ];
338
+ /**
339
+ * A tone's label on a tone's fill, resolved the way a browser resolves it.
340
+ *
341
+ * `checkLegibility` skips a token it cannot find, and that is right: an app that
342
+ * declares no `--sidebar` has not failed a bar, it has declined a role. But a
343
+ * token missing *while its siblings are present* is a different animal. The tone
344
+ * still renders — `var(--brand-foreground,var(--primary-foreground))` simply
345
+ * moves to the next link — so the control is painted in a pair the app never
346
+ * chose and skipping it reads as a pass.
347
+ *
348
+ * That is how a bronze `--brand` shipped with a white label at 2.80:1: the fill
349
+ * was declared, the label was not, and nothing measured the pair that actually
350
+ * reached the screen. So this follows the chain to whichever link is really
351
+ * there, and measures that. An app declaring a whole tone is measured on its own
352
+ * tokens; one declaring none falls through to the package's, which are measured
353
+ * anyway; one declaring half hears about it in the only terms that matter, the
354
+ * two colours a reader is going to see.
355
+ */
356
+ function checkToneCuts(css, { themes = ["light", "dark"] } = {}) {
357
+ const failures = [];
358
+ for (const theme of themes) {
359
+ const tokens = resolveTokens(css, theme);
360
+ const colorOf = (token) => parseColor(tokens[token] ?? "");
361
+ for (const [, chains] of TONE_CHAINS) {
362
+ const fill = chains.fill.find((token) => colorOf(token));
363
+ const ink = chains.ink.find((token) => colorOf(token));
364
+ if (!fill || !ink)
365
+ continue;
366
+ const ratio = contrast(colorOf(ink), colorOf(fill));
367
+ if (ratio >= 4.5)
368
+ continue;
369
+ failures.push({
370
+ theme,
371
+ ink,
372
+ surface: fill,
373
+ ratio,
374
+ required: 4.5,
375
+ // Only when the tone's own ink was not the one that answered.
376
+ ...(ink === chains.ink[0] ? {} : { via: chains.ink[0] }),
377
+ });
378
+ }
379
+ }
380
+ return failures;
381
+ }
304
382
  /**
305
383
  * The cuts a token ships, read off the same three sets `checkSignals` measures.
306
384
  *
@@ -376,12 +454,18 @@ export function checkSignals(css, { themes = ["light", "dark"] } = {}) {
376
454
  }),
377
455
  ...checkLegibility(css, { inks: INKS_TINTED, themes }),
378
456
  ...checkLegibility(css, { inks: TERTIARY, minimum: 3, themes }),
379
- ...ON_FILL.flatMap(([fill, label]) => checkLegibility(css, { inks: [label], surfaces: [fill], themes })),
457
+ ...ON_SURFACE.flatMap(([fill, label]) => checkLegibility(css, { inks: [label], surfaces: [fill], themes })),
458
+ ...checkToneCuts(css, { themes }),
380
459
  ];
381
460
  }
382
461
  /** A one-line report per failure, for a test's assertion message. */
383
462
  export function formatFailures(failures) {
384
463
  return failures
385
- .map((f) => `${f.theme}: ${f.ink} on ${f.surface} is ${f.ratio.toFixed(2)}:1, below ${f.required}:1`)
464
+ .map((f) => {
465
+ const measured = `${f.theme}: ${f.ink} on ${f.surface} is ${f.ratio.toFixed(2)}:1, below ${f.required}:1`;
466
+ return f.via
467
+ ? `${measured} — ${f.via} is not declared, so ${f.surface} took ${f.ink} from the fallback`
468
+ : measured;
469
+ })
386
470
  .join("\n");
387
471
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { cn } from "./cn.js";
2
- export { toneClass, impliedTone, INK_ON_FILL, inkOnSurface, type Tone, } from "./tone.js";
2
+ export { toneClass, impliedTone, INK_ON_FILL, INK_ON_CARD, INK_ON_POPOVER, INK_ON_SIDEBAR, inkOnSurfaceStyle, type Tone, } from "./tone.js";
3
3
  export { resolveLink, isExternalHref, type LinkBehavior, type ResolvedLink, } from "./href.js";
4
4
  export * from "./typography/index.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { cn } from "./cn.js";
3
3
  // takes it too: a link has a tone, drawn from the same seven a button has.
4
4
  // `toneClass` only: the raw table and its derived half used to ship separately,
5
5
  // and the order they were combined in was load-bearing.
6
- export { toneClass, impliedTone, INK_ON_FILL, inkOnSurface, } from "./tone.js";
6
+ export { toneClass, impliedTone, INK_ON_FILL, INK_ON_CARD, INK_ON_POPOVER, INK_ON_SIDEBAR, inkOnSurfaceStyle, } from "./tone.js";
7
7
  // Where an href goes, for the rare call site that styles someone else's element
8
8
  // and cannot render a Card/Button/TypographyLink — the same pairing with
9
9
  // `buttonVariants`. Prefer passing `href` to a component over calling this.
package/dist/tone.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CSSProperties } from "react";
1
2
  /**
2
3
  * One semantic colour vocabulary, for every component that carries meaning in a
3
4
  * hue: Button, Callout, TypographyLink. Before this there were three lists —
@@ -111,11 +112,30 @@ export declare const toneClass: (tone: Tone) => string;
111
112
  */
112
113
  export declare const INK_ON_FILL = "[--ink:var(--tone-ink)] [--ink-muted:var(--tone-ink)]";
113
114
  /**
114
- * The same contract for a surface the tones do not name: `--card`, `--popover`,
115
- * a sidebar. These are tints of the page rather than hues, so both rungs
116
- * survive and the pair is stated rather than collapsed.
115
+ * The same contract for a surface the tones do not name. These are tints of the
116
+ * page rather than hues, so both rungs survive and the pair is stated rather
117
+ * than collapsed.
118
+ *
119
+ * Stated one constant at a time, and not built from an argument, for the reason
120
+ * `TONE` above is a table of literals: Tailwind reads this file as text and can
121
+ * only generate a class it can see. A class assembled at runtime is a class that
122
+ * was never generated — no error, no style, and the nested type quietly keeps
123
+ * the page's ink on a surface that is not the page. The set is closed because
124
+ * the set of surfaces the theme names is closed; a surface with no token is not
125
+ * a surface. For one the package does not name, see `inkOnSurfaceStyle`.
126
+ */
127
+ export declare const INK_ON_CARD = "[--ink:var(--card-foreground)] [--ink-muted:var(--muted-foreground)]";
128
+ export declare const INK_ON_POPOVER = "[--ink:var(--popover-foreground)] [--ink-muted:var(--muted-foreground)]";
129
+ export declare const INK_ON_SIDEBAR = "[--ink:var(--sidebar-foreground)] [--ink-muted:var(--muted-foreground)]";
130
+ /**
131
+ * The escape hatch, for an app painting a surface of its own.
132
+ *
133
+ * Properties rather than a class, because a class this function returned would
134
+ * have to be generated by a scanner that never saw it. Inline custom properties
135
+ * need no scanner at all, so this works for any token and cannot silently
136
+ * produce nothing. It replaces a version that returned a class string and did.
117
137
  */
118
- export declare const inkOnSurface: (ink: string, muted?: string) => string;
138
+ export declare const inkOnSurfaceStyle: (ink: string, muted?: string) => CSSProperties;
119
139
  /**
120
140
  * What an unstated tone means, given how much ink the component is spending.
121
141
  * Shared, because `Button` and `Badge` both need it and two copies of a default
package/dist/tone.js CHANGED
@@ -121,11 +121,30 @@ export const toneClass = (tone) => `${TONE_SURFACE} ${TONE[tone]}`;
121
121
  */
122
122
  export const INK_ON_FILL = "[--ink:var(--tone-ink)] [--ink-muted:var(--tone-ink)]";
123
123
  /**
124
- * The same contract for a surface the tones do not name: `--card`, `--popover`,
125
- * a sidebar. These are tints of the page rather than hues, so both rungs
126
- * survive and the pair is stated rather than collapsed.
124
+ * The same contract for a surface the tones do not name. These are tints of the
125
+ * page rather than hues, so both rungs survive and the pair is stated rather
126
+ * than collapsed.
127
+ *
128
+ * Stated one constant at a time, and not built from an argument, for the reason
129
+ * `TONE` above is a table of literals: Tailwind reads this file as text and can
130
+ * only generate a class it can see. A class assembled at runtime is a class that
131
+ * was never generated — no error, no style, and the nested type quietly keeps
132
+ * the page's ink on a surface that is not the page. The set is closed because
133
+ * the set of surfaces the theme names is closed; a surface with no token is not
134
+ * a surface. For one the package does not name, see `inkOnSurfaceStyle`.
135
+ */
136
+ export const INK_ON_CARD = "[--ink:var(--card-foreground)] [--ink-muted:var(--muted-foreground)]";
137
+ export const INK_ON_POPOVER = "[--ink:var(--popover-foreground)] [--ink-muted:var(--muted-foreground)]";
138
+ export const INK_ON_SIDEBAR = "[--ink:var(--sidebar-foreground)] [--ink-muted:var(--muted-foreground)]";
139
+ /**
140
+ * The escape hatch, for an app painting a surface of its own.
141
+ *
142
+ * Properties rather than a class, because a class this function returned would
143
+ * have to be generated by a scanner that never saw it. Inline custom properties
144
+ * need no scanner at all, so this works for any token and cannot silently
145
+ * produce nothing. It replaces a version that returned a class string and did.
127
146
  */
128
- export const inkOnSurface = (ink, muted = "--muted-foreground") => `[--ink:var(${ink})] [--ink-muted:var(${muted})]`;
147
+ export const inkOnSurfaceStyle = (ink, muted = "--muted-foreground") => ({ "--ink": `var(${ink})`, "--ink-muted": `var(${muted})` });
129
148
  /**
130
149
  * What an unstated tone means, given how much ink the component is spending.
131
150
  * Shared, because `Button` and `Badge` both need it and two copies of a default
@@ -11,12 +11,19 @@ declare const pVariants: (props?: ({
11
11
  tone?: "muted" | "default" | null | undefined;
12
12
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
13
13
  export type ParagraphVariants = VariantProps<typeof pVariants>;
14
- export declare function TypographyP({ className, variant, tone, children, ...props }: ComponentProps<"p"> & ParagraphVariants): import("react").JSX.Element;
14
+ /**
15
+ * `as` is here for the reason the caption has it, one step further on: a
16
+ * component that hands its body to a caller cannot know whether what arrives is
17
+ * a sentence or a list, and a paragraph may hold neither a list nor a div. The
18
+ * HTML parser closes the `<p>` early and React reports a hydration error, so
19
+ * every wrapper of that shape (`Callout` was the one) renders `as="div"`.
20
+ */
21
+ export declare function TypographyP({ className, variant, tone, as, children, ...props }: WithAs<ParagraphVariants>): import("react").JSX.Element;
15
22
  /** A preset's props: its base's, minus the axes it has decided. `keyof Pins` reads
16
23
  * the exclusion off the pinned object the preset also spreads, so the two cannot
17
24
  * drift — `<TypographyMuted tone="default">` used to compile and un-mute it. */
18
25
  type Preset<Base, Pins> = Omit<Base, keyof Pins>;
19
- type ParagraphProps = ComponentProps<"p"> & ParagraphVariants;
26
+ type ParagraphProps = WithAs<ParagraphVariants>;
20
27
  /** The UI rung in the secondary ink. */
21
28
  declare const MUTED: {
22
29
  readonly tone: "muted";
@@ -25,8 +25,15 @@ const pVariants = cva("", {
25
25
  },
26
26
  defaultVariants: { variant: "ui", tone: "default" },
27
27
  });
28
- export function TypographyP({ className, variant, tone, children, ...props }) {
29
- return (_jsx("p", { className: cn(pVariants({ variant, tone }), className), ...props, children: children }));
28
+ /**
29
+ * `as` is here for the reason the caption has it, one step further on: a
30
+ * component that hands its body to a caller cannot know whether what arrives is
31
+ * a sentence or a list, and a paragraph may hold neither a list nor a div. The
32
+ * HTML parser closes the `<p>` early and React reports a hydration error, so
33
+ * every wrapper of that shape (`Callout` was the one) renders `as="div"`.
34
+ */
35
+ export function TypographyP({ className, variant, tone, as = "p", children, ...props }) {
36
+ return (_jsx(TextAs, { as: as, className: cn(pVariants({ variant, tone }), className), ...props, children: children }));
30
37
  }
31
38
  /** The UI rung in the secondary ink. */
32
39
  const MUTED = { tone: "muted" };
package/llms.txt CHANGED
@@ -22,8 +22,12 @@ silently. Full reference: https://github.com/supertypeai/foundations
22
22
  `border-border`. No hex values, no `bg-zinc-800`, no `dark:` overrides that
23
23
  swap one token for another.
24
24
  5. **Paint a surface, hand down its ink.** Any element you give a background
25
- needs `INK_ON_FILL` (a tone fill) or `inkOnSurface(token)` (a tinted one), or
26
- the type inside it keeps the page's ink and fails contrast silently.
25
+ needs `INK_ON_FILL` (a tone fill) or one of `INK_ON_CARD`, `INK_ON_POPOVER`,
26
+ `INK_ON_SIDEBAR` (a tinted one), or the type inside it keeps the page's ink
27
+ and fails contrast silently. Never build one of these class strings
28
+ yourself: Tailwind only generates a class it can read as text, so an
29
+ assembled one styles nothing. For a surface with no constant, spread
30
+ `inkOnSurfaceStyle(token)` into `style`.
27
31
  6. **Write the words like a person would.** See Writing copy below. It applies
28
32
  to every string a reader sees and to the comments you leave behind.
29
33
 
@@ -45,6 +49,7 @@ silently. Full reference: https://github.com/supertypeai/foundations
45
49
  | a number or metric | `TypographyStat` | root |
46
50
  | inline code | `TypographyInlineCode` | root |
47
51
  | a link | `TypographyLink` | root |
52
+ | an anchor around something already drawn | `Anchor` | `/blocks` |
48
53
  | where an href goes, without a component | `resolveLink` | root |
49
54
  | a highlighted phrase | `TypographyHighlight` | root |
50
55
  | a button, or a link that looks like one | `Button` | `/blocks` |
@@ -68,14 +73,14 @@ silently. Full reference: https://github.com/supertypeai/foundations
68
73
  | import | exports |
69
74
  |---|---|
70
75
  | `@supertype.ai/foundations` | `cn`, `TypographyH1`, `TypographyH2`, `TypographyH3`, `TypographyH4`, `TypographyEyebrow`, `TypographyP`, `TypographyMuted`, `TypographyProse`, `TypographyList`, `TypographyProseList`, `TypographyCaption`, `TypographySmall`, `TypographyLabel`, `TypographyStat`, `TypographyInlineCode`, `TypographyLink`, `TypographyHighlight`, `headingClass`, `headingFace`, `eyebrowClass`, `toneClass`, `impliedTone`, `resolveLink`, `isExternalHref`. Types: `TypographyTag`, `ParagraphVariants`, `ListProps`, `CaptionVariants`, `LabelVariants`, `StatVariants`, `Tone`, `HighlightTone`, `LinkBehavior`, `ResolvedLink` |
71
- | `@supertype.ai/foundations/blocks` | `Cards`, `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `Callout`, `Button`, `buttonVariants`, `Badge`, `badgeVariants`, `Steps`, `Step`, `Disclosure`, `DisclosureGroup`, `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`, `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent`, `TabGroup`, `SEGMENT`. Types: `ButtonLook`, `BadgeLook`, `TabItem` |
76
+ | `@supertype.ai/foundations/blocks` | `Anchor`, `Cards`, `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `Callout`, `Button`, `buttonVariants`, `Badge`, `badgeVariants`, `Steps`, `Step`, `Disclosure`, `DisclosureGroup`, `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`, `Tabs`, `TabsList`, `TabsTrigger`, `TabsContent`, `TabGroup`, `SEGMENT`. Types: `ButtonLook`, `BadgeLook`, `TabItem` |
72
77
  | `@supertype.ai/foundations/mdx` | `proseMdxComponents` |
73
78
  | `@supertype.ai/foundations/essay` | `createEssay`, `EssayHeader`, `EssayLayout`, `EssaySection`, `EssayPullQuote`, `EssayFigure`, `EssayMovements`, `EssayDocument`, `EssayColumns`, `EssayAside`, `EssayBody`, `ReadingLayout`, `TableOfContents`, `ReadingRail`, `ReadingProgressBar`, `Rail`, `RailLink`, `PostMetaRow`, `PostDate`, `ReadTime`, `TagPills`, `MetaDot`, `formatPostDate`, `extractHeadings`, `readingTime`, `createSlugger`, `useReadingProgress`, `useScrollSpy`. Types: `TocHeading`, `EssayDecorations`, `EssayIndexEntry`, `EssayDocSection`, `EssayMovement`, `PostDateFormat` |
74
79
  | `@supertype.ai/foundations/seo` | `createSeo`. Types: `SeoConfig`, `ArticleAuthor`, `ArticleOptions`, `PageMetadata` |
75
80
  | `@supertype.ai/foundations/og` | `ogCard`, `OG_SIZE`. Types: `OgCardOptions` |
76
81
  | `@supertype.ai/foundations/eslint` | `designRules` (every rule as one array, the one to spread), `designConfig` (the same set wrapped as a flat-config entry). The builders `colourRules`, `typographyRules`, `linkRules`, `themeOverrideRules`, `surfaceAsInkRules`, `renamedTokenRules` are exported too, though spreading them by hand is how a consumer ends up missing one. Types: `FlatConfigEntry`, `DesignRuleOptions`, `DesignConfigOptions`, `RestrictedSyntax`, `ColourOptions`, `TypographyOptions` |
77
82
  | `@supertype.ai/foundations/rehype` | `rehypeProseCode`, `proseCodeOptions`, `PROSE_LANGS`, `PROSE_THEMES`. Build-time only, must not resolve React |
78
- | `@supertype.ai/foundations/contrast` | `checkLegibility` (inks at 4.5:1), `checkSignals` (fills at 3:1 — status hues, the categorical earth hues and the six chart series alike — tinted inks at 4.5:1, `--subtle-foreground` at the 3:1 it is documented for, labels against their own fill), `checkHairlines` (`--border` and `--input` at 1.4:1 on `--background` and `--card`, `--sidebar-border` on `--sidebar`: a rule is exempt from the ink and mark bars, but it still has to read as the same weight in both themes), `resolveTokens`, `formatFailures`, `specificity`, `parseColor`, `luminance`, `contrast` (WCAG ratio), `lc` (APCA lightness contrast: polarity-aware, for checking that an ink ramp is perceptually ordered rather than merely ordered by ratio), `tokenCuts` (which cuts a token ships: fill, the label printed on it, the hue as words, the taxonomy `checkSignals` measures against). Types: `Rgb`, `Theme`, `LegibilityFailure`, `TokenCuts`. Build-time only |
83
+ | `@supertype.ai/foundations/contrast` | `checkLegibility` (inks at 4.5:1), `checkSignals` (fills at 3:1 — status hues, the categorical earth hues and the six chart series alike — tinted inks at 4.5:1, `--subtle-foreground` at the 3:1 it is documented for, labels against their own fill: the tone rows are read off `TONE` and each cut is resolved along its `var()` fallback chain, so an app that declares `--brand` without `--brand-foreground` is measured on the label the cascade really reaches for rather than skipped), `checkHairlines` (`--border` and `--input` at 1.4:1 on `--background` and `--card`, `--sidebar-border` on `--sidebar`: a rule is exempt from the ink and mark bars, but it still has to read as the same weight in both themes), `resolveTokens`, `formatFailures`, `specificity`, `parseColor`, `luminance`, `contrast` (WCAG ratio), `lc` (APCA lightness contrast: polarity-aware, for checking that an ink ramp is perceptually ordered rather than merely ordered by ratio), `tokenCuts` (which cuts a token ships: fill, the label printed on it, the hue as words, the taxonomy `checkSignals` measures against). Types: `Rgb`, `Theme`, `LegibilityFailure`, `TokenCuts`. Build-time only |
79
84
 
80
85
  ## Props worth knowing
81
86
 
@@ -90,8 +95,12 @@ silently. Full reference: https://github.com/supertypeai/foundations
90
95
  - `TypographyLink`: `href` (required), `tone?: Tone` (default `muted`), `addArrow?`, `newTab?`. The href decides internal versus external.
91
96
  - `TypographyHighlight`: `tone?: HighlightTone`, one of `"primary" | "success" | "ochre" | "terracotta" | "sage" | "fig"`, plus `seed?: number`. A separate type from `Tone` on purpose: this axis is categorical (which one it is) where `Tone` is semantic (what it means), the same split theme.css draws between the earth swatches and the status tokens.
92
97
  - `Card`: `href`, `title`, `description`, `icon`, `external`. An href makes the whole card a link.
98
+ - `Anchor`: `href` (required), plus `external`/`newTab`. An unstyled anchor for a
99
+ link that is not typography and not a control — a thumbnail, a chip, a tooltip
100
+ trigger. It exists so `target`/`rel` are never written at a call site; `external`
101
+ is for a same-origin path that is not a route, which the router would prefetch.
93
102
  - `Tone` is the one semantic colour vocabulary, shared by `Button`, `Badge`, `Callout`, `TypographyLink` and `TabsList`: `"muted" | "primary" | "secondary" | "brand" | "success" | "warn" | "destructive"`, defaulting to `muted` everywhere except a solid `Button`. Seven tones, seven tokens, one to one, which is the bar for adding one. Four names map onto others: `neutral` and `foreground` are `muted`, the word the rest of the package uses; `accent` is `--primary`'s hover tint, so a washed `primary` renders the same thing; `info` is covered by the `success | warn | destructive` triad. `brand` falls back to `--primary` in an app that defines no `--brand`.
94
- - **Ink is handed down by whatever paints.** `toneClass(tone)` is a palette and sets no ink. A surface that fills adds `INK_ON_FILL`; a tinted one uses `inkOnSurface("--card-foreground")`. Both declare `--ink` and `--ink-muted`, which every type primitive reads, falling back to the page. Paint a background without them and a nested `TypographyLabel` prints `--foreground` on your fill, which measures 2.34:1 on `--primary`. On a hue fill `--ink-muted` equals `--ink`: a filled control has one ink, and wanting a second rung means wanting a tinted surface.
103
+ - **Ink is handed down by whatever paints.** `toneClass(tone)` is a palette and sets no ink. A surface that fills adds `INK_ON_FILL`; a tinted one adds `INK_ON_CARD`, `INK_ON_POPOVER` or `INK_ON_SIDEBAR`, and one the package does not name spreads `inkOnSurfaceStyle(token)` into `style` rather than building a class. Both declare `--ink` and `--ink-muted`, which every type primitive reads, falling back to the page. Paint a background without them and a nested `TypographyLabel` prints `--foreground` on your fill, which measures 2.34:1 on `--primary`. On a hue fill `--ink-muted` equals `--ink`: a filled control has one ink, and wanting a second rung means wanting a tinted surface.
95
104
  - `Button`: `variant?: "solid" | "soft" | "outline" | "ghost" | "link"` (default `solid`), `tone?: Tone` (defaults to `primary` on a solid button and `muted` on every other variant — filling a button in is how a page says this is the action), `size?: "xs" | "sm" | "md" | "lg" | "xl"` (default `md`), `icon?: boolean` for a square glyph box, `pill?: boolean` for full-round corners, `href` to make it a link, `render` for an element that is neither a button nor a link. Variant is how much ink it spends and tone is what the ink means, on separate axes, so a quiet delete is `variant="ghost" tone="destructive"`.
96
105
  - `Badge`: `variant?: "solid" | "soft" | "outline" | "ghost"` (default `solid`), `tone?: Tone`, `size?: "xs" | "sm"` (default `sm`), `pill?: boolean`, `href` for a badge that leads somewhere. Same axes and same spellings as `Button`, minus `link`, which belongs to things you click. `warning` and `supertype` were `tone="warn"` and `tone="brand"` under invented names.
97
106
  - `Callout`: `tone?: Tone`, `density?: "compact" | "editorial"`, `title`, `icon`, `action`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supertype.ai/foundations",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -82,7 +82,7 @@
82
82
  "./package.json": "./package.json"
83
83
  },
84
84
  "scripts": {
85
- "build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/cjs-marker.mjs && node scripts/check-llms.mjs && node scripts/pins.mjs",
85
+ "build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/cjs-marker.mjs && node scripts/check-candidates.mjs && node scripts/check-llms.mjs && node scripts/pins.mjs",
86
86
  "dev": "tsc -p tsconfig.json --watch",
87
87
  "test": "yarn build && vitest run",
88
88
  "test:watch": "vitest",
package/src/type.css CHANGED
@@ -71,3 +71,33 @@
71
71
  .editorial.font-heading {
72
72
  font-weight: var(--heading-weight);
73
73
  }
74
+
75
+ /* A glyph set beside words, sized and seated so it belongs to the line.
76
+
77
+ Two numbers, both read off the font rather than chosen. Lucide draws its ink
78
+ across 22 of its 24 grid units, so a 0.8em box puts that ink at 1.02 times the
79
+ cap height of whatever rung it sits beside.
80
+
81
+ The seat corrects a bias the browser introduces. Centring aligns a glyph on the
82
+ middle of the line box, and the line box is built from the font's ascent and
83
+ descent rounded to whole pixels, which lands its middle below the middle of the
84
+ capitals: 0.31px low at the 11px rung, 0.46px at the 10px, 0.50px at the 13px.
85
+ So a centred glyph is always seated slightly low, by an amount that changes with
86
+ the rung, which is why hand nudges get written one place at a time and never
87
+ generalise. Lifting by 0.035em takes it back onto the cap band across the ramp.
88
+
89
+ A box pinned in pixels cannot hold the first number either, because the band it
90
+ has to match moves with the rung. Twelve pixels is 1.39 times the cap height at
91
+ the 11px rung and 1.53 at the 10px one, so the glyph climbs over the ascenders
92
+ and hangs well under the baseline while every letter beside it rests on it.
93
+
94
+ For a glyph inline with words. A glyph that stands alone, in a button, a nav
95
+ rail or an avatar slot, has no band to answer to and keeps a fixed size.
96
+
97
+ The 22-of-24 figure is lucide's grid. An app on a different icon set wants a
98
+ different box for the same ratio. */
99
+ @utility icon-inline {
100
+ width: 0.8em;
101
+ height: 0.8em;
102
+ translate: 0 -0.035em;
103
+ }