@supertype.ai/foundations 0.1.33 → 0.1.35

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.33"
69
+ "@supertype.ai/foundations": "https://github.com/supertypeai/foundations.git#v0.1.35"
70
70
  ```
71
71
 
72
72
  </details>
@@ -0,0 +1,71 @@
1
+ import type { ReactNode } from "react";
2
+ /** One cell of the grid. `mark` and `ink` are class names, so they can be any
3
+ * hue the consuming app has: `bg-fern`/`text-fern-ink` from theme.css, or a
4
+ * token of their own. Both optional — a point with no hue is a plain one. */
5
+ export interface BulletinPoint {
6
+ title: ReactNode;
7
+ body: ReactNode;
8
+ /** The dot before the title. A background class: `bg-terracotta`. */
9
+ mark?: string;
10
+ /** The title's ink. A text class: `text-terracotta-ink`. */
11
+ ink?: string;
12
+ }
13
+ export interface BulletinProps {
14
+ /** `card` is the panel. `line` is the same statement at one line: the accent,
15
+ * the action and the sentence, with the grid dropped. */
16
+ variant?: "card" | "line";
17
+ /**
18
+ * Drawn along the top edge of the panel, full width, before the padding
19
+ * starts. `<Ribbon />` is the one that ships; a rule, a gradient or nothing
20
+ * are all fine.
21
+ */
22
+ accent?: ReactNode;
23
+ /** The kicker over the headline. */
24
+ eyebrow?: ReactNode;
25
+ headline?: ReactNode;
26
+ /** The sentence under the headline on `card`, and the whole of the copy on `line`. */
27
+ lede?: ReactNode;
28
+ /** The grid. One point renders full width; two or more go two-up from `sm`. */
29
+ points?: readonly BulletinPoint[];
30
+ /** The control on the footer rule — a `Button`, a link, a form. */
31
+ action?: ReactNode;
32
+ /** The line opposite the action. A licence, a date, a version. */
33
+ footnote?: ReactNode;
34
+ /** Under the rule, for whatever the panel has left to say. */
35
+ children?: ReactNode;
36
+ className?: string;
37
+ }
38
+ /**
39
+ * The shell. Every slot is optional, and an omitted one renders nothing rather
40
+ * than an empty box, so the same component covers a full credits panel and a
41
+ * headline with one button under it.
42
+ */
43
+ export declare function Bulletin({ variant, accent, eyebrow, headline, lede, points, action, footnote, children, className, }: BulletinProps): import("react").JSX.Element;
44
+ /** One band of the ribbon: the class that paints it, and the word shown on hover. */
45
+ export interface RibbonHue {
46
+ name: string;
47
+ fill: string;
48
+ }
49
+ /**
50
+ * The categorical palette in ribbon order: warm through green through cool and
51
+ * back to earth, so the run reads as one spectrum rather than eight swatches.
52
+ *
53
+ * Written out one full classname at a time, never assembled from the name.
54
+ * Tailwind generates the classes it can see as literals, and a template string
55
+ * would leave every one of them purged with nothing to report. A consumer
56
+ * passing hues of their own is subject to the same rule in their own source.
57
+ */
58
+ export declare const EDITORIAL_INKS: readonly RibbonHue[];
59
+ /**
60
+ * A band of hues, for a `Bulletin`'s `accent`. Decorative, so it is hidden from
61
+ * the reader that cannot see it and carries a `title` for the one that can:
62
+ * hovering names the hue, and the segment widens to show the cut at full size.
63
+ *
64
+ * `h-1.5 w-full` on a panel and `h-1 min-w-20 flex-1 rounded-full` in a row —
65
+ * stated by the caller, since the two shapes have nothing in common but the
66
+ * colours.
67
+ */
68
+ export declare function Ribbon({ hues, className, }: {
69
+ hues?: readonly RibbonHue[];
70
+ className?: string;
71
+ }): import("react").JSX.Element;
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "../cn.js";
3
+ import { INK_ON_CARD } from "../tone.js";
4
+ import { TypographyCaption, TypographyLabel, TypographyMuted, } from "../typography/paragraph.js";
5
+ import { TypographyEyebrow, TypographyH3 } from "../typography/header.js";
6
+ /**
7
+ * The shell. Every slot is optional, and an omitted one renders nothing rather
8
+ * than an empty box, so the same component covers a full credits panel and a
9
+ * headline with one button under it.
10
+ */
11
+ export function Bulletin({ variant = "card", accent, eyebrow, headline, lede, points, action, footnote, children, className, }) {
12
+ if (variant === "line") {
13
+ return (_jsxs("div", { className: cn("flex flex-wrap items-center gap-x-4 gap-y-3", "pt-4", className), children: [action, accent, lede && _jsx(TypographyCaption, { size: "2xs", children: lede })] }));
14
+ }
15
+ const rule = action || footnote;
16
+ return (_jsxs("section", { className: cn("overflow-hidden rounded-xl border border-border",
17
+ // It paints, so it hands down its ink.
18
+ "bg-card", INK_ON_CARD, className), children: [accent, _jsxs("div", { className: "p-6 sm:p-8", children: [eyebrow && (_jsx(TypographyEyebrow, { tone: "subtle", children: eyebrow })), headline && (_jsx(TypographyH3, { className: cn("max-w-lg", eyebrow && "mt-2"), children: headline })), lede && (_jsx(TypographyMuted, { as: "p", className: "mt-2 max-w-xl", children: lede })), points && points.length > 0 && (
19
+ // A lone point takes the width. Two columns holding one of them is a
20
+ // grid drawn around an empty cell.
21
+ _jsx("ul", { className: cn("mt-7 grid gap-6", points.length > 1 && "sm:grid-cols-2"), children: points.map(({ title, body, mark, ink }, i) => (_jsxs("li", { children: [_jsxs(TypographyLabel, { as: "p", size: "xs", className: cn("flex items-center gap-2", ink), children: [mark && (_jsx("span", { "aria-hidden": true, className: cn("size-2 rounded-full", mark) })), title] }), _jsx(TypographyCaption, { as: "p", size: "xs", className: "mt-1.5", children: body })] }, i))) })), rule && (_jsxs("div", { className: "mt-8 flex flex-wrap items-center justify-between gap-4 border-t border-border pt-5", children: [action, footnote && (_jsx(TypographyCaption, { size: "2xs", children: footnote }))] })), children && (_jsx(TypographyCaption, { as: "div", size: "xs", className: cn(rule && "mt-5"), children: children }))] })] }));
22
+ }
23
+ /**
24
+ * The categorical palette in ribbon order: warm through green through cool and
25
+ * back to earth, so the run reads as one spectrum rather than eight swatches.
26
+ *
27
+ * Written out one full classname at a time, never assembled from the name.
28
+ * Tailwind generates the classes it can see as literals, and a template string
29
+ * would leave every one of them purged with nothing to report. A consumer
30
+ * passing hues of their own is subject to the same rule in their own source.
31
+ */
32
+ export const EDITORIAL_INKS = [
33
+ { name: "terracotta", fill: "bg-terracotta" },
34
+ { name: "ochre", fill: "bg-ochre" },
35
+ { name: "moss", fill: "bg-moss" },
36
+ { name: "fern", fill: "bg-fern" },
37
+ { name: "sage", fill: "bg-sage" },
38
+ { name: "stone", fill: "bg-stone" },
39
+ { name: "fig", fill: "bg-fig" },
40
+ { name: "cocoa", fill: "bg-cocoa" },
41
+ ];
42
+ /**
43
+ * A band of hues, for a `Bulletin`'s `accent`. Decorative, so it is hidden from
44
+ * the reader that cannot see it and carries a `title` for the one that can:
45
+ * hovering names the hue, and the segment widens to show the cut at full size.
46
+ *
47
+ * `h-1.5 w-full` on a panel and `h-1 min-w-20 flex-1 rounded-full` in a row —
48
+ * stated by the caller, since the two shapes have nothing in common but the
49
+ * colours.
50
+ */
51
+ export function Ribbon({ hues = EDITORIAL_INKS, className, }) {
52
+ return (_jsx("div", { "aria-hidden": true, className: cn("flex overflow-hidden", className), children: hues.map(({ name, fill }) => (_jsx("span", { title: name, className: cn("flex-1 transition-[flex-grow] duration-500 ease-out hover:grow-[2.5]", fill) }, name))) }));
53
+ }
@@ -4,10 +4,10 @@ import { type VariantProps } from "class-variance-authority";
4
4
  import { type LinkBehavior } from "../href.js";
5
5
  declare const button: (props?: ({
6
6
  tone?: "muted" | "primary" | "secondary" | "brand" | "success" | "warn" | "destructive" | null | undefined;
7
- size?: "sm" | "xs" | "md" | "lg" | "xl" | null | undefined;
7
+ size?: "sm" | "xs" | "lg" | "xl" | "md" | null | undefined;
8
8
  icon?: boolean | null | undefined;
9
9
  pill?: boolean | null | undefined;
10
- variant?: "solid" | "link" | "soft" | "outline" | "ghost" | null | undefined;
10
+ variant?: "link" | "solid" | "soft" | "outline" | "ghost" | null | undefined;
11
11
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
12
12
  export type ButtonLook = VariantProps<typeof button>;
13
13
  /**
@@ -0,0 +1,48 @@
1
+ import type { ReactNode } from "react";
2
+ /** Where the mark points. Exported because a footer sometimes wants the bare
3
+ * href — a `<link rel="…">`, a sitemap entry, an analytics label. */
4
+ export declare const FOUNDATIONS_URL = "https://github.com/supertypeai/foundations";
5
+ /**
6
+ * The palette reduced to a small chip. It is small enough for a footer row and
7
+ * still recognisable from the full panel.
8
+ */
9
+ export declare function FoundationsMark({ className }: {
10
+ className?: string;
11
+ }): import("react").JSX.Element;
12
+ /**
13
+ * The standalone link. It is meant for a footer row or another layout that
14
+ * already has its own structure. Use `Colophon` when the block itself is the
15
+ * thing being placed.
16
+ */
17
+ export declare function BuiltWithFoundations({ label, className, }: {
18
+ label?: ReactNode;
19
+ className?: string;
20
+ }): import("react").JSX.Element;
21
+ export interface ColophonProps {
22
+ /**
23
+ * `card` is the full panel and `line` is the compact row.
24
+ */
25
+ variant?: "card" | "line";
26
+ /**
27
+ * The link label. Use this for translations or a different wording.
28
+ */
29
+ label?: ReactNode;
30
+ /**
31
+ * Extra content for the panel, such as a short note about the site or the
32
+ * people behind it.
33
+ */
34
+ children?: ReactNode;
35
+ className?: string;
36
+ }
37
+ /**
38
+ * A preset built on top of `Bulletin`.
39
+ *
40
+ * ```tsx
41
+ * <Colophon /> // the panel
42
+ * <Colophon variant="line" /> // the compact row
43
+ * ```
44
+ *
45
+ * Use `BuiltWithFoundations` for the standalone link, and `Bulletin` if you
46
+ * want the same layout with different copy.
47
+ */
48
+ export declare function Colophon({ variant, label, children, className, }: ColophonProps): import("react").JSX.Element;
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "../cn.js";
3
+ import { Button } from "./button.js";
4
+ import { Bulletin, EDITORIAL_INKS, Ribbon, } from "./bulletin.js";
5
+ // ---------------------------------------------------------------------------
6
+ // A compact statement block for a footer, a section, or a page.
7
+ //
8
+ // Colophon keeps the name because it matches the role: a short note about the
9
+ // system behind the site. The layout is shared with Bulletin, while the copy
10
+ // here is the package's own preset.
11
+ //
12
+ // The panel uses the editorial inks and the row uses the same mark in a tighter
13
+ // layout. The content is mostly fixed, but the label and children can be
14
+ // replaced for a site that wants its own wording.
15
+ // ---------------------------------------------------------------------------
16
+ /** Where the mark points. Exported because a footer sometimes wants the bare
17
+ * href — a `<link rel="…">`, a sitemap entry, an analytics label. */
18
+ export const FOUNDATIONS_URL = "https://github.com/supertypeai/foundations";
19
+ /**
20
+ * The two claims, each paired with a hue from the ribbon above it.
21
+ *
22
+ * They are short statements about how the package is built and how the system
23
+ * is checked.
24
+ */
25
+ const CLAIMS = [
26
+ {
27
+ mark: "bg-terracotta",
28
+ ink: "text-terracotta-ink",
29
+ title: "Decisions live in one place",
30
+ body: "Utility classes are for styling. The actual decision belongs in the design system. Every type style, tone, and divider on this page comes from one package, so a change is one diff instead of a search through the app.",
31
+ },
32
+ {
33
+ mark: "bg-sage",
34
+ ink: "text-sage-ink",
35
+ title: "Every colour is measured twice",
36
+ body: "WCAG tells us whether a colour passes the audit. APCA's Lc tells us how it feels in context, since the same contrast can read differently on different backgrounds. Both checks run against the shipped stylesheet in light and dark mode, so CI catches anything that slips.",
37
+ },
38
+ ];
39
+ const HEADLINE = "Designed with intention and mathematical rigor.";
40
+ /** The panel has a paragraph's room under its headline. */
41
+ const LEDE = "Typography primitives, semantic tokens, the essay shell, and contrast checks in one package, enforced in CI.";
42
+ /** The row has a line. Sharing `LEDE` with the panel put a paragraph in a
43
+ * footer, where it wrapped under the ribbon and stopped being a row. */
44
+ const NOTE = "Colors, typography, and blocks, tested and measured.";
45
+ const FOOTNOTE = "Open source design system by Supertype. MIT licensed.";
46
+ /**
47
+ * The palette reduced to a small chip. It is small enough for a footer row and
48
+ * still recognisable from the full panel.
49
+ */
50
+ export function FoundationsMark({ className }) {
51
+ return (_jsx("span", { "aria-hidden": true, className: cn("grid size-4 shrink-0 grid-cols-4 grid-rows-2 overflow-hidden rounded-[3px]", className), children: EDITORIAL_INKS.map(({ name, fill }) => (_jsx("span", { className: fill }, name))) }));
52
+ }
53
+ /**
54
+ * The trailing arrow. It sits just under the text and keeps the link visually
55
+ * aligned with the other controls.
56
+ */
57
+ function Arrow() {
58
+ return (_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", className: "size-3 opacity-60 transition-opacity group-hover:opacity-100", children: _jsx("path", { d: "M7 17 17 7M7 7h10v10" }) }));
59
+ }
60
+ /**
61
+ * The standalone link. It is meant for a footer row or another layout that
62
+ * already has its own structure. Use `Colophon` when the block itself is the
63
+ * thing being placed.
64
+ */
65
+ export function BuiltWithFoundations({ label = "Built with Foundations", className, }) {
66
+ return (_jsxs(Button, { href: FOUNDATIONS_URL, variant: "outline", size: "sm", className: cn("group gap-2", className), children: [_jsx(FoundationsMark, { className: "size-3.5" }), label, _jsx(Arrow, {})] }));
67
+ }
68
+ /**
69
+ * A preset built on top of `Bulletin`.
70
+ *
71
+ * ```tsx
72
+ * <Colophon /> // the panel
73
+ * <Colophon variant="line" /> // the compact row
74
+ * ```
75
+ *
76
+ * Use `BuiltWithFoundations` for the standalone link, and `Bulletin` if you
77
+ * want the same layout with different copy.
78
+ */
79
+ export function Colophon({ variant = "card", label, children, className, }) {
80
+ const line = variant === "line";
81
+ return (_jsx(Bulletin, { variant: variant, accent: _jsx(Ribbon, { className: line ? "h-1 min-w-20 flex-1 rounded-full" : "h-1.5 w-full" }), eyebrow: "Colophon", headline: HEADLINE, lede: line ? NOTE : LEDE, points: CLAIMS, action: _jsx(BuiltWithFoundations, { label: label }), footnote: FOOTNOTE, className: className, children: children }));
82
+ }
@@ -8,3 +8,5 @@ export { Badge, badgeVariants, type BadgeLook } from "./badge.js";
8
8
  export { Tabs, TabsList, TabsTrigger, TabsContent, TabGroup, type TabItem, } from "./tabs.js";
9
9
  export { SEGMENT } from "./segment.js";
10
10
  export { Steps, Step } from "./steps.js";
11
+ export { Bulletin, Ribbon, EDITORIAL_INKS, type BulletinProps, type BulletinPoint, type RibbonHue, } from "./bulletin.js";
12
+ export { Colophon, BuiltWithFoundations, FoundationsMark, FOUNDATIONS_URL, type ColophonProps, } from "./colophon.js";
@@ -8,3 +8,5 @@ export { Badge, badgeVariants } from "./badge.js";
8
8
  export { Tabs, TabsList, TabsTrigger, TabsContent, TabGroup, } from "./tabs.js";
9
9
  export { SEGMENT } from "./segment.js";
10
10
  export { Steps, Step } from "./steps.js";
11
+ export { Bulletin, Ribbon, EDITORIAL_INKS, } from "./bulletin.js";
12
+ export { Colophon, BuiltWithFoundations, FoundationsMark, FOUNDATIONS_URL, } from "./colophon.js";
@@ -54,7 +54,20 @@ function themeOverrideRules() {
54
54
  * role.
55
55
  */
56
56
  function surfaceAsInkRules() {
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.");
57
+ return [
58
+ ...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."),
59
+ // The three fills with no legitimate use as a foreground, glyph or word.
60
+ // Each is a background whose lightness is chosen to hold a label, so read as
61
+ // ink against the page it lands under the tertiary rung: in dark, secondary
62
+ // measures 2.77:1, primary 3.36:1 and success 3.79:1, where
63
+ // `--subtle-foreground` is 6.75:1. An accent quieter than the quietest ink is
64
+ // the bug, and it reads the same way on an icon as it does in a sentence.
65
+ //
66
+ // Unconditional, unlike the opt-in rules above, because the sweep is done in
67
+ // both apps and there is no correct call site left to grandfather. `warn` and
68
+ // `info` stay out: their fills are bright enough to read (7.68:1 and 6.10:1).
69
+ ...rule("/(^| )(dark:|hover:|focus:|group-hover:)*text-(primary|secondary|success)($| )/", "That is a fill token used as ink. A fill's lightness is chosen to hold a label printed on it, so against the page it reads under the tertiary ink in dark (primary 3.36:1, secondary 2.77:1, success 3.79:1). Each ships an `-ink` cut checked at 4.5:1 against the page. Add `-ink`."),
70
+ ];
58
71
  }
59
72
  /**
60
73
  * `-foreground` means the label printed on a fill; `-ink` means the hue as
@@ -92,7 +105,7 @@ function linkRules() {
92
105
  },
93
106
  ];
94
107
  }
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, } = {}) {
108
+ 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, leading = false, } = {}) {
96
109
  return [
97
110
  // Alpha ink composites against whatever surface it lands on, so its
98
111
  // contrast is unmeasurable. The ink tokens are measured.
@@ -103,11 +116,10 @@ function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2xs 11 /
103
116
  ...rule("/(^| )text-\\[\\d+px\\]/", `Arbitrary font sizes bypass the type ramp. Use a rung (${ramp}).`),
104
117
  // A primitive that owns a size axis, reached past for a class that does the
105
118
  // same thing. The class wins on the page, so nothing looks wrong — what is
106
- // lost is everything else the axis carries: `TypographyCaption` pins leading
107
- // per rung because a wrapped caption sets cramped at the ramp's own setting,
108
- // and `TypographyStat` pairs its rungs with the heading ladder so a figure
109
- // and the heading beside it retune together on an editorial surface. A
110
- // literal gets the size and silently drops the rest.
119
+ // lost is everything else the axis carries: `TypographyStat` pairs its rungs
120
+ // with the heading ladder, so a figure and the heading beside it retune
121
+ // together on an editorial surface, where a literal stays put. A literal
122
+ // gets the size and silently drops the rest.
111
123
  //
112
124
  // Matching the class node INSIDE the attribute, rather than the className
113
125
  // string on its own, lets this name the component. It reaches into `cn()`
@@ -124,6 +136,20 @@ function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2xs 11 /
124
136
  })),
125
137
  ]
126
138
  : []),
139
+ // Leading stated at a call site, on a primitive whose rung already carries
140
+ // one. The ramp is what an app retunes, and a class pins past it: the pinned
141
+ // value follows the component onto a surface tuned for different sizes and
142
+ // reports nothing when it no longer fits. Where a role genuinely needs a
143
+ // number the rung cannot give, the reading paragraph and the value being the
144
+ // two, the primitive states it and every call site inherits the decision.
145
+ ...(leading
146
+ ? [
147
+ ...["Literal[value", "TemplateElement[value.raw"].map((node) => ({
148
+ selector: `JSXOpeningElement[name.name=/^Typography[A-Z]/] JSXAttribute[name.name="className"] ${node}=/(^| )leading-/]`,
149
+ message: "Leading comes from the rung, which is what an app retunes. Move the rung (size=), or state it on the primitive if every call site wants it.",
150
+ })),
151
+ ]
152
+ : []),
127
153
  // Two valid primitives forming an invalid pair, which the value rules above
128
154
  // cannot see: `<TypographyP>` is the 14px interface rung, and the list under
129
155
  // it reads at the prose rung, so one passage lands two rungs apart.
package/dist/cn.js CHANGED
@@ -1,5 +1,39 @@
1
1
  import { clsx } from "clsx";
2
- import { twMerge } from "tailwind-merge";
2
+ import { extendTailwindMerge } from "tailwind-merge";
3
+ /**
4
+ * `text-h1`…`text-h4` are this package's rungs, declared in type.css as
5
+ * `--text-h1`…`--text-h4`. tailwind-merge has never heard of them.
6
+ *
7
+ * Faced with a `text-*` it does not know, it has to pick a class group, and
8
+ * `h3` matches neither a t-shirt size nor an arbitrary value — so it falls
9
+ * through to `text-color`, which accepts any token. tailwind-merge therefore
10
+ * believed `text-h3` was a colour, and deleted whichever colour stood beside
11
+ * it. Measured, not assumed:
12
+ *
13
+ * twMerge("text-red-500 text-h3") → "text-h3" // collided
14
+ * twMerge("text-sm text-h3") → "text-sm text-h3" // did not
15
+ *
16
+ * Which side lost depended on `cva` key order, so it broke in both directions
17
+ * at once. `HEADING_BASE` states the ink before the rung, so every heading on
18
+ * the default variant shipped with no colour class at all and quietly inherited
19
+ * whatever painted above it — which is right on a page and wrong on every
20
+ * surface that hands down an ink, the contract `INK_ON_CARD` and friends exist
21
+ * to keep. `statVariants` orders `size` before `tone`, so a
22
+ * `<TypographyStat size="card" tone="muted">` lost its rung instead and
23
+ * rendered at the inherited size.
24
+ *
25
+ * `text-2xs` and `text-3xs` are custom rungs too and were never affected: they
26
+ * match tailwind-merge's t-shirt-size pattern and land in `font-size` on their
27
+ * own. Only the four heading rungs fall through, which is why this list is
28
+ * exactly four names long.
29
+ *
30
+ * Registering them leaves every real conflict intact — two rungs still collide
31
+ * with each other and with the body ramp, two colours still collide — and stops
32
+ * the one that was never a conflict. test/heading-ink.test.tsx holds it.
33
+ */
34
+ const twMerge = extendTailwindMerge({
35
+ extend: { classGroups: { "font-size": [{ text: ["h1", "h2", "h3", "h4"] }] } },
36
+ });
3
37
  export function cn(...inputs) {
4
38
  return twMerge(clsx(inputs));
5
39
  }
package/dist/eslint.d.ts CHANGED
@@ -59,8 +59,11 @@ export interface TypographyOptions {
59
59
  * migrated, and that migration is the intended end state.
60
60
  */
61
61
  axis?: boolean;
62
+ /** Flag a leading class on a typography primitive. Off by default: an app
63
+ * adopting it has a backlog to clear first. */
64
+ leading?: boolean;
62
65
  }
63
- export declare function typographyRules({ weights, ramp, pairing, axis, }?: TypographyOptions): RestrictedSyntax[];
66
+ export declare function typographyRules({ weights, ramp, pairing, axis, leading, }?: TypographyOptions): RestrictedSyntax[];
64
67
  /**
65
68
  * Every design rule, as one list.
66
69
  *
package/dist/eslint.js CHANGED
@@ -44,7 +44,20 @@ export function themeOverrideRules() {
44
44
  * role.
45
45
  */
46
46
  export function surfaceAsInkRules() {
47
- 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.");
47
+ return [
48
+ ...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."),
49
+ // The three fills with no legitimate use as a foreground, glyph or word.
50
+ // Each is a background whose lightness is chosen to hold a label, so read as
51
+ // ink against the page it lands under the tertiary rung: in dark, secondary
52
+ // measures 2.77:1, primary 3.36:1 and success 3.79:1, where
53
+ // `--subtle-foreground` is 6.75:1. An accent quieter than the quietest ink is
54
+ // the bug, and it reads the same way on an icon as it does in a sentence.
55
+ //
56
+ // Unconditional, unlike the opt-in rules above, because the sweep is done in
57
+ // both apps and there is no correct call site left to grandfather. `warn` and
58
+ // `info` stay out: their fills are bright enough to read (7.68:1 and 6.10:1).
59
+ ...rule("/(^| )(dark:|hover:|focus:|group-hover:)*text-(primary|secondary|success)($| )/", "That is a fill token used as ink. A fill's lightness is chosen to hold a label printed on it, so against the page it reads under the tertiary ink in dark (primary 3.36:1, secondary 2.77:1, success 3.79:1). Each ships an `-ink` cut checked at 4.5:1 against the page. Add `-ink`."),
60
+ ];
48
61
  }
49
62
  /**
50
63
  * `-foreground` means the label printed on a fill; `-ink` means the hue as
@@ -82,7 +95,7 @@ export function linkRules() {
82
95
  },
83
96
  ];
84
97
  }
85
- export 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, } = {}) {
98
+ export 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, leading = false, } = {}) {
86
99
  return [
87
100
  // Alpha ink composites against whatever surface it lands on, so its
88
101
  // contrast is unmeasurable. The ink tokens are measured.
@@ -93,11 +106,10 @@ export function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2x
93
106
  ...rule("/(^| )text-\\[\\d+px\\]/", `Arbitrary font sizes bypass the type ramp. Use a rung (${ramp}).`),
94
107
  // A primitive that owns a size axis, reached past for a class that does the
95
108
  // same thing. The class wins on the page, so nothing looks wrong — what is
96
- // lost is everything else the axis carries: `TypographyCaption` pins leading
97
- // per rung because a wrapped caption sets cramped at the ramp's own setting,
98
- // and `TypographyStat` pairs its rungs with the heading ladder so a figure
99
- // and the heading beside it retune together on an editorial surface. A
100
- // literal gets the size and silently drops the rest.
109
+ // lost is everything else the axis carries: `TypographyStat` pairs its rungs
110
+ // with the heading ladder, so a figure and the heading beside it retune
111
+ // together on an editorial surface, where a literal stays put. A literal
112
+ // gets the size and silently drops the rest.
101
113
  //
102
114
  // Matching the class node INSIDE the attribute, rather than the className
103
115
  // string on its own, lets this name the component. It reaches into `cn()`
@@ -114,6 +126,20 @@ export function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2x
114
126
  })),
115
127
  ]
116
128
  : []),
129
+ // Leading stated at a call site, on a primitive whose rung already carries
130
+ // one. The ramp is what an app retunes, and a class pins past it: the pinned
131
+ // value follows the component onto a surface tuned for different sizes and
132
+ // reports nothing when it no longer fits. Where a role genuinely needs a
133
+ // number the rung cannot give, the reading paragraph and the value being the
134
+ // two, the primitive states it and every call site inherits the decision.
135
+ ...(leading
136
+ ? [
137
+ ...["Literal[value", "TemplateElement[value.raw"].map((node) => ({
138
+ selector: `JSXOpeningElement[name.name=/^Typography[A-Z]/] JSXAttribute[name.name="className"] ${node}=/(^| )leading-/]`,
139
+ message: "Leading comes from the rung, which is what an app retunes. Move the rung (size=), or state it on the primitive if every call site wants it.",
140
+ })),
141
+ ]
142
+ : []),
117
143
  // Two valid primitives forming an invalid pair, which the value rules above
118
144
  // cannot see: `<TypographyP>` is the 14px interface rung, and the list under
119
145
  // it reads at the prose rung, so one passage lands two rungs apart.
@@ -62,7 +62,7 @@ export function createEssay({ Reveal = PlainReveal, Glow = NoGlow, } = {}) {
62
62
  * An ordered list says that without an arrow — the ordinal is the ornament.
63
63
  */
64
64
  function EssayMovements({ items }) {
65
- return (_jsx("ol", { className: "flex flex-col", children: items.map(({ title, body }, i) => (_jsxs("li", { className: "border-t border-border/60 py-8 first:border-t-0 first:pt-0 last:pb-0 sm:grid sm:grid-cols-[3.5rem_minmax(0,1fr)] sm:gap-6", children: [_jsx(TypographyCaption, { className: "block pt-2 font-mono tabular-nums text-primary max-sm:mb-2", children: String(i + 1).padStart(2, "0") }), _jsxs("div", { className: "flex flex-col gap-3", children: [_jsx(TypographyH3, { children: title }), body] })] }, title))) }));
65
+ return (_jsx("ol", { className: "flex flex-col", children: items.map(({ title, body }, i) => (_jsxs("li", { className: "border-t border-border/60 py-8 first:border-t-0 first:pt-0 last:pb-0 sm:grid sm:grid-cols-[3.5rem_minmax(0,1fr)] sm:gap-6", children: [_jsx(TypographyCaption, { className: "block pt-2 font-mono tabular-nums text-primary-ink max-sm:mb-2", children: String(i + 1).padStart(2, "0") }), _jsxs("div", { className: "flex flex-col gap-3", children: [_jsx(TypographyH3, { children: title }), body] })] }, title))) }));
66
66
  }
67
67
  /**
68
68
  * A reference document as data: the index derives from the sections, so a
@@ -105,5 +105,5 @@ export function ReadTime({ minutes, icon: Icon, className, }) {
105
105
  export function TagPills({ tags, className, }) {
106
106
  if (tags.length === 0)
107
107
  return null;
108
- return (_jsx("span", { className: cn("flex flex-wrap gap-1.5", className), children: tags.map((tag) => (_jsx("span", { className: "rounded-full bg-primary/15 px-2.5 py-0.5 text-xs font-medium text-primary", children: tag }, tag))) }));
108
+ return (_jsx("span", { className: cn("flex flex-wrap gap-1.5", className), children: tags.map((tag) => (_jsx("span", { className: "rounded-full bg-primary/15 px-2.5 py-0.5 text-xs font-medium text-primary-ink", children: tag }, tag))) }));
109
109
  }
@@ -18,7 +18,7 @@ export function RailLink({ active = false, nested = false, className, children,
18
18
  // inactive neighbour keeps its line and nothing shifts when the active
19
19
  // item changes.
20
20
  "before:absolute before:inset-y-1 before:-left-px before:w-0.5 before:rounded-full before:bg-primary before:opacity-0 before:transition-opacity", active
21
- ? "font-medium text-primary before:opacity-100"
21
+ ? "font-medium text-primary-ink before:opacity-100"
22
22
  : "text-muted-foreground hover:text-foreground", className),
23
23
  children,
24
24
  };
@@ -31,11 +31,11 @@ import { type WithAs } from "./as.js";
31
31
  */
32
32
  export declare const headingFace = "font-heading font-[number:var(--heading-weight)]";
33
33
  declare const h1Variants: (props?: ({
34
- variant?: "default" | "display" | null | undefined;
34
+ variant?: "display" | "default" | null | undefined;
35
35
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
36
36
  export declare function TypographyH1({ className, variant, children, ...props }: React.ComponentProps<"h1"> & VariantProps<typeof h1Variants>): import("react").JSX.Element;
37
37
  declare const h2Variants: (props?: ({
38
- variant?: "default" | "display" | null | undefined;
38
+ variant?: "display" | "default" | null | undefined;
39
39
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
40
40
  /** The h2 ramp as a class, for a caller that must render its own element. */
41
41
  export declare const headingClass: (variant?: VariantProps<typeof h2Variants>["variant"]) => string;
@@ -48,7 +48,7 @@ export declare function TypographyH2({ className, variant, divider, children, ..
48
48
  divider?: boolean;
49
49
  }): import("react").JSX.Element;
50
50
  declare const h3Variants: (props?: ({
51
- variant?: "default" | "display" | null | undefined;
51
+ variant?: "display" | "default" | null | undefined;
52
52
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
53
53
  export declare function TypographyH3({ className, variant, children, ...props }: React.ComponentProps<"h3"> & VariantProps<typeof h3Variants>): import("react").JSX.Element;
54
54
  /** The card / panel title: 14px in the product, 20 on an editorial surface. */
@@ -55,11 +55,11 @@ export declare function TypographyProseList(props: Preset<ListProps, typeof PROS
55
55
  * `sm` is the default because meta is separated from body by ink, not size;
56
56
  * the smaller rungs are a deliberate step down, not the norm.
57
57
  *
58
- * The two upper rungs open their leading past the ramp: plenty of captions are
59
- * a wrapped sentence, and the ramp sets those cramped at reading rungs. The
60
- * micro rung keeps whatever the ramp gives it, which is already tuned tight
61
- * half again the glyph height at 11px reads as a stray gap under a one-line
62
- * note, and it is the rung the labels beside it are set on.
58
+ * Leading comes from the rung, as it does for every other primitive here. Two
59
+ * of these rungs used to pin a fixed ratio on top of the ramp, which held a
60
+ * caption apart from the label beside it and overrode the retune an editorial
61
+ * subtree had just made. An app that wants more air under a wrapped caption
62
+ * moves the rung, which is where the rest of the page reads its leading from.
63
63
  *
64
64
  * `inherit` is the parenthetical inside a heading, an eyebrow or a stat. It
65
65
  * takes the size of whatever set it and resets the weight, because the only
@@ -108,13 +108,19 @@ export declare function TypographyLabel({ className, size, as, children, ...prop
108
108
  *
109
109
  * Tabular is right in a column and wrong in a headline, so it is an axis rather
110
110
  * than a constant. Keep `tabular` anywhere a value updates in place.
111
+ *
112
+ * Leading is the one thing here the rung does not decide. A stat sits on the
113
+ * body rungs, whose leading is room for the line that follows, and a value has
114
+ * no line following it: the ratio lands as dead space arguing with the padding
115
+ * the tile around it already sets. Pinned tight, for the reason a badge is.
111
116
  */
112
117
  declare const statVariants: (props?: ({
113
- size?: "inherit" | "page" | "display" | "card" | "panel" | null | undefined;
118
+ size?: "display" | "base" | "section" | "inherit" | "page" | "sm" | "xs" | "2xs" | "3xs" | "lg" | "xl" | "2xl" | "3xl" | "card" | "panel" | null | undefined;
119
+ tone?: "muted" | "default" | null | undefined;
114
120
  figures?: "tabular" | "proportional" | null | undefined;
115
121
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
116
122
  export type StatVariants = VariantProps<typeof statVariants>;
117
- export declare function TypographyStat({ className, size, figures, children, ...props }: ComponentProps<"span"> & StatVariants): import("react").JSX.Element;
123
+ export declare function TypographyStat({ className, size, figures, tone, children, ...props }: ComponentProps<"span"> & StatVariants): import("react").JSX.Element;
118
124
  /**
119
125
  * A run of code inside a sentence: a command, a field name, a trigger.
120
126
  *
@@ -86,11 +86,11 @@ export function TypographyProseList(props) {
86
86
  * `sm` is the default because meta is separated from body by ink, not size;
87
87
  * the smaller rungs are a deliberate step down, not the norm.
88
88
  *
89
- * The two upper rungs open their leading past the ramp: plenty of captions are
90
- * a wrapped sentence, and the ramp sets those cramped at reading rungs. The
91
- * micro rung keeps whatever the ramp gives it, which is already tuned tight
92
- * half again the glyph height at 11px reads as a stray gap under a one-line
93
- * note, and it is the rung the labels beside it are set on.
89
+ * Leading comes from the rung, as it does for every other primitive here. Two
90
+ * of these rungs used to pin a fixed ratio on top of the ramp, which held a
91
+ * caption apart from the label beside it and overrode the retune an editorial
92
+ * subtree had just made. An app that wants more air under a wrapped caption
93
+ * moves the rung, which is where the rest of the page reads its leading from.
94
94
  *
95
95
  * `inherit` is the parenthetical inside a heading, an eyebrow or a stat. It
96
96
  * takes the size of whatever set it and resets the weight, because the only
@@ -100,8 +100,8 @@ export function TypographyProseList(props) {
100
100
  const captionVariants = cva("text-[color:var(--ink-muted,var(--muted-foreground))]", {
101
101
  variants: {
102
102
  size: {
103
- sm: "text-sm leading-normal",
104
- xs: "text-xs leading-normal",
103
+ sm: "text-sm",
104
+ xs: "text-xs",
105
105
  "2xs": "text-2xs",
106
106
  inherit: "font-normal",
107
107
  },
@@ -158,27 +158,62 @@ export function TypographyLabel({ className, size, as, children, ...props }) {
158
158
  *
159
159
  * Tabular is right in a column and wrong in a headline, so it is an axis rather
160
160
  * than a constant. Keep `tabular` anywhere a value updates in place.
161
+ *
162
+ * Leading is the one thing here the rung does not decide. A stat sits on the
163
+ * body rungs, whose leading is room for the line that follows, and a value has
164
+ * no line following it: the ratio lands as dead space arguing with the padding
165
+ * the tile around it already sets. Pinned tight, for the reason a badge is.
161
166
  */
162
- const statVariants = cva("font-semibold tracking-tight", {
167
+ const statVariants = cva("tracking-tight leading-none", {
163
168
  variants: {
164
169
  /**
165
- * Named rungs, because the ramp was reachable only by spelling a class. The
166
- * three here are the ones call sites actually converged on: `display` is the
167
- * figure a section is built around, `page` and `panel` ride the heading
168
- * ladder so a stat and the heading beside it step together and therefore
169
- * retune together on an editorial surface, which a literal never would.
170
+ * Two ladders, because a figure sits in one of two places and they scale
171
+ * apart on an editorial surface. The rung names are the body ramp: a value
172
+ * in a table cell, a chip or a tile, beside interface copy it should step
173
+ * with. The surface names ride the heading ladder, for a figure that is
174
+ * itself the headline, so it and the heading beside it retune together.
170
175
  *
171
- * `inherit` is the default and writes nothing: a stat inside a heading, a
172
- * chip or a sentence takes the size that set it, and every existing call
173
- * site keeps the size it passed.
176
+ * The heading half shipped alone and covered three call sites in eighteen.
177
+ * The other fifteen wanted a body rung, could not say so, and spelled a
178
+ * class instead, which takes the size and drops everything else the axis
179
+ * carries.
180
+ *
181
+ * `inherit` is the default and writes nothing: a stat inside a heading or a
182
+ * sentence takes the size that set it.
174
183
  */
175
184
  size: {
176
185
  inherit: "",
186
+ "3xs": "text-3xs",
187
+ "2xs": "text-2xs",
188
+ xs: "text-xs",
189
+ sm: "text-sm",
190
+ base: "text-base",
191
+ lg: "text-lg",
192
+ xl: "text-xl",
193
+ "2xl": "text-2xl",
194
+ "3xl": "text-3xl",
177
195
  card: "text-h4",
178
196
  panel: "text-h3",
197
+ section: "text-h2",
179
198
  page: "text-h1",
180
199
  display: "text-6xl font-black",
181
200
  },
201
+ /**
202
+ * How loud the value is. `muted` is the qualifier that follows a figure,
203
+ * "of 2,000" beside "1,284": a value, so it keeps the tight leading, and
204
+ * quiet, so it does not compete with what it qualifies. Unweighted for the
205
+ * reason the caption is, that colour and weight both saying "secondary" is
206
+ * one of them arguing with the other.
207
+ *
208
+ * `default` states no ink on purpose. A stat takes the ink around it, which
209
+ * inside a filled control is that control's label and in a muted row is the
210
+ * row's, and naming one here would repaint every stat that inherits a
211
+ * quieter ink deliberately.
212
+ */
213
+ tone: {
214
+ default: "font-semibold",
215
+ muted: "font-normal text-[color:var(--ink-muted,var(--muted-foreground))]",
216
+ },
182
217
  figures: {
183
218
  /** Even advances stop a value jittering as it refreshes. */
184
219
  tabular: "tabular-nums",
@@ -186,10 +221,10 @@ const statVariants = cva("font-semibold tracking-tight", {
186
221
  proportional: "proportional-nums",
187
222
  },
188
223
  },
189
- defaultVariants: { size: "inherit", figures: "tabular" },
224
+ defaultVariants: { size: "inherit", figures: "tabular", tone: "default" },
190
225
  });
191
- export function TypographyStat({ className, size, figures, children, ...props }) {
192
- return (_jsx("span", { className: cn(statVariants({ size, figures }), className), ...props, children: children }));
226
+ export function TypographyStat({ className, size, figures, tone, children, ...props }) {
227
+ return (_jsx("span", { className: cn(statVariants({ size, figures, tone }), className), ...props, children: children }));
193
228
  }
194
229
  /**
195
230
  * A run of code inside a sentence: a command, a field name, a trigger.
package/llms.txt CHANGED
@@ -57,6 +57,9 @@ silently. Full reference: https://github.com/supertypeai/foundations
57
57
  | a link card, or a grid of them | `Card`, `Cards` | `/blocks` |
58
58
  | an aside, warning or note | `Callout` | `/blocks` |
59
59
  | numbered instructions | `Steps`, `Step` | `/blocks` |
60
+ | an announcement, release note or credits panel | `Bulletin` | `/blocks` |
61
+ | a compact statement block for a page, section, or footer | `Colophon` | `/blocks` |
62
+ | the same statement as one item in a footer row | `BuiltWithFoundations` | `/blocks` |
60
63
  | an FAQ or collapsible, no JS | `Disclosure`, `DisclosureGroup` | `/blocks` |
61
64
  | an animated accordion | `Accordion` (client) | `/blocks` |
62
65
  | tabbed content | `TabGroup` when the tabs are data, `Tabs` to compose by hand (client) | `/blocks` |
@@ -73,7 +76,7 @@ silently. Full reference: https://github.com/supertypeai/foundations
73
76
  | import | exports |
74
77
  |---|---|
75
78
  | `@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` |
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` |
79
+ | `@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`, `Bulletin`, `Ribbon`, `EDITORIAL_INKS`, `Colophon`, `BuiltWithFoundations`, `FoundationsMark`, `FOUNDATIONS_URL`. Types: `ButtonLook`, `BadgeLook`, `TabItem`, `BulletinProps`, `BulletinPoint`, `RibbonHue`, `ColophonProps` |
77
80
  | `@supertype.ai/foundations/mdx` | `proseMdxComponents` |
78
81
  | `@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` |
79
82
  | `@supertype.ai/foundations/seo` | `createSeo`. Types: `SeoConfig`, `ArticleAuthor`, `ArticleOptions`, `PageMetadata` |
@@ -91,7 +94,7 @@ silently. Full reference: https://github.com/supertypeai/foundations
91
94
  prop each one pins is dropped from its type, so passing it fails to compile.
92
95
  - `TypographyCaption`, `TypographyLabel`, `TypographySmall`: `size?: "sm" | "xs" | "2xs" | "inherit"`, `as?`.
93
96
  - `TypographyEyebrow`: `tone?: "heading" | "label" | "muted" | "subtle"`, `size?: "sm" | "xs" | "2xs" | "3xs"`, `as?`. Each tone carries the rung it is usually set at and `size` overrides it, so omitting it changes nothing. Reach for `muted` for the uppercase micro-label a dense product sets over a group of controls, and `subtle` for a column head or rail marker read on the way past — that shape hand-rolled is the most common way an app ends up spelling type classes.
94
- - `TypographyStat`: `size?: "inherit" | "card" | "panel" | "page" | "display"`, `figures?: "tabular" | "proportional"`. Keep tabular where a value updates in place.
97
+ - `TypographyStat`: `size?: "inherit" | "3xs" | "2xs" | "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl" | "card" | "panel" | "section" | "page" | "display"`, `figures?: "tabular" | "proportional"`, `tone?: "default" | "muted"`. Two ladders in one axis: the rung names are the body ramp, for a figure beside interface copy it should step with, and the surface names ride the heading ladder, for a figure that is the headline. `tone="muted"` is the qualifier after a figure, "of 2,000" beside "1,284": still tight, no longer competing. Keep tabular where a value updates in place.
95
98
  - `TypographyLink`: `href` (required), `tone?: Tone` (default `muted`), `addArrow?`, `newTab?`. The href decides internal versus external.
96
99
  - `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.
97
100
  - `Card`: `href`, `title`, `description`, `icon`, `external`. An href makes the whole card a link.
@@ -104,6 +107,9 @@ silently. Full reference: https://github.com/supertypeai/foundations
104
107
  - `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"`.
105
108
  - `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.
106
109
  - `Callout`: `tone?: Tone`, `density?: "compact" | "editorial"`, `title`, `icon`, `action`.
110
+ - `Bulletin`: the panel shape with no copy in it — `variant?: "card" | "line"` (default `card`), `accent` (drawn along the top edge, full width, before the padding; `<Ribbon />` is the one that ships), `eyebrow`, `headline`, `lede`, `points` (a `BulletinPoint[]` of `{ title, body, mark?, ink? }`, where `mark` and `ink` are class names like `bg-fern`/`text-fern-ink`; one point takes the width, two or more go two-up from `sm`), `action` (the control on the footer rule), `footnote` (the line opposite it), `children` (under the rule) and `className`. Every slot is optional and an omitted one renders nothing, so the same component covers a credits panel and a headline with one button under it. Reach for it for an announcement, a release note, a status bulletin. `Ribbon` takes `hues?: RibbonHue[]` (default `EDITORIAL_INKS`, the eight categorical hues as data) and states no size of its own — the caller passes `h-1.5 w-full` on a panel and `h-1 min-w-20 flex-1 rounded-full` in a row.
111
+ - `Colophon`: `Bulletin` with this package's words in it, and the words are not props. `variant?: "card" | "line"` (default `card`), plus `label` (the link's words, for translation) and `children` (the consumer's own credit, rendered under the rule on `card`). A compact statement block for a page (`card`) or a footer row (`line`). There is nothing to configure, so `<Colophon />` is the whole call. Its copy is deliberately not props: this is the package's only content component — every other block is an empty container whose words come from the call site — which makes it a mark rather than a block, and a mark every site rewrites is not a mark. An app wanting to say something in its own words wants a `Card`. It draws the eight categorical hues and inks two of them, all declared in theme.css: an app that imported tokens.css alone renders a panel with an invisible ribbon and no error.
112
+ - **`Colophon` or `BuiltWithFoundations`.** A colophon is a block with something to say and needs a place to say it — a credits page, an about page, the foot of a long article. `BuiltWithFoundations` is one item in a row somebody else laid out, beside a privacy link and a copyright line, and says only where the site came from. The test is what the surface is for: somewhere with its own columns already takes the link, and somewhere the block itself is being placed takes the colophon. There is no `Colophon` variant rendering the link alone, since that would be a second name for a component that already has one. `BuiltWithFoundations` is a `Button variant="outline" size="sm"` with an `href`, so it sits on the same hairline and the same radius rung as every other control on the page. `FoundationsMark` is the eight-hue chip alone and `FOUNDATIONS_URL` the bare href.
107
113
  - `DisclosureGroup`: `type?: "multiple" | "single"`, `defaultValue` (matches the title string).
108
114
  - `TabsList`: `variant?: "default" | "line"`, `tone?: Tone` (default `primary`). The tone
109
115
  inks the marker alone, which on `line` means the underline and the active icon. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supertype.ai/foundations",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/tokens.css CHANGED
@@ -38,6 +38,21 @@
38
38
  --color-info-ink: var(--info-ink);
39
39
  --color-danger: var(--danger);
40
40
 
41
+ /* The one role with no value in this package, and registered anyway. A v4
42
+ utility exists only for a name in the namespace, so without these three
43
+ `bg-brand` compiles to nothing at all and the element keeps whatever ink it
44
+ inherited, silently. Naming a role is structural; the hue stays the app's.
45
+
46
+ The fallbacks are the chains tone.ts already reads, so an app that declares
47
+ no identity hue paints its principal one here too, and an app that declares
48
+ `--brand` alone still gets a readable ink and label rather than nothing. */
49
+ --color-brand: var(--brand, var(--primary));
50
+ --color-brand-ink: var(--brand-ink, var(--primary-ink, var(--primary)));
51
+ --color-brand-foreground: var(
52
+ --brand-foreground,
53
+ var(--primary-foreground)
54
+ );
55
+
41
56
  --color-border: var(--border);
42
57
  --color-input: var(--input);
43
58
  --color-ring: var(--ring);
package/src/type.css CHANGED
@@ -1,5 +1,7 @@
1
- /* Editorial ramp: Tailwind's default sizes, untouched. Density is a product
2
- concern — viably runs its own dense ramp and opts marketing back out to these. */
1
+ /* The type ramp. A product may retune the body rungs at the root, where density
2
+ is its own concern; `.editorial` below restates the reading sizes so a marketing
3
+ or docs subtree inside a dense app lands back on them without the app copying
4
+ Tailwind's table into its own stylesheet. */
3
5
 
4
6
  @theme {
5
7
  /* Below Tailwind's 12px floor. Without them these get written as `text-[11px]`. */
@@ -50,6 +52,24 @@
50
52
  --font-heading: var(--font-average, ui-serif), Georgia, serif;
51
53
  --heading-weight: 400;
52
54
 
55
+ /* Tailwind's own rungs, restated rather than inherited. A dense app has
56
+ already moved these at the root, and this is the surface that has to get
57
+ back to reading sizes: without them `.editorial` retunes the headings and
58
+ leaves the body under them at 13px, which is the ratio the ladder exists
59
+ to keep. Stated here, the class is the whole opt-out. */
60
+ --text-xs: 0.75rem;
61
+ --text-xs--line-height: calc(1 / 0.75);
62
+ --text-sm: 0.875rem;
63
+ --text-sm--line-height: calc(1.25 / 0.875);
64
+ --text-base: 1rem;
65
+ --text-base--line-height: calc(1.5 / 1);
66
+ --text-lg: 1.125rem;
67
+ --text-lg--line-height: calc(1.75 / 1.125);
68
+ --text-xl: 1.25rem;
69
+ --text-xl--line-height: calc(1.75 / 1.25);
70
+ --text-2xl: 1.5rem;
71
+ --text-2xl--line-height: calc(2 / 1.5);
72
+
53
73
  /* The same ladder off an 18px reading rung: 1.11 / 1.33 / 1.67 / 2. Every
54
74
  rung clears the body it sits over — the contract the product values above
55
75
  keep too, and the one the shared rungs could not. */