@supertype.ai/foundations 0.1.29 → 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.
package/dist/contrast.js CHANGED
@@ -131,9 +131,7 @@ export function parseColor(value) {
131
131
  }
132
132
  const hex = input.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
133
133
  if (hex) {
134
- const digits = hex[1].length === 3
135
- ? [...hex[1]].map((d) => d + d).join("")
136
- : hex[1];
134
+ const digits = hex[1].length === 3 ? [...hex[1]].map((d) => d + d).join("") : hex[1];
137
135
  return [
138
136
  parseInt(digits.slice(0, 2), 16),
139
137
  parseInt(digits.slice(2, 4), 16),
@@ -158,6 +156,47 @@ export function contrast(a, b) {
158
156
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
159
157
  return (hi + 0.05) / (lo + 0.05);
160
158
  }
159
+ /**
160
+ * APCA lightness contrast (Lc), the perceptual measure WCAG 3 is built on.
161
+ *
162
+ * It sits beside `contrast` because the two answer different questions and an
163
+ * ink ramp needs both. A WCAG ratio is polarity-blind: it reports the same
164
+ * number whether the text is dark on light or light on dark, when in fact dark
165
+ * glyphs on a bright field thin out and light glyphs on a dark field bloat. That
166
+ * blindness is what lets a ramp be ordered by ratio and still read flat — viably
167
+ * shipped a `--muted-foreground` measuring 72.5 Lc in light and 52.1 in dark,
168
+ * the same verdict from `contrast` on both sides and twenty points apart to a
169
+ * reader.
170
+ *
171
+ * Lc also states the term a ratio cannot: legibility is contrast times size, so
172
+ * a floor here is what says an ink comfortable at 16px is or is not comfortable
173
+ * on the 13px rung a dense product actually spends.
174
+ *
175
+ * Returned absolute. It is signed by polarity in the specification, and every
176
+ * caller so far asks "is this legible", never "which way round is it".
177
+ */
178
+ export function lc(text, background) {
179
+ // Screen luminance on APCA's own curve, which is not WCAG's: exponent 2.4 on
180
+ // the raw channel, with weights of its own.
181
+ const y = ([r, g, b]) => {
182
+ const v = 0.2126729 * (r / 255) ** 2.4 +
183
+ 0.7151522 * (g / 255) ** 2.4 +
184
+ 0.072175 * (b / 255) ** 2.4;
185
+ // Soft clamp near black, where the power curve stops modelling perception.
186
+ return v < 0.022 ? v + (0.022 - v) ** 1.414 : v;
187
+ };
188
+ const [yText, yBackground] = [y(text), y(background)];
189
+ // Two exponent pairs, one per polarity. This asymmetry is the whole reason Lc
190
+ // says something a ratio cannot.
191
+ const s = yBackground > yText
192
+ ? (yBackground ** 0.56 - yText ** 0.57) * 1.14
193
+ : (yBackground ** 0.65 - yText ** 0.62) * 1.14;
194
+ // Below the noise floor the two are the same colour as far as a reader is
195
+ // concerned, and the offset below would report a spurious 2.7.
196
+ if (Math.abs(s) < 0.1)
197
+ return 0;
198
+ return Math.abs(s > 0 ? (s - 0.027) * 100 : (s + 0.027) * 100);
199
+ }
161
200
  const INKS = ["--foreground", "--muted-foreground", "--card-foreground"];
162
201
  const SURFACES = ["--background", "--card", "--muted"];
163
202
  /**
@@ -200,6 +239,16 @@ const FILLS = [
200
239
  "--stone",
201
240
  "--fig",
202
241
  "--cocoa",
242
+ // A chart series is a mark like any other, and docs/cli.md has always said so
243
+ // ("a status dot or a chart bar that cannot be picked out of its background").
244
+ // Leaving them out of this list is how the sand shipped at 2.18:1 in light and
245
+ // the taupe at 2.26:1 in dark: a promise in prose that nothing measured.
246
+ "--chart-1",
247
+ "--chart-2",
248
+ "--chart-3",
249
+ "--chart-4",
250
+ "--chart-5",
251
+ "--chart-6",
203
252
  ];
204
253
  /**
205
254
  * A fill has to separate from the page and from a card. Not from `--muted`: a
@@ -209,6 +258,7 @@ const FILLS = [
209
258
  const FILL_SURFACES = ["--background", "--card"];
210
259
  /** The same hues as words, at the bar body copy is held to. */
211
260
  const INKS_TINTED = [
261
+ "--primary-ink",
212
262
  "--success-ink",
213
263
  "--warn-ink",
214
264
  "--info-ink",
@@ -223,14 +273,29 @@ const INKS_TINTED = [
223
273
  "--fig-ink",
224
274
  "--cocoa-ink",
225
275
  ];
276
+ /**
277
+ * The tertiary ink, at the 3:1 its own comment in theme.css claims for it —
278
+ * placeholders and disabled labels, never anything load-bearing. Held here
279
+ * rather than in `INKS` because 4.5:1 would fail a token that is correct; held
280
+ * *somewhere* because the sentence stating the bar was the only thing enforcing
281
+ * it, and light sits at 3.14:1 on --muted with nothing watching the gap.
282
+ */
283
+ const TERTIARY = ["--subtle-foreground"];
226
284
  /**
227
285
  * shadcn's shape: `-foreground` is the label printed on the fill, so the pair is
228
286
  * measured against itself rather than against the page.
287
+ *
288
+ * `--success` and `--warn` joined the list when the tone table stopped making
289
+ * exceptions of them. A filled status control is a real thing, `Button
290
+ * tone="warn" variant="solid"` renders one, and white on amber measured 2.44:1
291
+ * on the dark theme for as long as the pair went unnamed here.
229
292
  */
230
293
  const ON_FILL = [
231
294
  ["--primary", "--primary-foreground"],
232
295
  ["--secondary", "--secondary-foreground"],
233
296
  ["--destructive", "--destructive-foreground"],
297
+ ["--success", "--success-foreground"],
298
+ ["--warn", "--warn-foreground"],
234
299
  ["--accent", "--accent-foreground"],
235
300
  ["--card", "--card-foreground"],
236
301
  ["--popover", "--popover-foreground"],
@@ -254,14 +319,63 @@ export function tokenCuts(token) {
254
319
  asInk: INKS_TINTED.find((ink) => ink === `${fill}-ink`),
255
320
  };
256
321
  }
322
+ /**
323
+ * A hairline is neither ink nor a mark, so neither bar fits: WCAG exempts a
324
+ * decorative rule outright, and holding one to 3:1 would draw a box, not a
325
+ * border. What it owes is symmetry — the same rule has to read as the same
326
+ * weight in both themes, and it did not: the dark hairline was tuned by hand
327
+ * (L22's 1.34:1 on --card was rejected as too faint) while the light one was
328
+ * never measured at all and shipped under the value dark had turned down.
329
+ *
330
+ * 1.4:1 is that floor, set just under the pair the themes now agree on. Only
331
+ * --background and --card: a rule inside a `muted` well sits on a surface that
332
+ * is itself a wash, and 1.3:1 is the practical floor for that kind of well.
333
+ */
334
+ const HAIRLINES = ["--border", "--input"];
335
+ const HAIRLINE_SURFACES = ["--background", "--card"];
336
+ /**
337
+ * The sidebar keeps its own pair, because a rule there is drawn on `--sidebar`
338
+ * and never on the page. Measuring it against `--background` would fail a border
339
+ * that is correct and pass one that is not.
340
+ */
341
+ const SIDEBAR_HAIRLINE = [
342
+ "--sidebar-border",
343
+ "--sidebar",
344
+ ];
345
+ /**
346
+ * The bar a rule owes, held apart from `checkSignals` because it is not a
347
+ * signal: nothing here carries meaning in its hue, it only has to be seen.
348
+ */
349
+ export function checkHairlines(css, { themes = ["light", "dark"] } = {}) {
350
+ return [
351
+ ...checkLegibility(css, {
352
+ inks: HAIRLINES,
353
+ surfaces: HAIRLINE_SURFACES,
354
+ minimum: 1.4,
355
+ themes,
356
+ }),
357
+ ...checkLegibility(css, {
358
+ inks: [SIDEBAR_HAIRLINE[0]],
359
+ surfaces: [SIDEBAR_HAIRLINE[1]],
360
+ minimum: 1.4,
361
+ themes,
362
+ }),
363
+ ];
364
+ }
257
365
  /**
258
366
  * The three bars a palette owes, run over the same engine as `checkLegibility`.
259
367
  * Without this the numbers in a theme's comments are claims, not measurements.
260
368
  */
261
369
  export function checkSignals(css, { themes = ["light", "dark"] } = {}) {
262
370
  return [
263
- ...checkLegibility(css, { inks: FILLS, surfaces: FILL_SURFACES, minimum: 3, themes }),
371
+ ...checkLegibility(css, {
372
+ inks: FILLS,
373
+ surfaces: FILL_SURFACES,
374
+ minimum: 3,
375
+ themes,
376
+ }),
264
377
  ...checkLegibility(css, { inks: INKS_TINTED, themes }),
378
+ ...checkLegibility(css, { inks: TERTIARY, minimum: 3, themes }),
265
379
  ...ON_FILL.flatMap(([fill, label]) => checkLegibility(css, { inks: [label], surfaces: [fill], themes })),
266
380
  ];
267
381
  }
package/dist/eslint.d.ts CHANGED
@@ -20,11 +20,27 @@ export declare function colourRules({ accents, }?: ColourOptions): RestrictedSyn
20
20
  */
21
21
  export declare function themeOverrideRules(): RestrictedSyntax[];
22
22
  /**
23
- * `--muted` is a fill at L92%, so `text-muted` is ~1.1:1 — invisible, and it
24
- * shipped at 17 sites. `text-background` is absent: inverse ink is a real role.
23
+ * `--muted` is a fill at L92%, so `text-muted` lands at ~1.1:1. Invisible, and
24
+ * it shipped at 17 sites. `text-background` stays legal: inverse ink is a real
25
+ * role.
25
26
  */
26
27
  export declare function surfaceAsInkRules(): RestrictedSyntax[];
27
28
  export declare function renamedTokenRules(): RestrictedSyntax[];
29
+ /**
30
+ * `render={<a href="…" />}` on a component that takes an `href`. It reads as a
31
+ * styling choice and is a routing one: the cloned anchor skips the router, so
32
+ * the page fully reloads and the view transition is lost, and an off-site href
33
+ * never grows a `rel`. Button, Badge and Card each decide internal vs external
34
+ * from the href itself, so the anchor is never needed and cannot be right more
35
+ * often than the one shared rule is.
36
+ *
37
+ * Narrow on both axes, so it never fires on a line that is correct. Only those
38
+ * three components — `RailLink` deliberately takes a router element through
39
+ * `render`, because its module has to stay importable without Next. And only a
40
+ * bare `<a>`: `render={<Link/>}` is redundant beside `href` but it still routes,
41
+ * so it is not a bug.
42
+ */
43
+ export declare function linkRules(): RestrictedSyntax[];
28
44
  export interface TypographyOptions {
29
45
  /** Three-weight ramp. Off for editorial, where 700 is a register not a shout. */
30
46
  weights?: boolean;
@@ -40,7 +56,7 @@ export interface TypographyOptions {
40
56
  /**
41
57
  * Flag a size class on a primitive that already owns a size axis. Off by
42
58
  * default for the same reason as `pairing`: it fails until the consumer has
43
- * migrated, and the migration is the point.
59
+ * migrated, and that migration is the intended end state.
44
60
  */
45
61
  axis?: boolean;
46
62
  }
@@ -48,7 +64,7 @@ export declare function typographyRules({ weights, ramp, pairing, axis, }?: Typo
48
64
  /**
49
65
  * Every design rule, as one list.
50
66
  *
51
- * The five builders below it are still exported, and spreading them by hand is
67
+ * The builders below it are still exported, and spreading them by hand is
52
68
  * what both consumers were doing — one of them into a flat config, the other
53
69
  * into a legacy `.eslintrc`, and *both* of them had quietly left out
54
70
  * `renamedTokenRules`, so neither would have flagged a deprecated token name.
package/dist/eslint.js CHANGED
@@ -39,22 +39,49 @@ export function themeOverrideRules() {
39
39
  return rule(`/(^| )dark:(${COLOUR_PREFIX})-(${TOKEN})($| )/`, "A `dark:` override on a token means the token is wrong — fix it in theme.css, where one change covers every call site, rather than here. Alpha variants (dark:bg-destructive/20) stay legal: those tune a wash's density, not the token.");
40
40
  }
41
41
  /**
42
- * `--muted` is a fill at L92%, so `text-muted` is ~1.1:1 — invisible, and it
43
- * shipped at 17 sites. `text-background` is absent: inverse ink is a real role.
42
+ * `--muted` is a fill at L92%, so `text-muted` lands at ~1.1:1. Invisible, and
43
+ * it shipped at 17 sites. `text-background` stays legal: inverse ink is a real
44
+ * role.
44
45
  */
45
46
  export function surfaceAsInkRules() {
46
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
48
  }
48
49
  /**
49
50
  * `-foreground` means the label printed on a fill; `-ink` means the hue as
50
- * words. `warn-foreground` and the eight categorical `-foreground` tokens were
51
- * always inks, under the other name. The old spellings still resolve, so nothing
52
- * breaks on the day of the rename; this is what stops them surviving it.
51
+ * words. The eight categorical `-foreground` tokens were always inks, under the
52
+ * other name. The old spellings still resolve, so nothing breaks on the day of
53
+ * the rename; this is what stops them surviving it.
54
+ *
55
+ * `warn` left this list when the status tones gained real on-fill labels:
56
+ * `--warn-foreground` now means what its name says, the ink printed on the warn
57
+ * fill, and `Button tone="warn" variant="solid"` is what reads it.
53
58
  */
54
- const RENAMED_INKS = "warn|terracotta|ochre|moss|fern|sage|stone|fig|cocoa";
59
+ const RENAMED_INKS = "terracotta|ochre|moss|fern|sage|stone|fig|cocoa";
55
60
  export function renamedTokenRules() {
56
61
  return rule(`/(^| )(dark:|hover:|focus:|group-hover:)*(text|bg|border|ring|fill|stroke|decoration)-(${RENAMED_INKS})-foreground($| )/`, "That is the deprecated name for the same hue's `-ink`. In this package `-foreground` is the label printed on a fill and `-ink` is the hue used as words, and none of these hues has a printed-on label — they are checked at 4.5:1 against the page, and printing one on its own fill measures about 1.2:1. Use `-ink`.");
57
62
  }
63
+ /**
64
+ * `render={<a href="…" />}` on a component that takes an `href`. It reads as a
65
+ * styling choice and is a routing one: the cloned anchor skips the router, so
66
+ * the page fully reloads and the view transition is lost, and an off-site href
67
+ * never grows a `rel`. Button, Badge and Card each decide internal vs external
68
+ * from the href itself, so the anchor is never needed and cannot be right more
69
+ * often than the one shared rule is.
70
+ *
71
+ * Narrow on both axes, so it never fires on a line that is correct. Only those
72
+ * three components — `RailLink` deliberately takes a router element through
73
+ * `render`, because its module has to stay importable without Next. And only a
74
+ * bare `<a>`: `render={<Link/>}` is redundant beside `href` but it still routes,
75
+ * so it is not a bug.
76
+ */
77
+ export function linkRules() {
78
+ return [
79
+ {
80
+ selector: 'JSXOpeningElement[name.name=/^(Button|Badge|Card)$/] > JSXAttribute[name.name="render"] > JSXExpressionContainer > JSXElement > JSXOpeningElement[name.name="a"]',
81
+ message: "Pass `href` instead of rendering an anchor. A cloned <a> bypasses the router (full page load, no view transition) and gets no rel on an off-site href; `href` routes through the package's one rule. `render` is for an element that is not a link.",
82
+ },
83
+ ];
84
+ }
58
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, } = {}) {
59
86
  return [
60
87
  // Alpha ink composites against whatever surface it lands on, so its
@@ -100,7 +127,7 @@ export function typographyRules({ weights = false, ramp = "text-3xs 10 / text-2x
100
127
  ? [
101
128
  {
102
129
  selector: 'JSXElement:has(>JSXOpeningElement[name.name="TypographyP"]) ~ JSXElement > JSXOpeningElement[name.name="TypographyProseList"]',
103
- message: "A ui paragraph over a prose list splits one passage across two rungs. Promote the paragraph with TypographyProse, or drop the list to the paragraph's rung with TypographyList variant=\"ui\".",
130
+ message: 'A ui paragraph over a prose list splits one passage across two rungs. Promote the paragraph with TypographyProse, or drop the list to the paragraph\'s rung with TypographyList variant="ui".',
104
131
  },
105
132
  ]
106
133
  : []),
@@ -113,6 +140,7 @@ export function designRules({ accents, typography = true, ...type } = {}) {
113
140
  return [
114
141
  ...colourRules({ accents }),
115
142
  ...(typography ? typographyRules(type) : []),
143
+ ...linkRules(),
116
144
  ...themeOverrideRules(),
117
145
  ...surfaceAsInkRules(),
118
146
  ...renamedTokenRules(),
@@ -136,10 +164,7 @@ export function designConfig({ files = ["**/*.{ts,tsx,js,jsx}"], ...options } =
136
164
  name: "@supertype.ai/foundations/design",
137
165
  files,
138
166
  rules: {
139
- "no-restricted-syntax": [
140
- "error",
141
- ...designRules(options),
142
- ],
167
+ "no-restricted-syntax": ["error", ...designRules(options)],
143
168
  },
144
169
  },
145
170
  ];
@@ -5,7 +5,7 @@ import type { TocHeading } from "./toc.js";
5
5
  * moment an aside appeared, setting body copy on a different axis per page.
6
6
  * The measure grows per step — a comfortable line length is a range.
7
7
  *
8
- * 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
9
9
  * container can pay for it:
10
10
  *
11
11
  * aside = (container − 3rem padding − measure) ÷ 2 − 2.5rem gutter
@@ -6,7 +6,7 @@ import { ReadingRail } from "./reading.js";
6
6
  * moment an aside appeared, setting body copy on a different axis per page.
7
7
  * The measure grows per step — a comfortable line length is a range.
8
8
  *
9
- * 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
10
10
  * container can pay for it:
11
11
  *
12
12
  * aside = (container − 3rem padding − measure) ÷ 2 − 2.5rem gutter
@@ -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;
@@ -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,3 +1,4 @@
1
1
  export { cn } from "./cn.js";
2
- export { toneClass, impliedTone, type Tone } from "./tone.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";
3
4
  export * from "./typography/index.js";
package/dist/index.js CHANGED
@@ -1,9 +1,13 @@
1
1
  export { cn } from "./cn.js";
2
2
  // The semantic colour vocabulary. Exported from the root because typography
3
- // takes it too a link has a tone, and it is the same seven a button has.
3
+ // takes it too: a link has a tone, drawn from the same seven a button has.
4
4
  // `toneClass` only: the raw table and its derived half used to ship separately,
5
5
  // and the order they were combined in was load-bearing.
6
- export { toneClass, impliedTone } from "./tone.js";
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";
7
11
  export * from "./typography/index.js";
8
12
  // NOTE: blocks, the MDX map, and the Shiki plugin are all deliberately absent
9
13
  // from this barrel.
package/dist/tone.d.ts CHANGED
@@ -30,8 +30,8 @@
30
30
  */
31
31
  /**
32
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, and it is not a wash of `--foreground`.
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
35
  *
36
36
  * Its hue is `--foreground` rather than `--muted-foreground` on purpose. `muted`
37
37
  * says the control carries no meaning, not that it carries less contrast — a
@@ -47,15 +47,15 @@ export declare const TONE: {
47
47
  /** No meaning: chrome, toolbars, anything that must not compete. */
48
48
  readonly muted: "[--tone-fill:var(--muted)] [--tone-ink:var(--foreground)] [--tone-hue:var(--foreground)] [--tone-line:var(--border)]";
49
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)]";
50
+ readonly primary: "[--tone-fill:var(--primary)] [--tone-ink:var(--primary-foreground)] [--tone-hue:var(--primary-ink,var(--primary))]";
51
51
  /** The warm accent. `--secondary-ink` is its readable cut; the fill is not. */
52
52
  readonly secondary: "[--tone-fill:var(--secondary)] [--tone-ink:var(--secondary-foreground)] [--tone-hue:var(--secondary-ink)]";
53
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(--tint-foreground)] [--tone-hue:var(--brand-ink,var(--primary))]";
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
55
  /** It worked. */
56
- readonly success: "[--tone-fill:var(--success)] [--tone-ink:var(--tint-foreground)] [--tone-hue:var(--success-ink)]";
56
+ readonly success: "[--tone-fill:var(--success)] [--tone-ink:var(--success-foreground)] [--tone-hue:var(--success-ink)]";
57
57
  /** A footgun: the reader can still proceed, but not blindly. */
58
- readonly warn: "[--tone-fill:var(--warn)] [--tone-ink:var(--tint-foreground)] [--tone-hue:var(--warn-ink)]";
58
+ readonly warn: "[--tone-fill:var(--warn)] [--tone-ink:var(--warn-foreground)] [--tone-hue:var(--warn-ink)]";
59
59
  /** It deletes something, or it already failed. */
60
60
  readonly destructive: "[--tone-fill:var(--destructive)] [--tone-ink:var(--destructive-foreground)] [--tone-hue:var(--destructive)]";
61
61
  };
@@ -70,9 +70,13 @@ export type Tone = keyof typeof TONE;
70
70
  * while `color-mix` is what the modifier compiles to anyway — the same CSS, one
71
71
  * layer less of trust.
72
72
  *
73
- * `--tone-fill-hover` mixes toward `--foreground` rather than darkening by a
74
- * fixed amount, so a filled control deepens on the light theme and lifts on the
75
- * dark one from a single declaration. A `dark:` override here is what the
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
76
80
  * package's own ESLint rule exists to prevent.
77
81
  */
78
82
  export declare const TONE_SURFACE: string;
@@ -87,6 +91,31 @@ export declare const TONE_SURFACE: string;
87
91
  * whose order matters and whose values always travel together is one argument.
88
92
  */
89
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;
90
119
  /**
91
120
  * What an unstated tone means, given how much ink the component is spending.
92
121
  * Shared, because `Button` and `Badge` both need it and two copies of a default