@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
@@ -1,10 +1,11 @@
1
1
  import type { ComponentProps, ComponentType, ReactNode } from "react";
2
+ import type { TocHeading } from "./toc.js";
2
3
  /**
3
4
  * Three tracks with the third empty: two would push the prose off-centre the
4
5
  * moment an aside appeared, setting body copy on a different axis per page.
5
6
  * The measure grows per step — a comfortable line length is a range.
6
7
  *
7
- * The margin track is what the third one buys, and it only exists if the
8
+ * The margin track is what the third one buys, and appears only where the
8
9
  * container can pay for it:
9
10
  *
10
11
  * aside = (container − 3rem padding − measure) ÷ 2 − 2.5rem gutter
@@ -24,6 +25,41 @@ import type { ComponentProps, ComponentType, ReactNode } from "react";
24
25
  export declare function EssayColumns({ aside, children, className, ...props }: ComponentProps<"div"> & {
25
26
  aside?: ReactNode;
26
27
  }): import("react").JSX.Element;
28
+ /**
29
+ * The margin track's contents, pinned as the column scrolls.
30
+ *
31
+ * The offset is stated here and nowhere else: it has to clear the same sticky
32
+ * site nav that `EssaySection`'s `scroll-mt` clears, and two literals a file
33
+ * apart is how an anchored heading ends up under the chrome that the rail
34
+ * scrolled it to.
35
+ */
36
+ export declare function EssayAside({ children, className, }: {
37
+ children: ReactNode;
38
+ className?: string;
39
+ }): import("react").JSX.Element;
40
+ /**
41
+ * The join between a header and the body under it: the one place in the package that draws
42
+ * it, so a seam cannot be ruled twice by a header and a layout that cannot see each other.
43
+ *
44
+ * The rule is for the narrow layout alone. Past `@6xl` the margin rail marks the join, and
45
+ * the header's own bottom padding is the whole of the gap.
46
+ */
47
+ export declare function EssayBody({ children, className, }: {
48
+ children: ReactNode;
49
+ className?: string;
50
+ }): import("react").JSX.Element;
51
+ /**
52
+ * The reading column with a scroll-spied rail in its margin: an article whose
53
+ * body is prose or MDX, rather than the declared sections `EssayLayout` sets.
54
+ *
55
+ * The rail is dropped when a piece has no headings, so a short post gets a
56
+ * centred measure instead of a margin holding an empty nav.
57
+ */
58
+ export declare function ReadingLayout({ headings, children, className, }: {
59
+ headings: readonly TocHeading[];
60
+ children: ReactNode;
61
+ className?: string;
62
+ }): import("react").JSX.Element;
27
63
  /** A separator between meta items. Decorative, so it is hidden from assistive tech. */
28
64
  export declare function MetaDot({ className }: {
29
65
  className?: string;
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cn } from "../cn.js";
3
+ import { ReadingRail } from "./reading.js";
3
4
  /**
4
5
  * Three tracks with the third empty: two would push the prose off-centre the
5
6
  * moment an aside appeared, setting body copy on a different axis per page.
6
7
  * The measure grows per step — a comfortable line length is a range.
7
8
  *
8
- * The margin track is what the third one buys, and it only exists if the
9
+ * The margin track is what the third one buys, and appears only where the
9
10
  * container can pay for it:
10
11
  *
11
12
  * aside = (container − 3rem padding − measure) ÷ 2 − 2.5rem gutter
@@ -25,6 +26,37 @@ import { cn } from "../cn.js";
25
26
  export function EssayColumns({ aside, children, className, ...props }) {
26
27
  return (_jsx("div", { className: "@container w-full", children: _jsxs("div", { className: cn("mx-auto grid w-full max-w-6xl gap-10 px-6", "@6xl:grid-cols-[1fr_minmax(0,42rem)_1fr] @6xl:gap-0", "@7xl:max-w-7xl @7xl:grid-cols-[1fr_minmax(0,44rem)_1fr]", "@min-[84rem]:max-w-[84rem] @min-[84rem]:grid-cols-[1fr_minmax(0,46rem)_1fr]", className), ...props, children: [_jsx("div", { className: "hidden @6xl:block @6xl:pr-10", children: aside }), _jsx("div", { className: "mx-auto w-full min-w-0 max-w-2xl @6xl:max-w-none", children: children }), _jsx("div", { className: "hidden @6xl:block" })] }) }));
27
28
  }
29
+ /**
30
+ * The margin track's contents, pinned as the column scrolls.
31
+ *
32
+ * The offset is stated here and nowhere else: it has to clear the same sticky
33
+ * site nav that `EssaySection`'s `scroll-mt` clears, and two literals a file
34
+ * apart is how an anchored heading ends up under the chrome that the rail
35
+ * scrolled it to.
36
+ */
37
+ export function EssayAside({ children, className, }) {
38
+ return _jsx("div", { className: cn("sticky top-24", className), children: children });
39
+ }
40
+ /**
41
+ * The join between a header and the body under it: the one place in the package that draws
42
+ * it, so a seam cannot be ruled twice by a header and a layout that cannot see each other.
43
+ *
44
+ * The rule is for the narrow layout alone. Past `@6xl` the margin rail marks the join, and
45
+ * the header's own bottom padding is the whole of the gap.
46
+ */
47
+ export function EssayBody({ children, className, }) {
48
+ return (_jsx("div", { className: cn("border-t border-border pt-12 @6xl:border-t-0 @6xl:pt-0", className), children: children }));
49
+ }
50
+ /**
51
+ * The reading column with a scroll-spied rail in its margin: an article whose
52
+ * body is prose or MDX, rather than the declared sections `EssayLayout` sets.
53
+ *
54
+ * The rail is dropped when a piece has no headings, so a short post gets a
55
+ * centred measure instead of a margin holding an empty nav.
56
+ */
57
+ export function ReadingLayout({ headings, children, className, }) {
58
+ return (_jsx(EssayColumns, { className: cn("pb-16 sm:pb-24", className), aside: headings.length > 0 ? (_jsx(EssayAside, { children: _jsx(ReadingRail, { headings: headings }) })) : undefined, children: _jsx(EssayBody, { children: children }) }));
59
+ }
28
60
  /** A separator between meta items. Decorative, so it is hidden from assistive tech. */
29
61
  export function MetaDot({ className }) {
30
62
  return (_jsx("span", { "aria-hidden": true, className: cn("text-muted-foreground/50", className), children: "\u00B7" }));
@@ -10,6 +10,16 @@ export declare function RailLink({ active, nested, className, children, render,
10
10
  /** A sub-heading under the item above it, indented a step further in. */
11
11
  nested?: boolean;
12
12
  children: ReactNode;
13
- /** Swap the anchor for another link element, e.g. `<Link href={…} />`. */
13
+ /**
14
+ * Swap the anchor for another link element, e.g. `<Link href={…} />`.
15
+ *
16
+ * The one link in the package that does NOT take an `href` and route it
17
+ * itself, and deliberately: this module is reached from `contents.tsx`,
18
+ * `reading.tsx` and `layout.tsx`, which a consumer imports in bare Node and in
19
+ * a test runner with no Next installed. Importing ../href.ts here would put
20
+ * `next/link` on that path — test/essay-toc.test.ts is what holds the line.
21
+ * The rail's own links are `#hash` anchors, which want no router anyway; a
22
+ * rail of routes passes the router's Link through `render`.
23
+ */
14
24
  render?: ReactElement<ComponentProps<"a">>;
15
25
  }): import("react").JSX.Element;
@@ -12,6 +12,6 @@ export declare function ReadingProgressBar({ className }: {
12
12
  * stores, so mounting them together costs one scroll subscription, not two.
13
13
  */
14
14
  export declare function ReadingRail({ headings, className, }: {
15
- headings: TocHeading[];
15
+ headings: readonly TocHeading[];
16
16
  className?: string;
17
17
  }): import("react").JSX.Element | null;
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { useEffect, useState, useSyncExternalStore } from "react";
3
3
  /**
4
- * Reading progress 01 from one shared listener: a page mounts both the bar and
4
+ * Reading progress, 0 to 1, from one shared listener: a page mounts both the bar and
5
5
  * the rail, and hook-local state would double every subscription. Reads coalesce
6
6
  * to a frame, since `scrollHeight` forces layout.
7
7
  */
package/dist/href.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { ComponentProps, ReactElement } from "react";
2
+ /**
3
+ * Where a link goes, decided once.
4
+ *
5
+ * This branch — scheme test, router `Link` or plain `<a>`, `rel` on the way out
6
+ * — was written three times: Card, TypographyLink, and (by omission) every call
7
+ * site that reached for `render={<a href="…" />}` because the component it was
8
+ * calling had no `href` of its own. The last of those is the expensive copy: it
9
+ * looks like a styling escape hatch and is actually a routing decision, made at
10
+ * the call site, wrongly. A hero CTA written that way full-page-reloads past the
11
+ * router and drops the view transition, and nothing in the type system says so.
12
+ *
13
+ * So components take `href`, not an anchor. `render` stays for what it is for:
14
+ * an element that is genuinely not an anchor.
15
+ */
16
+ /** A scheme (`mailto:`, `https:`) means the href leaves the app entirely. */
17
+ export declare function isExternalHref(href: string): boolean;
18
+ export type LinkBehavior = {
19
+ /** Override the scheme sniff: an absolute URL that is home, or a relative one that is not. */
20
+ external?: boolean;
21
+ /**
22
+ * Defaults on for an http(s) href, off for everything else — `mailto:` and
23
+ * `tel:` hand off to another app and have no tab to open.
24
+ */
25
+ newTab?: boolean;
26
+ };
27
+ /** `Link` requires its own href; as far as a call site here goes it is an anchor. */
28
+ type AnchorComponent = (props: ComponentProps<"a">) => ReactElement | null;
29
+ export type ResolvedLink = {
30
+ Component: AnchorComponent | "a";
31
+ /** Spread onto the element: the href, plus `target`/`rel` when it opens away. */
32
+ props: ComponentProps<"a">;
33
+ /** For a caller that renders differently for an off-site link — an arrow glyph, an icon. */
34
+ external: boolean;
35
+ };
36
+ /**
37
+ * A same-page hash is the one internal href that stays a plain anchor: routing
38
+ * `#section` through the router asks for a navigation and a view transition to
39
+ * reach a place the browser can already scroll to.
40
+ */
41
+ export declare function resolveLink(href: string, { external, newTab }?: LinkBehavior): ResolvedLink;
42
+ export {};
package/dist/href.js ADDED
@@ -0,0 +1,63 @@
1
+ import { Link } from "next-view-transitions";
2
+ /**
3
+ * Where a link goes, decided once.
4
+ *
5
+ * This branch — scheme test, router `Link` or plain `<a>`, `rel` on the way out
6
+ * — was written three times: Card, TypographyLink, and (by omission) every call
7
+ * site that reached for `render={<a href="…" />}` because the component it was
8
+ * calling had no `href` of its own. The last of those is the expensive copy: it
9
+ * looks like a styling escape hatch and is actually a routing decision, made at
10
+ * the call site, wrongly. A hero CTA written that way full-page-reloads past the
11
+ * router and drops the view transition, and nothing in the type system says so.
12
+ *
13
+ * So components take `href`, not an anchor. `render` stays for what it is for:
14
+ * an element that is genuinely not an anchor.
15
+ */
16
+ /** A scheme (`mailto:`, `https:`) means the href leaves the app entirely. */
17
+ export function isExternalHref(href) {
18
+ return /^[a-z][a-z0-9+.-]*:/i.test(href);
19
+ }
20
+ /**
21
+ * What a wrong `href` is worth saying out loud.
22
+ *
23
+ * A value exported from a `"use client"` module and imported by a server
24
+ * component arrives as a boundary stub — a function that throws when called —
25
+ * rather than the string it is in the client bundle. Passing one here read as
26
+ * `TypeError: href.startsWith is not a function`, which React reported with an
27
+ * empty stack: no component, no file, and every route in the app failing at once
28
+ * because the offending link sat in a layout.
29
+ *
30
+ * The value is the diagnosis, so it goes in the message. `String()` on that stub
31
+ * prints the "Attempted to call X() from the server" text React put there, which
32
+ * names the export and the boundary in one line.
33
+ */
34
+ function assertHref(href) {
35
+ if (typeof href === "string")
36
+ return;
37
+ const seen = typeof href === "function"
38
+ ? `a function: ${String(href).replace(/\s+/g, " ").slice(0, 160)}`
39
+ : `${typeof href}: ${JSON.stringify(href)}`;
40
+ throw new TypeError(`href must be a string, and this one is ${seen}. A function here is usually ` +
41
+ `a value exported from a "use client" module and imported by a server ` +
42
+ `component, which crosses the boundary as a stub rather than a string. ` +
43
+ `Move the constant to a plain module and import it from both sides.`);
44
+ }
45
+ /**
46
+ * A same-page hash is the one internal href that stays a plain anchor: routing
47
+ * `#section` through the router asks for a navigation and a view transition to
48
+ * reach a place the browser can already scroll to.
49
+ */
50
+ export function resolveLink(href, { external, newTab } = {}) {
51
+ assertHref(href);
52
+ const leavesApp = external ?? isExternalHref(href);
53
+ const away = leavesApp && (newTab ?? href.startsWith("http"));
54
+ const inPage = !leavesApp && href.startsWith("#");
55
+ return {
56
+ Component: leavesApp || inPage ? "a" : Link,
57
+ props: {
58
+ href,
59
+ ...(away ? { target: "_blank", rel: "noopener noreferrer" } : {}),
60
+ },
61
+ external: leavesApp,
62
+ };
63
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export { cn } from "./cn.js";
2
+ export { toneClass, impliedTone, INK_ON_FILL, inkOnSurface, type Tone, } from "./tone.js";
3
+ export { resolveLink, isExternalHref, type LinkBehavior, type ResolvedLink, } from "./href.js";
2
4
  export * from "./typography/index.js";
package/dist/index.js CHANGED
@@ -1,4 +1,13 @@
1
1
  export { cn } from "./cn.js";
2
+ // The semantic colour vocabulary. Exported from the root because typography
3
+ // takes it too: a link has a tone, drawn from the same seven a button has.
4
+ // `toneClass` only: the raw table and its derived half used to ship separately,
5
+ // and the order they were combined in was load-bearing.
6
+ export { toneClass, impliedTone, INK_ON_FILL, inkOnSurface, } from "./tone.js";
7
+ // Where an href goes, for the rare call site that styles someone else's element
8
+ // and cannot render a Card/Button/TypographyLink — the same pairing with
9
+ // `buttonVariants`. Prefer passing `href` to a component over calling this.
10
+ export { resolveLink, isExternalHref, } from "./href.js";
2
11
  export * from "./typography/index.js";
3
12
  // NOTE: blocks, the MDX map, and the Shiki plugin are all deliberately absent
4
13
  // from this barrel.
package/dist/mdx.d.ts CHANGED
@@ -1,9 +1,30 @@
1
- import type { ComponentProps } from "react";
1
+ import { type ComponentProps, type ReactNode } from "react";
2
2
  import { Disclosure, DisclosureGroup } from "./blocks/accordion.js";
3
3
  import { Callout } from "./blocks/callout.js";
4
4
  import { Card, Cards } from "./blocks/card.js";
5
5
  import { Step, Steps } from "./blocks/steps.js";
6
- import { TabGroup, Tab } from "./blocks/tabs.js";
6
+ /**
7
+ * `<Tabs items={["npm","pnpm"]}>` with a `<Tab>` per panel: the shape an MDX author
8
+ * writes, and the only place in the package that speaks it.
9
+ *
10
+ * Children pair with `items` **by position**, since a caller writing markdown has no
11
+ * value to bind. `value` on a `<Tab>` is for the author's eye and is not matched —
12
+ * matching it would silently drop a panel the moment a label was edited. The label is
13
+ * the value here, so the strip still survives a reorder.
14
+ *
15
+ * This lived in `TabGroup` and made `TabGroup` unusable everywhere else: an app has data,
16
+ * not positional children, so every one of them rebuilt the adapter by hand. Positional
17
+ * children are an authoring convenience, not a component API, so they stop here.
18
+ */
19
+ declare function MdxTabs({ items, children }: {
20
+ items: string[];
21
+ children: ReactNode;
22
+ }): import("react").JSX.Element;
23
+ /** `value` names the panel at the call site; it is not used for matching. */
24
+ declare function MdxTab({ children }: {
25
+ value?: string;
26
+ children: ReactNode;
27
+ }): import("react").JSX.Element;
7
28
  /**
8
29
  * Rendered from markdown syntax, so there is no call site and no knob — a knob
9
30
  * here is one every project turns differently. Headings carry variants and this
@@ -19,8 +40,8 @@ export declare const proseMdxComponents: {
19
40
  Accordions: typeof DisclosureGroup;
20
41
  Accordion: typeof Disclosure;
21
42
  Banner: (props: ComponentProps<typeof Callout>) => import("react").JSX.Element;
22
- Tabs: typeof TabGroup;
23
- Tab: typeof Tab;
43
+ Tabs: typeof MdxTabs;
44
+ Tab: typeof MdxTab;
24
45
  Steps: typeof Steps;
25
46
  Step: typeof Step;
26
47
  h1: (props: ComponentProps<"h1">) => import("react").JSX.Element;
@@ -45,3 +66,4 @@ export declare const proseMdxComponents: {
45
66
  th: (props: ComponentProps<"th">) => import("react").JSX.Element;
46
67
  td: (props: ComponentProps<"td">) => import("react").JSX.Element;
47
68
  };
69
+ export {};
package/dist/mdx.js CHANGED
@@ -1,4 +1,5 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Children } from "react";
2
3
  import { TypographyH1, TypographyH2, TypographyH3, TypographyH4, } from "./typography/header.js";
3
4
  import { cn } from "./cn.js";
4
5
  import Image from "next/image";
@@ -6,8 +7,33 @@ import { Disclosure, DisclosureGroup } from "./blocks/accordion.js";
6
7
  import { Callout } from "./blocks/callout.js";
7
8
  import { Card, Cards } from "./blocks/card.js";
8
9
  import { Step, Steps } from "./blocks/steps.js";
9
- import { TabGroup, Tab } from "./blocks/tabs.js";
10
+ import { TabGroup } from "./blocks/tabs.js";
10
11
  import { TypographyProse, TypographyProseList, TypographyLink, } from "./typography/paragraph.js";
12
+ /**
13
+ * `<Tabs items={["npm","pnpm"]}>` with a `<Tab>` per panel: the shape an MDX author
14
+ * writes, and the only place in the package that speaks it.
15
+ *
16
+ * Children pair with `items` **by position**, since a caller writing markdown has no
17
+ * value to bind. `value` on a `<Tab>` is for the author's eye and is not matched —
18
+ * matching it would silently drop a panel the moment a label was edited. The label is
19
+ * the value here, so the strip still survives a reorder.
20
+ *
21
+ * This lived in `TabGroup` and made `TabGroup` unusable everywhere else: an app has data,
22
+ * not positional children, so every one of them rebuilt the adapter by hand. Positional
23
+ * children are an authoring convenience, not a component API, so they stop here.
24
+ */
25
+ function MdxTabs({ items, children }) {
26
+ const panels = Children.toArray(children);
27
+ return (_jsx(TabGroup, { tabs: items.map((label, i) => ({
28
+ value: label,
29
+ label,
30
+ content: panels[i],
31
+ })) }));
32
+ }
33
+ /** `value` names the panel at the call site; it is not used for matching. */
34
+ function MdxTab({ children }) {
35
+ return _jsx(_Fragment, { children: children });
36
+ }
11
37
  /**
12
38
  * Rendered from markdown syntax, so there is no call site and no knob — a knob
13
39
  * here is one every project turns differently. Headings carry variants and this
@@ -26,8 +52,8 @@ export const proseMdxComponents = {
26
52
  Accordions: DisclosureGroup,
27
53
  Accordion: Disclosure,
28
54
  Banner: (props) => (_jsx(Callout, { density: "editorial", ...props })),
29
- Tabs: TabGroup,
30
- Tab,
55
+ Tabs: MdxTabs,
56
+ Tab: MdxTab,
31
57
  Steps,
32
58
  Step,
33
59
  h1: (props) => (_jsx(TypographyH1, { variant: "display", ...props })),
package/dist/tone.d.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * One semantic colour vocabulary, for every component that carries meaning in a
3
+ * hue: Button, Callout, TypographyLink. Before this there were three lists —
4
+ * Callout's `muted`, TypographyLink's `foreground` and a button's `default` were
5
+ * three spellings of "no meaning at all", and `accent` named `--accent` on a
6
+ * button and `--primary` in a callout.
7
+ *
8
+ * A component that wants a tone does not get to invent a name for one.
9
+ *
10
+ * Seven tones, seven tokens, one to one. That is the test for admitting a new
11
+ * one: `muted` is `--muted`, `warn` is `--warn`, and a proposed tone with no
12
+ * token of its own is a second name for a tone that already has one. It is what
13
+ * ruled out `neutral` (no such token, and `muted` is the word the rest of the
14
+ * package already uses — `--muted-foreground`, `TypographyMuted`,
15
+ * `TypographyP tone="muted"`), `accent` (that is `--primary`'s hover tint, so a
16
+ * washed `primary` renders the same control), and `info` (a real token, but
17
+ * `success | warn | destructive` is already the complete good/careful/bad
18
+ * triad, and nothing in either app had ever reached for a fourth).
19
+ *
20
+ * The rule the package already applies to every tinted role holds here too, and
21
+ * is why each row names three values rather than one: `--tone-fill` is a surface,
22
+ * `--tone-ink` is the label printed on it, and `--tone-hue` is the same colour
23
+ * used as words. A fill is a mark and clears 3:1; an ink is read and clears
24
+ * 4.5:1. Tuning one value to do both jobs sinks whichever job it was not tuned
25
+ * for. `checkSignals` in /contrast is the test.
26
+ *
27
+ * Custom properties rather than thirty-five class strings: a tone declares, a
28
+ * component spends. `Button` has five variants and `Callout` two densities, and
29
+ * neither has a per-tone branch anywhere in it.
30
+ */
31
+ /**
32
+ * `muted` is the only row that names a fourth value. A hairline derived from the
33
+ * ink at 45% is right for a hue and wrong for the absence of one. `--border` is
34
+ * the tuned answer there, in place of a wash of `--foreground`.
35
+ *
36
+ * Its hue is `--foreground` rather than `--muted-foreground` on purpose. `muted`
37
+ * says the control carries no meaning, not that it carries less contrast — a
38
+ * cancel button beside a save button is quiet because it is not filled in, and
39
+ * its label still has to be read.
40
+ *
41
+ * `brand` is the consumer's own identity, and the only row whose token the
42
+ * package does not define. It falls back to `--primary`, so an app that has no
43
+ * identity hue of its own gets its principal one — the tone stays meaningful
44
+ * everywhere rather than rendering nothing in the apps that skipped it.
45
+ */
46
+ export declare const TONE: {
47
+ /** No meaning: chrome, toolbars, anything that must not compete. */
48
+ readonly muted: "[--tone-fill:var(--muted)] [--tone-ink:var(--foreground)] [--tone-hue:var(--foreground)] [--tone-line:var(--border)]";
49
+ /** The principal action, and the package's default wherever a tone is optional. */
50
+ readonly primary: "[--tone-fill:var(--primary)] [--tone-ink:var(--primary-foreground)] [--tone-hue:var(--primary-ink,var(--primary))]";
51
+ /** The warm accent. `--secondary-ink` is its readable cut; the fill is not. */
52
+ readonly secondary: "[--tone-fill:var(--secondary)] [--tone-ink:var(--secondary-foreground)] [--tone-hue:var(--secondary-ink)]";
53
+ /** The consumer's identity hue, if it defined one. Otherwise the principal one. */
54
+ readonly brand: "[--tone-fill:var(--brand,var(--primary))] [--tone-ink:var(--brand-foreground,var(--primary-foreground))] [--tone-hue:var(--brand-ink,var(--primary-ink,var(--primary)))]";
55
+ /** It worked. */
56
+ readonly success: "[--tone-fill:var(--success)] [--tone-ink:var(--success-foreground)] [--tone-hue:var(--success-ink)]";
57
+ /** A footgun: the reader can still proceed, but not blindly. */
58
+ readonly warn: "[--tone-fill:var(--warn)] [--tone-ink:var(--warn-foreground)] [--tone-hue:var(--warn-ink)]";
59
+ /** It deletes something, or it already failed. */
60
+ readonly destructive: "[--tone-fill:var(--destructive)] [--tone-ink:var(--destructive-foreground)] [--tone-hue:var(--destructive)]";
61
+ };
62
+ export type Tone = keyof typeof TONE;
63
+ /**
64
+ * What every tone is worth once mixed, derived once here rather than eight times
65
+ * above. Declared before `TONE` in any class list, so `neutral` can override
66
+ * `--tone-line`.
67
+ *
68
+ * Expressed as variables rather than through Tailwind's `/10` modifier because
69
+ * the modifier's support for arbitrary custom properties is a moving target,
70
+ * while `color-mix` is what the modifier compiles to anyway — the same CSS, one
71
+ * layer less of trust.
72
+ *
73
+ * A hover moves the fill 18% toward `--hover-toward`, the extreme theme.css
74
+ * points away from the page: black on latte, white on espresso. The token
75
+ * carries the direction and the percentage carries the state. Mixing toward
76
+ * `--foreground` instead made the step as long as the gap between the fill and
77
+ * the ink, so a `primary` button moved 4.9 ΔL* on latte against 6.2 on espresso
78
+ * and read as no hover at all. Every tone now clears 6 ΔL* in both themes,
79
+ * measured in test/composition.test.ts. A `dark:` override here is what the
80
+ * package's own ESLint rule exists to prevent.
81
+ */
82
+ export declare const TONE_SURFACE: string;
83
+ /**
84
+ * A tone, as one class list. This is the only thing a component should need.
85
+ *
86
+ * It was two — `cn(TONE_SURFACE, TONE[tone])` — and the order was load-bearing
87
+ * in a way nothing enforced: the derived values have to be declared first so
88
+ * `muted` can override `--tone-line` with `--border`. Getting that backwards
89
+ * gave every quiet control a hairline washed out of its own ink, and the only
90
+ * thing standing between a caller and that bug was a comment. Two arguments
91
+ * whose order matters and whose values always travel together is one argument.
92
+ */
93
+ export declare const toneClass: (tone: Tone) => string;
94
+ /**
95
+ * The ink a nested element inherits, declared by whatever painted the surface
96
+ * under it. Two properties, one rule: paint a background, hand down its ink.
97
+ *
98
+ * `toneClass` alone is a palette, not a surface — a `Callout` spends the same
99
+ * seven values as a filled `Button` and tints at 5%, so the words inside it
100
+ * still sit on the page and still want the page's ink. Only a component that
101
+ * actually fills promotes `--tone-ink` to the inherited ink, and the type
102
+ * primitives read it with the page as their fallback. A tint that promotes
103
+ * nothing is therefore correct by default, which is the failure this replaces:
104
+ * `TypographyLabel` pinned `text-foreground`, won over the `text-primary-foreground`
105
+ * on the anchor around it, and printed 2.34:1 on a filled button.
106
+ *
107
+ * `--ink-muted` collapses to the ink itself, because a hue fill has no second
108
+ * rung: mixing the ink 20% toward `--primary` measures 4.22:1 on the
109
+ * espresso theme and 3.49:1 at 30%. Nothing on a filled control may be quieter
110
+ * than its label. Wanting two rungs is wanting a tinted surface.
111
+ */
112
+ export declare const INK_ON_FILL = "[--ink:var(--tone-ink)] [--ink-muted:var(--tone-ink)]";
113
+ /**
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.
117
+ */
118
+ export declare const inkOnSurface: (ink: string, muted?: string) => string;
119
+ /**
120
+ * What an unstated tone means, given how much ink the component is spending.
121
+ * Shared, because `Button` and `Badge` both need it and two copies of a default
122
+ * is how two components come to disagree about what saying nothing means.
123
+ *
124
+ * Filling a control in is how a page says *this is the action*, so a filled one
125
+ * with nothing else stated is the principal one; anything less is chrome until a
126
+ * call site says otherwise. Measured, not assumed: 244 of the 246 buttons across
127
+ * both apps that name a variant name `outline` or `ghost`, and every one wants
128
+ * the page's own ink.
129
+ */
130
+ export declare const impliedTone: (variant: string | null | undefined) => Tone;
package/dist/tone.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * One semantic colour vocabulary, for every component that carries meaning in a
3
+ * hue: Button, Callout, TypographyLink. Before this there were three lists —
4
+ * Callout's `muted`, TypographyLink's `foreground` and a button's `default` were
5
+ * three spellings of "no meaning at all", and `accent` named `--accent` on a
6
+ * button and `--primary` in a callout.
7
+ *
8
+ * A component that wants a tone does not get to invent a name for one.
9
+ *
10
+ * Seven tones, seven tokens, one to one. That is the test for admitting a new
11
+ * one: `muted` is `--muted`, `warn` is `--warn`, and a proposed tone with no
12
+ * token of its own is a second name for a tone that already has one. It is what
13
+ * ruled out `neutral` (no such token, and `muted` is the word the rest of the
14
+ * package already uses — `--muted-foreground`, `TypographyMuted`,
15
+ * `TypographyP tone="muted"`), `accent` (that is `--primary`'s hover tint, so a
16
+ * washed `primary` renders the same control), and `info` (a real token, but
17
+ * `success | warn | destructive` is already the complete good/careful/bad
18
+ * triad, and nothing in either app had ever reached for a fourth).
19
+ *
20
+ * The rule the package already applies to every tinted role holds here too, and
21
+ * is why each row names three values rather than one: `--tone-fill` is a surface,
22
+ * `--tone-ink` is the label printed on it, and `--tone-hue` is the same colour
23
+ * used as words. A fill is a mark and clears 3:1; an ink is read and clears
24
+ * 4.5:1. Tuning one value to do both jobs sinks whichever job it was not tuned
25
+ * for. `checkSignals` in /contrast is the test.
26
+ *
27
+ * Custom properties rather than thirty-five class strings: a tone declares, a
28
+ * component spends. `Button` has five variants and `Callout` two densities, and
29
+ * neither has a per-tone branch anywhere in it.
30
+ */
31
+ /**
32
+ * `muted` is the only row that names a fourth value. A hairline derived from the
33
+ * ink at 45% is right for a hue and wrong for the absence of one. `--border` is
34
+ * the tuned answer there, in place of a wash of `--foreground`.
35
+ *
36
+ * Its hue is `--foreground` rather than `--muted-foreground` on purpose. `muted`
37
+ * says the control carries no meaning, not that it carries less contrast — a
38
+ * cancel button beside a save button is quiet because it is not filled in, and
39
+ * its label still has to be read.
40
+ *
41
+ * `brand` is the consumer's own identity, and the only row whose token the
42
+ * package does not define. It falls back to `--primary`, so an app that has no
43
+ * identity hue of its own gets its principal one — the tone stays meaningful
44
+ * everywhere rather than rendering nothing in the apps that skipped it.
45
+ */
46
+ export const TONE = {
47
+ /** No meaning: chrome, toolbars, anything that must not compete. */
48
+ muted: "[--tone-fill:var(--muted)] [--tone-ink:var(--foreground)] [--tone-hue:var(--foreground)] [--tone-line:var(--border)]",
49
+ /** The principal action, and the package's default wherever a tone is optional. */
50
+ primary: "[--tone-fill:var(--primary)] [--tone-ink:var(--primary-foreground)] [--tone-hue:var(--primary-ink,var(--primary))]",
51
+ /** The warm accent. `--secondary-ink` is its readable cut; the fill is not. */
52
+ secondary: "[--tone-fill:var(--secondary)] [--tone-ink:var(--secondary-foreground)] [--tone-hue:var(--secondary-ink)]",
53
+ /** The consumer's identity hue, if it defined one. Otherwise the principal one. */
54
+ brand: "[--tone-fill:var(--brand,var(--primary))] [--tone-ink:var(--brand-foreground,var(--primary-foreground))] [--tone-hue:var(--brand-ink,var(--primary-ink,var(--primary)))]",
55
+ /** It worked. */
56
+ success: "[--tone-fill:var(--success)] [--tone-ink:var(--success-foreground)] [--tone-hue:var(--success-ink)]",
57
+ /** A footgun: the reader can still proceed, but not blindly. */
58
+ warn: "[--tone-fill:var(--warn)] [--tone-ink:var(--warn-foreground)] [--tone-hue:var(--warn-ink)]",
59
+ /** It deletes something, or it already failed. */
60
+ destructive: "[--tone-fill:var(--destructive)] [--tone-ink:var(--destructive-foreground)] [--tone-hue:var(--destructive)]",
61
+ };
62
+ /**
63
+ * What every tone is worth once mixed, derived once here rather than eight times
64
+ * above. Declared before `TONE` in any class list, so `neutral` can override
65
+ * `--tone-line`.
66
+ *
67
+ * Expressed as variables rather than through Tailwind's `/10` modifier because
68
+ * the modifier's support for arbitrary custom properties is a moving target,
69
+ * while `color-mix` is what the modifier compiles to anyway — the same CSS, one
70
+ * layer less of trust.
71
+ *
72
+ * A hover moves the fill 18% toward `--hover-toward`, the extreme theme.css
73
+ * points away from the page: black on latte, white on espresso. The token
74
+ * carries the direction and the percentage carries the state. Mixing toward
75
+ * `--foreground` instead made the step as long as the gap between the fill and
76
+ * the ink, so a `primary` button moved 4.9 ΔL* on latte against 6.2 on espresso
77
+ * and read as no hover at all. Every tone now clears 6 ΔL* in both themes,
78
+ * measured in test/composition.test.ts. A `dark:` override here is what the
79
+ * package's own ESLint rule exists to prevent.
80
+ */
81
+ export const TONE_SURFACE = [
82
+ /** A hairline, or a rule carrying the tone. */
83
+ "[--tone-line:color-mix(in_oklab,var(--tone-hue)_45%,transparent)]",
84
+ /** A panel's tint: large area, so barely there. */
85
+ "[--tone-veil:color-mix(in_oklab,var(--tone-hue)_5%,transparent)]",
86
+ /** A control's tint at rest. */
87
+ "[--tone-wash:color-mix(in_oklab,var(--tone-hue)_10%,transparent)]",
88
+ /** The same control under the pointer: twice the tint, so the step reads. */
89
+ "[--tone-wash-hover:color-mix(in_oklab,var(--tone-hue)_20%,transparent)]",
90
+ /** A filled control under the pointer. */
91
+ "[--tone-fill-hover:color-mix(in_oklab,var(--tone-fill),var(--hover-toward)_18%)]",
92
+ ].join(" ");
93
+ /**
94
+ * A tone, as one class list. This is the only thing a component should need.
95
+ *
96
+ * It was two — `cn(TONE_SURFACE, TONE[tone])` — and the order was load-bearing
97
+ * in a way nothing enforced: the derived values have to be declared first so
98
+ * `muted` can override `--tone-line` with `--border`. Getting that backwards
99
+ * gave every quiet control a hairline washed out of its own ink, and the only
100
+ * thing standing between a caller and that bug was a comment. Two arguments
101
+ * whose order matters and whose values always travel together is one argument.
102
+ */
103
+ export const toneClass = (tone) => `${TONE_SURFACE} ${TONE[tone]}`;
104
+ /**
105
+ * The ink a nested element inherits, declared by whatever painted the surface
106
+ * under it. Two properties, one rule: paint a background, hand down its ink.
107
+ *
108
+ * `toneClass` alone is a palette, not a surface — a `Callout` spends the same
109
+ * seven values as a filled `Button` and tints at 5%, so the words inside it
110
+ * still sit on the page and still want the page's ink. Only a component that
111
+ * actually fills promotes `--tone-ink` to the inherited ink, and the type
112
+ * primitives read it with the page as their fallback. A tint that promotes
113
+ * nothing is therefore correct by default, which is the failure this replaces:
114
+ * `TypographyLabel` pinned `text-foreground`, won over the `text-primary-foreground`
115
+ * on the anchor around it, and printed 2.34:1 on a filled button.
116
+ *
117
+ * `--ink-muted` collapses to the ink itself, because a hue fill has no second
118
+ * rung: mixing the ink 20% toward `--primary` measures 4.22:1 on the
119
+ * espresso theme and 3.49:1 at 30%. Nothing on a filled control may be quieter
120
+ * than its label. Wanting two rungs is wanting a tinted surface.
121
+ */
122
+ export const INK_ON_FILL = "[--ink:var(--tone-ink)] [--ink-muted:var(--tone-ink)]";
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.
127
+ */
128
+ export const inkOnSurface = (ink, muted = "--muted-foreground") => `[--ink:var(${ink})] [--ink-muted:var(${muted})]`;
129
+ /**
130
+ * What an unstated tone means, given how much ink the component is spending.
131
+ * Shared, because `Button` and `Badge` both need it and two copies of a default
132
+ * is how two components come to disagree about what saying nothing means.
133
+ *
134
+ * Filling a control in is how a page says *this is the action*, so a filled one
135
+ * with nothing else stated is the principal one; anything less is chrome until a
136
+ * call site says otherwise. Measured, not assumed: 244 of the 246 buttons across
137
+ * both apps that name a variant name `outline` or `ghost`, and every one wants
138
+ * the page's own ink.
139
+ */
140
+ export const impliedTone = (variant) => variant == null || variant === "solid" ? "primary" : "muted";