@supertype.ai/foundations 0.1.24

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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +369 -0
  3. package/bin/foundations.mjs +713 -0
  4. package/dist/blocks/accordion.d.ts +23 -0
  5. package/dist/blocks/accordion.js +59 -0
  6. package/dist/blocks/callout.d.ts +57 -0
  7. package/dist/blocks/callout.js +61 -0
  8. package/dist/blocks/card.d.ts +34 -0
  9. package/dist/blocks/card.js +56 -0
  10. package/dist/blocks/index.d.ts +7 -0
  11. package/dist/blocks/index.js +7 -0
  12. package/dist/blocks/interactive-accordion.d.ts +13 -0
  13. package/dist/blocks/interactive-accordion.js +27 -0
  14. package/dist/blocks/segment.d.ts +37 -0
  15. package/dist/blocks/segment.js +37 -0
  16. package/dist/blocks/steps.d.ts +10 -0
  17. package/dist/blocks/steps.js +13 -0
  18. package/dist/blocks/tabs.d.ts +32 -0
  19. package/dist/blocks/tabs.js +69 -0
  20. package/dist/cjs/eslint.js +146 -0
  21. package/dist/cjs/package.json +3 -0
  22. package/dist/cn.d.ts +2 -0
  23. package/dist/cn.js +5 -0
  24. package/dist/contrast.d.ts +47 -0
  25. package/dist/contrast.js +255 -0
  26. package/dist/eslint.d.ts +74 -0
  27. package/dist/eslint.js +138 -0
  28. package/dist/essay/contents.d.ts +10 -0
  29. package/dist/essay/contents.js +17 -0
  30. package/dist/essay/essay.d.ts +125 -0
  31. package/dist/essay/essay.js +92 -0
  32. package/dist/essay/index.d.ts +7 -0
  33. package/dist/essay/index.js +9 -0
  34. package/dist/essay/layout.d.ts +72 -0
  35. package/dist/essay/layout.js +77 -0
  36. package/dist/essay/rail.d.ts +15 -0
  37. package/dist/essay/rail.js +26 -0
  38. package/dist/essay/reading.d.ts +17 -0
  39. package/dist/essay/reading.js +31 -0
  40. package/dist/essay/scroll.d.ts +8 -0
  41. package/dist/essay/scroll.js +78 -0
  42. package/dist/essay/toc.d.ts +23 -0
  43. package/dist/essay/toc.js +50 -0
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.js +33 -0
  46. package/dist/injection.d.ts +8 -0
  47. package/dist/injection.js +1 -0
  48. package/dist/mdx.d.ts +47 -0
  49. package/dist/mdx.js +68 -0
  50. package/dist/og.d.ts +18 -0
  51. package/dist/og.js +50 -0
  52. package/dist/rehype.d.ts +18 -0
  53. package/dist/rehype.js +41 -0
  54. package/dist/seo.d.ts +174 -0
  55. package/dist/seo.js +152 -0
  56. package/dist/typography/as.d.ts +15 -0
  57. package/dist/typography/as.js +8 -0
  58. package/dist/typography/header.d.ts +44 -0
  59. package/dist/typography/header.js +119 -0
  60. package/dist/typography/highlight.d.ts +33 -0
  61. package/dist/typography/highlight.js +98 -0
  62. package/dist/typography/index.d.ts +4 -0
  63. package/dist/typography/index.js +3 -0
  64. package/dist/typography/paragraph.d.ts +157 -0
  65. package/dist/typography/paragraph.js +229 -0
  66. package/llms.txt +125 -0
  67. package/package.json +140 -0
  68. package/src/prose.css +12 -0
  69. package/src/shiki.css +23 -0
  70. package/src/theme.css +272 -0
  71. package/src/tokens.css +43 -0
  72. package/src/type.css +73 -0
package/dist/eslint.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The design rules, as ESLint selectors, shared so the two apps cannot drift.
3
+ * Plain data — esquery matches these against className strings, so no plugin.
4
+ * They see values, never the shape of a class list; a primitive fixes that.
5
+ */
6
+ /** A className written as a plain string, or as a chunk of a template literal. */
7
+ const classString = (pattern) => [
8
+ `Literal[value=${pattern}]`,
9
+ `TemplateElement[value.raw=${pattern}]`,
10
+ ];
11
+ const rule = (pattern, message) => classString(pattern).map((selector) => ({ selector, message }));
12
+ const VARIANTS = "(dark:|hover:|focus:|group-hover:|active:|disabled:|sm:|md:|lg:|xl:)*";
13
+ const PALETTE = "(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)";
14
+ /** Applies everywhere, marketing included — the tints exist for those pages. */
15
+ export function colourRules({ accents = "a brand accent", } = {}) {
16
+ return [
17
+ ...rule(`/(^| )${VARIANTS}(text|bg|border|ring|from|to|via|fill|stroke|divide|outline|placeholder|shadow|decoration|accent|caret)-${PALETTE}-\\d+/`, `Raw Tailwind palette. Use a token — text-foreground / text-muted-foreground / text-subtle-foreground, bg-background / bg-card / bg-muted, border-border — a status token (success, warn, destructive), or ${accents}.`),
18
+ // Split by prefix: alpha on a fill is a scrim (the effect), on ink or a
19
+ // hairline it is just an undeclared colour.
20
+ ...rule(`/(^| )${VARIANTS}(bg|from|to|via|shadow)-(white|black)($| )/`, "Solid white/black is a hand-rolled surface. Use bg-card / bg-background, or bg-tint for a tinted panel. Alpha scrims (bg-black/50) stay legal — there the point is the transparency, not the hue."),
21
+ ...rule(`/(^| )${VARIANTS}(text|border|ring|divide|fill|stroke|decoration|outline|placeholder)-(white|black)($| |\\x2f)/`, "White/black ink and hairlines are hand-rolled colour, alpha or not. Use text-foreground / text-background for ink, text-tint-foreground for ink on a tinted surface, and border-border for a hairline — the token already carries the alpha the theme wants."),
22
+ ...rule("/(^| )(text|bg|border|fill|stroke|ring)-\\[#/", "Hex colours bypass the token system entirely. Add a token if the colour is real; use an existing one if it is not."),
23
+ ];
24
+ }
25
+ /**
26
+ * The semantic tokens, for the rules that care which token it is rather than
27
+ * whether one was used at all.
28
+ */
29
+ const TOKEN = "foreground|muted-foreground|subtle-foreground|muted|card-foreground|card|popover-foreground|popover|" +
30
+ "secondary-foreground|secondary|accent-foreground|accent|primary-foreground|primary|border|input|ring|" +
31
+ "background|destructive-foreground|destructive|success|warn|info|brand|tint-foreground";
32
+ const COLOUR_PREFIX = "text|bg|border|ring|from|to|via|fill|stroke|divide|placeholder|decoration|shadow|outline";
33
+ /**
34
+ * A token already knows what it does in the dark; `dark:` on one says the token
35
+ * is wrong. Only the solid form is restricted — `dark:bg-destructive/20` against
36
+ * a `/10` is the same token at the density a darker ground needs.
37
+ */
38
+ export function themeOverrideRules() {
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
+ }
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.
44
+ */
45
+ export function surfaceAsInkRules() {
46
+ 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
+ /**
49
+ * `-foreground` means the label printed on a fill; `-ink` means the hue as
50
+ * words. `warn-foreground` was always the ink, under the other name.
51
+ */
52
+ export function renamedTokenRules() {
53
+ return rule("/(^| )(dark:|hover:|focus:|group-hover:)*(text|bg|border|ring|fill|stroke)-warn-foreground($| )/", "`warn-foreground` is the deprecated name for `warn-ink`. In this package `-foreground` is the label printed on a fill and `-ink` is the hue used as text; warn has no printed-on label, because orange carries white text at no lightness. Use warn-ink.");
54
+ }
55
+ 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, } = {}) {
56
+ return [
57
+ // Alpha ink composites against whatever surface it lands on, so its
58
+ // contrast is unmeasurable. The ink tokens are measured.
59
+ // `\x2f`, never a literal slash: ESLint 8's esquery ends the regex at the
60
+ // first `/` it sees, however escaped, and hands RegExp the truncated half.
61
+ ...rule("/text-(foreground|muted-foreground|subtle-foreground)\\x2f\\d+/", "Alpha on a text token has unmeasurable contrast (it composites against whatever surface it lands on). Use text-foreground (primary), text-muted-foreground (secondary), or text-subtle-foreground (tertiary)."),
62
+ // px only: a rem value at display scale is an ornament, not a rung.
63
+ ...rule("/(^| )text-\\[\\d+px\\]/", `Arbitrary font sizes bypass the type ramp. Use a rung (${ramp}).`),
64
+ // A primitive that owns a size axis, reached past for a class that does the
65
+ // same thing. The class wins on the page, so nothing looks wrong — what is
66
+ // lost is everything else the axis carries: `TypographyCaption` pins leading
67
+ // per rung because a wrapped caption sets cramped at the ramp's own setting,
68
+ // and `TypographyStat` pairs its rungs with the heading ladder so a figure
69
+ // and the heading beside it retune together on an editorial surface. A
70
+ // literal gets the size and silently drops the rest.
71
+ //
72
+ // Matching the class node INSIDE the attribute, rather than the className
73
+ // string on its own, lets this name the component. It reaches into `cn()`
74
+ // for free, since the argument sits in the same subtree.
75
+ ...(axis
76
+ ? [
77
+ // Both node kinds, for the same reason `classString` above covers both: a
78
+ // class list assembled in a template literal is the shape a call site
79
+ // reaches for precisely when it is doing something conditional, which
80
+ // is where a stray rung is most likely to be hiding.
81
+ ...["Literal[value", "TemplateElement[value.raw"].map((node) => ({
82
+ selector: `JSXOpeningElement[name.name=/^Typography(Small|Caption|Stat|Eyebrow)$/] JSXAttribute[name.name="className"] ${node}=/(^| )text-(3xs|2xs|xs|sm|base|lg|xl|[2-9]xl|h[1-4])( |$)/]`,
83
+ message: "This primitive owns its size: pass the axis (TypographySmall/Caption size=, TypographyStat size=, TypographyEyebrow tone=) rather than a text-* class, which takes the size and drops the leading and ladder that come with the rung.",
84
+ })),
85
+ ]
86
+ : []),
87
+ // Two valid primitives forming an invalid pair, which the value rules above
88
+ // cannot see: `<TypographyP>` is the 14px interface rung, and the list under
89
+ // it reads at the prose rung, so one passage lands two rungs apart.
90
+ //
91
+ // `~` and never `+`: JSX puts a whitespace text node between sibling
92
+ // elements, and an adjacent-sibling selector will not cross it — measured,
93
+ // `+` matches nothing at all here. The cost of `~` is that it means "any
94
+ // later sibling", so it can reach past an intervening paragraph; on a corpus
95
+ // of 168 files it fired four times and was right four times.
96
+ ...(pairing
97
+ ? [
98
+ {
99
+ selector: 'JSXElement:has(>JSXOpeningElement[name.name="TypographyP"]) ~ JSXElement > JSXOpeningElement[name.name="TypographyProseList"]',
100
+ 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\".",
101
+ },
102
+ ]
103
+ : []),
104
+ ...(weights
105
+ ? rule("/(^| )font-(bold|extrabold|black)( |$)/", "The product weight ramp is 400 body / 500 label / 600 heading. Use font-semibold for headings, or a label primitive for a label.")
106
+ : []),
107
+ ];
108
+ }
109
+ /**
110
+ * Every rule in one flat-config entry, ready to spread into eslint.config.js:
111
+ *
112
+ * import { designConfig } from "@supertype.ai/foundations/eslint";
113
+ * export default [ ...designConfig({ accents: "the brand tints" }) ];
114
+ *
115
+ * One entry is not a detail. Flat config replaces a rule's options rather than
116
+ * merging them, so two blocks covering overlapping files leave only the last
117
+ * one's rules in effect. Combining them here is what stops a consumer losing
118
+ * half the set by accident. If you need a second scope, call this again with a
119
+ * different `files` and no overlap.
120
+ */
121
+ export function designConfig({ files = ["**/*.{ts,tsx,js,jsx}"], accents, weights, ramp, pairing, axis, } = {}) {
122
+ return [
123
+ {
124
+ name: "@supertype.ai/foundations/design",
125
+ files,
126
+ rules: {
127
+ "no-restricted-syntax": [
128
+ "error",
129
+ ...colourRules({ accents }),
130
+ ...typographyRules({ weights, ramp, pairing, axis }),
131
+ ...themeOverrideRules(),
132
+ ...surfaceAsInkRules(),
133
+ ...renamedTokenRules(),
134
+ ],
135
+ },
136
+ },
137
+ ];
138
+ }
@@ -0,0 +1,10 @@
1
+ import type { EssayIndexEntry } from "./essay.js";
2
+ /**
3
+ * The margin index, with the section you are in marked. Separate from
4
+ * `EssayLayout` so only this nav crosses the client boundary.
5
+ */
6
+ export declare function TableOfContents({ sections, label, }: {
7
+ sections: readonly EssayIndexEntry[];
8
+ /** The rail's own heading. Set it to `null` to render the links alone. */
9
+ label?: React.ReactNode;
10
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Rail, RailLink } from "./rail.js";
4
+ import { useScrollSpy } from "./scroll.js";
5
+ /**
6
+ * The margin index, with the section you are in marked. Separate from
7
+ * `EssayLayout` so only this nav crosses the client boundary.
8
+ */
9
+ export function TableOfContents({ sections, label = "On this page", }) {
10
+ const active = useScrollSpy(sections.map((s) => s.id));
11
+ // Nothing to index is nothing to render: an empty list would still draw the
12
+ // label and the rail's hairline in the margin. `ReadingRail` bails the same
13
+ // way. Placed after the hook so the call order never changes.
14
+ if (sections.length === 0)
15
+ return null;
16
+ return (_jsxs("nav", { "aria-label": "Page sections", children: [label ? (_jsx("p", { className: "mb-4 text-xs font-medium uppercase tracking-widest text-muted-foreground", children: label })) : null, _jsx(Rail, { children: sections.map(({ id, label: text }) => (_jsx(RailLink, { href: `#${id}`, active: active === id, children: text }, id))) })] }));
17
+ }
@@ -0,0 +1,125 @@
1
+ import type { ComponentProps, ComponentType, ReactNode } from "react";
2
+ /**
3
+ * The long-form reading surface: a page read from the top, not scanned.
4
+ * `EssayColumns` sets the measure, `EssayHeader` the opening, `EssayDocument`
5
+ * the whole of a page whose sections are just heading and prose.
6
+ */
7
+ /** One entry in the margin index, matching the `id` of the section it points at. */
8
+ export type EssayIndexEntry = {
9
+ id: string;
10
+ label: string;
11
+ };
12
+ /**
13
+ * Motion and gradient belong to an app, not the package. Both default to
14
+ * nothing, so a consumer supplying neither gets the same markup, statically.
15
+ */
16
+ export interface EssayDecorations {
17
+ /** `"scale"` is the only variant named, so a narrower union still satisfies it. */
18
+ Reveal?: ComponentType<{
19
+ children: ReactNode;
20
+ className?: string;
21
+ eager?: boolean;
22
+ variant?: "scale";
23
+ }>;
24
+ /** A backdrop behind the header. Rendered before the content, positioned by class. */
25
+ Glow?: ComponentType<{
26
+ className?: string;
27
+ intensity?: number;
28
+ }>;
29
+ }
30
+ /** One section of a reference document: a heading, and the prose under it. */
31
+ export type EssayDocSection = {
32
+ heading: string;
33
+ /** A string is set as one paragraph. Pass a node for anything longer. */
34
+ body: string | ReactNode;
35
+ /** Only when the anchor has to outlive a retitling, since it is a public URL. */
36
+ id?: string;
37
+ };
38
+ /** One step of a sequence: an ordinal, a title, and the prose under it. */
39
+ export type EssayMovement = {
40
+ title: string;
41
+ body: ReactNode;
42
+ };
43
+ export declare function createEssay({ Reveal, Glow, }?: EssayDecorations): {
44
+ EssayHeader: ({ eyebrow, title, lede, byline, }: {
45
+ eyebrow: ReactNode;
46
+ title: ReactNode;
47
+ /** The standfirst. Optional: a short piece can open on its title alone. */
48
+ lede?: ReactNode;
49
+ /** The signature line under the lede: who wrote it, and who it is written for. */
50
+ byline?: ReactNode;
51
+ }) => import("react").JSX.Element;
52
+ EssayLayout: ({ index, children, }: {
53
+ index: readonly EssayIndexEntry[];
54
+ children: ReactNode;
55
+ }) => import("react").JSX.Element;
56
+ EssaySection: ({ id, heading, children, }: {
57
+ id: string;
58
+ heading: ReactNode;
59
+ children: ReactNode;
60
+ }) => import("react").JSX.Element;
61
+ EssayPullQuote: ({ children }: {
62
+ children: ReactNode;
63
+ }) => import("react").JSX.Element;
64
+ EssayFigure: ({ children, caption, }: {
65
+ children: ReactNode;
66
+ caption: ReactNode;
67
+ }) => import("react").JSX.Element;
68
+ EssayMovements: ({ items }: {
69
+ items: readonly EssayMovement[];
70
+ }) => import("react").JSX.Element;
71
+ EssayDocument: ({ sections, ...header }: ComponentProps<({ eyebrow, title, lede, byline, }: {
72
+ eyebrow: ReactNode;
73
+ title: ReactNode;
74
+ /** The standfirst. Optional: a short piece can open on its title alone. */
75
+ lede?: ReactNode;
76
+ /** The signature line under the lede: who wrote it, and who it is written for. */
77
+ byline?: ReactNode;
78
+ }) => import("react").JSX.Element> & {
79
+ sections: readonly EssayDocSection[];
80
+ }) => import("react").JSX.Element;
81
+ };
82
+ /**
83
+ * The undecorated shell, bound once.
84
+ *
85
+ * The factory takes an app's motion and gradient, and an app that has neither
86
+ * still had to call it — at module scope, passing nothing — because there was
87
+ * no other way to reach the components. These are that call, made here, so a
88
+ * consumer without decorations imports them by name like the rest of the
89
+ * package. `createEssay` stays for the consumers that do decorate.
90
+ *
91
+ * Module scope is not incidental. The factory defines its components per call,
92
+ * so one made during a render hands React a new type on every pass and remounts
93
+ * everything under it.
94
+ */
95
+ export declare const EssayHeader: ({ eyebrow, title, lede, byline, }: {
96
+ eyebrow: ReactNode;
97
+ title: ReactNode;
98
+ /** The standfirst. Optional: a short piece can open on its title alone. */
99
+ lede?: ReactNode;
100
+ /** The signature line under the lede: who wrote it, and who it is written for. */
101
+ byline?: ReactNode;
102
+ }) => import("react").JSX.Element, EssayLayout: ({ index, children, }: {
103
+ index: readonly EssayIndexEntry[];
104
+ children: ReactNode;
105
+ }) => import("react").JSX.Element, EssaySection: ({ id, heading, children, }: {
106
+ id: string;
107
+ heading: ReactNode;
108
+ children: ReactNode;
109
+ }) => import("react").JSX.Element, EssayPullQuote: ({ children }: {
110
+ children: ReactNode;
111
+ }) => import("react").JSX.Element, EssayFigure: ({ children, caption, }: {
112
+ children: ReactNode;
113
+ caption: ReactNode;
114
+ }) => import("react").JSX.Element, EssayMovements: ({ items }: {
115
+ items: readonly EssayMovement[];
116
+ }) => import("react").JSX.Element, EssayDocument: ({ sections, ...header }: ComponentProps<({ eyebrow, title, lede, byline, }: {
117
+ eyebrow: ReactNode;
118
+ title: ReactNode;
119
+ /** The standfirst. Optional: a short piece can open on its title alone. */
120
+ lede?: ReactNode;
121
+ /** The signature line under the lede: who wrote it, and who it is written for. */
122
+ byline?: ReactNode;
123
+ }) => import("react").JSX.Element> & {
124
+ sections: readonly EssayDocSection[];
125
+ }) => import("react").JSX.Element;
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "../cn.js";
3
+ import { headingClass, TypographyEyebrow, TypographyH1, TypographyH2, TypographyH3, } from "../typography/header.js";
4
+ import { TypographyCaption, TypographyMuted, TypographyProse, } from "../typography/paragraph.js";
5
+ import { EssayColumns } from "./layout.js";
6
+ import { TableOfContents } from "./contents.js";
7
+ /** Pass-through: keeps the className the shell relies on for layout. */
8
+ const PlainReveal = ({ children, className, }) => _jsx("div", { className: className, children: children });
9
+ const NoGlow = () => null;
10
+ // Apostrophes are dropped rather than treated as a separator, so "What we don't
11
+ // collect" anchors at `what-we-dont-collect` the way a reader would type it.
12
+ const slugify = (heading) => heading
13
+ .toLowerCase()
14
+ .replace(/['’]/g, "")
15
+ .replace(/[^a-z0-9]+/g, "-")
16
+ .replace(/^-+|-+$/g, "");
17
+ /**
18
+ * Resolved for the whole document, since only the full list settles two cases:
19
+ * a heading with no ASCII slugs to "" (which `useScrollSpy` discards), and two
20
+ * differing only in case collide. An explicit `id` is always honoured.
21
+ */
22
+ const anchorIds = (sections) => {
23
+ const seen = new Map();
24
+ return sections.map((section, i) => {
25
+ const base = section.id ?? (slugify(section.heading) || `section-${i + 1}`);
26
+ const taken = seen.get(base) ?? 0;
27
+ seen.set(base, taken + 1);
28
+ return taken ? `${base}-${taken + 1}` : base;
29
+ });
30
+ };
31
+ export function createEssay({ Reveal = PlainReveal, Glow = NoGlow, } = {}) {
32
+ /** Left aligned: the eye has to reach the first line of prose either way. */
33
+ function EssayHeader({ eyebrow, title, lede, byline, }) {
34
+ return (_jsxs("header", { className: "relative overflow-hidden pb-12 pt-16 sm:pt-24", children: [_jsx(Glow, { className: "-top-40 left-1/2 -translate-x-1/2", intensity: 0.16 }), _jsx(EssayColumns, { children: _jsxs(Reveal, { eager: true, className: "flex flex-col gap-6", children: [_jsx(TypographyEyebrow, { children: eyebrow }), _jsx(TypographyH1, { variant: "display", className: "text-balance", children: title }), lede && (_jsx(TypographyMuted, { className: "text-pretty text-xl leading-relaxed", children: lede })), byline && (_jsx(TypographyMuted, { className: "border-t border-border/60 pt-5 text-base", children: byline }))] }) })] }));
35
+ }
36
+ /** The reading column, with the sticky index sitting in its left margin. */
37
+ function EssayLayout({ index, children, }) {
38
+ return (_jsx(EssayColumns, { className: "pb-16 sm:pb-24", aside: _jsx("div", { className: "sticky top-24", children: _jsx(TableOfContents, { sections: index }) }), children: _jsx("div", { className: "flex flex-col gap-16 border-t border-border pt-12 @6xl:border-t-0 @6xl:pt-0", children: children }) }));
39
+ }
40
+ /** The heading carries the anchor, offset so it lands under the sticky nav. */
41
+ function EssaySection({ id, heading, children, }) {
42
+ return (_jsx("section", { id: id, className: "scroll-mt-24", children: _jsxs(Reveal, { className: "flex flex-col gap-5", children: [_jsx(TypographyH2, { className: "text-balance", children: heading }), children] }) }));
43
+ }
44
+ /** One per essay: a page with three of them has decided nothing. */
45
+ function EssayPullQuote({ children }) {
46
+ return (_jsx(Reveal, { children: _jsx("blockquote", { className: cn(headingClass(), "text-balance border-l-2 border-primary/40 py-1 pl-6 leading-snug"), children: children }) }));
47
+ }
48
+ /** Generic on purpose: a page hands it anything; this decides only the fit. */
49
+ function EssayFigure({ children, caption, }) {
50
+ return (_jsx(Reveal, { variant: "scale", children: _jsxs("figure", { className: "flex flex-col gap-3", children: [children, _jsx(TypographyCaption, { className: "text-pretty", children: caption })] }) }));
51
+ }
52
+ /**
53
+ * A grid says the items are interchangeable; these hand output to each other.
54
+ * An ordered list says that without an arrow — the ordinal is the ornament.
55
+ */
56
+ function EssayMovements({ items }) {
57
+ 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))) }));
58
+ }
59
+ /**
60
+ * A reference document as data: the index derives from the sections, so a
61
+ * retitling cannot leave the rail scrolling to nothing.
62
+ */
63
+ function EssayDocument({ sections, ...header }) {
64
+ // Resolved once and shared, so the index and the sections cannot disagree
65
+ // about what a section is called.
66
+ const ids = anchorIds(sections);
67
+ return (_jsxs("article", { children: [_jsx(EssayHeader, { ...header }), _jsx(EssayLayout, { index: sections.map((s, i) => ({ id: ids[i], label: s.heading })), children: sections.map((s, i) => (_jsx(EssaySection, { id: ids[i], heading: s.heading, children: typeof s.body === "string" ? (_jsx(TypographyProse, { children: s.body })) : (s.body) }, ids[i]))) })] }));
68
+ }
69
+ return {
70
+ EssayHeader,
71
+ EssayLayout,
72
+ EssaySection,
73
+ EssayPullQuote,
74
+ EssayFigure,
75
+ EssayMovements,
76
+ EssayDocument,
77
+ };
78
+ }
79
+ /**
80
+ * The undecorated shell, bound once.
81
+ *
82
+ * The factory takes an app's motion and gradient, and an app that has neither
83
+ * still had to call it — at module scope, passing nothing — because there was
84
+ * no other way to reach the components. These are that call, made here, so a
85
+ * consumer without decorations imports them by name like the rest of the
86
+ * package. `createEssay` stays for the consumers that do decorate.
87
+ *
88
+ * Module scope is not incidental. The factory defines its components per call,
89
+ * so one made during a render hands React a new type on every pass and remounts
90
+ * everything under it.
91
+ */
92
+ export const { EssayHeader, EssayLayout, EssaySection, EssayPullQuote, EssayFigure, EssayMovements, EssayDocument, } = createEssay();
@@ -0,0 +1,7 @@
1
+ export { extractHeadings, createSlugger, readingTime, type TocHeading, } from "./toc.js";
2
+ export { Rail, RailLink } from "./rail.js";
3
+ export { TableOfContents } from "./contents.js";
4
+ export { createEssay, EssayHeader, EssayLayout, EssaySection, EssayPullQuote, EssayFigure, EssayMovements, EssayDocument, type EssayDecorations, type EssayIndexEntry, type EssayDocSection, type EssayMovement, } from "./essay.js";
5
+ export { useReadingProgress, useScrollSpy } from "./scroll.js";
6
+ export { ReadingProgressBar, ReadingRail } from "./reading.js";
7
+ export { EssayColumns, MetaDot, PostMetaRow, PostDate, formatPostDate, type PostDateFormat, ReadTime, TagPills, } from "./layout.js";
@@ -0,0 +1,9 @@
1
+ export { extractHeadings, createSlugger, readingTime, } from "./toc.js";
2
+ export { Rail, RailLink } from "./rail.js";
3
+ export { TableOfContents } from "./contents.js";
4
+ export { createEssay,
5
+ // The undecorated binding, for a consumer that supplies neither Reveal nor Glow.
6
+ EssayHeader, EssayLayout, EssaySection, EssayPullQuote, EssayFigure, EssayMovements, EssayDocument, } from "./essay.js";
7
+ export { useReadingProgress, useScrollSpy } from "./scroll.js";
8
+ export { ReadingProgressBar, ReadingRail } from "./reading.js";
9
+ export { EssayColumns, MetaDot, PostMetaRow, PostDate, formatPostDate, ReadTime, TagPills, } from "./layout.js";
@@ -0,0 +1,72 @@
1
+ import type { ComponentProps, ComponentType, ReactNode } from "react";
2
+ /**
3
+ * Three tracks with the third empty: two would push the prose off-centre the
4
+ * moment an aside appeared, setting body copy on a different axis per page.
5
+ * The measure grows per step — a comfortable line length is a range.
6
+ *
7
+ * The margin track is what the third one buys, and it only exists if the
8
+ * container can pay for it:
9
+ *
10
+ * aside = (container − 3rem padding − measure) ÷ 2 − 2.5rem gutter
11
+ *
12
+ * At 72rem, the grid's own first cap, that is 11rem — enough for a rail. At
13
+ * `lg` (64rem), where this used to switch on, it is 7rem, and a `text-sm` label
14
+ * past about thirteen characters wraps. So the reveal is pinned to the width
15
+ * the three tracks were drawn for rather than to a viewport step that happens
16
+ * to be near it.
17
+ *
18
+ * A container query, not a media query, because the answer depends on the room
19
+ * this shell was given and not on the size of the window. Mounted in something
20
+ * narrower — a docs page with its own 64rem column — it now drops the rail and
21
+ * sets the prose centred, which is a layout, where before it drew a 5.5rem
22
+ * margin and called it one.
23
+ */
24
+ export declare function EssayColumns({ aside, children, className, ...props }: ComponentProps<"div"> & {
25
+ aside?: ReactNode;
26
+ }): import("react").JSX.Element;
27
+ /** A separator between meta items. Decorative, so it is hidden from assistive tech. */
28
+ export declare function MetaDot({ className }: {
29
+ className?: string;
30
+ }): import("react").JSX.Element;
31
+ /** The row of meta beneath a title: byline, date, reading time, tags. */
32
+ export declare function PostMetaRow({ className, children, size, ...props }: ComponentProps<"div"> & {
33
+ size?: "sm" | "base";
34
+ }): import("react").JSX.Element;
35
+ declare const DATE_FMT: {
36
+ readonly short: Intl.DateTimeFormat;
37
+ readonly long: Intl.DateTimeFormat;
38
+ };
39
+ export type PostDateFormat = keyof typeof DATE_FMT;
40
+ /**
41
+ * The date string, outside React. An OG image builds one in a plain function and
42
+ * the index builds one in a component; two formatters is how the two drift.
43
+ *
44
+ * Fixed to `en-US`, not the visitor's locale: server and client must agree or
45
+ * React reports a hydration mismatch, and the server cannot see their locale.
46
+ * The index abbreviates because its dates sit inside a card's metadata line; an
47
+ * article spells the month out under a display title.
48
+ */
49
+ export declare const formatPostDate: (date: string | Date, format?: PostDateFormat) => string;
50
+ /** `<time datetime>` carries the machine value beside the human one. */
51
+ export declare function PostDate({ date, format, className, }: {
52
+ date: string | Date;
53
+ format?: PostDateFormat;
54
+ className?: string;
55
+ }): import("react").JSX.Element | null;
56
+ /**
57
+ * Estimated reading time. Pair with `readingTime()` from the toc module.
58
+ * `icon` is injected, so the package needs no icon set of its own.
59
+ */
60
+ export declare function ReadTime({ minutes, icon: Icon, className, }: {
61
+ minutes: number;
62
+ icon?: ComponentType<{
63
+ className?: string;
64
+ }>;
65
+ className?: string;
66
+ }): import("react").JSX.Element;
67
+ /** Topic tags, as quiet pills. */
68
+ export declare function TagPills({ tags, className, }: {
69
+ tags: readonly string[];
70
+ className?: string;
71
+ }): import("react").JSX.Element | null;
72
+ export {};
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "../cn.js";
3
+ /**
4
+ * Three tracks with the third empty: two would push the prose off-centre the
5
+ * moment an aside appeared, setting body copy on a different axis per page.
6
+ * The measure grows per step — a comfortable line length is a range.
7
+ *
8
+ * The margin track is what the third one buys, and it only exists if the
9
+ * container can pay for it:
10
+ *
11
+ * aside = (container − 3rem padding − measure) ÷ 2 − 2.5rem gutter
12
+ *
13
+ * At 72rem, the grid's own first cap, that is 11rem — enough for a rail. At
14
+ * `lg` (64rem), where this used to switch on, it is 7rem, and a `text-sm` label
15
+ * past about thirteen characters wraps. So the reveal is pinned to the width
16
+ * the three tracks were drawn for rather than to a viewport step that happens
17
+ * to be near it.
18
+ *
19
+ * A container query, not a media query, because the answer depends on the room
20
+ * this shell was given and not on the size of the window. Mounted in something
21
+ * narrower — a docs page with its own 64rem column — it now drops the rail and
22
+ * sets the prose centred, which is a layout, where before it drew a 5.5rem
23
+ * margin and called it one.
24
+ */
25
+ export function EssayColumns({ aside, children, className, ...props }) {
26
+ 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
+ /** A separator between meta items. Decorative, so it is hidden from assistive tech. */
29
+ export function MetaDot({ className }) {
30
+ return (_jsx("span", { "aria-hidden": true, className: cn("text-muted-foreground/50", className), children: "\u00B7" }));
31
+ }
32
+ /** The row of meta beneath a title: byline, date, reading time, tags. */
33
+ export function PostMetaRow({ className, children, size = "base", ...props }) {
34
+ return (_jsx("div", { className: cn("flex flex-wrap items-center gap-x-2 text-muted-foreground", size === "sm" ? "text-xs" : "text-sm", className), ...props, children: children }));
35
+ }
36
+ const DATE_FMT = {
37
+ short: new Intl.DateTimeFormat("en-US", {
38
+ year: "numeric",
39
+ month: "short",
40
+ day: "numeric",
41
+ }),
42
+ long: new Intl.DateTimeFormat("en-US", {
43
+ year: "numeric",
44
+ month: "long",
45
+ day: "numeric",
46
+ }),
47
+ };
48
+ /**
49
+ * The date string, outside React. An OG image builds one in a plain function and
50
+ * the index builds one in a component; two formatters is how the two drift.
51
+ *
52
+ * Fixed to `en-US`, not the visitor's locale: server and client must agree or
53
+ * React reports a hydration mismatch, and the server cannot see their locale.
54
+ * The index abbreviates because its dates sit inside a card's metadata line; an
55
+ * article spells the month out under a display title.
56
+ */
57
+ export const formatPostDate = (date, format = "short") => DATE_FMT[format].format(typeof date === "string" ? new Date(date) : date);
58
+ /** `<time datetime>` carries the machine value beside the human one. */
59
+ export function PostDate({ date, format, className, }) {
60
+ const value = typeof date === "string" ? new Date(date) : date;
61
+ if (Number.isNaN(value.getTime()))
62
+ return null;
63
+ return (_jsx("time", { dateTime: value.toISOString(), className: className, children: formatPostDate(value, format) }));
64
+ }
65
+ /**
66
+ * Estimated reading time. Pair with `readingTime()` from the toc module.
67
+ * `icon` is injected, so the package needs no icon set of its own.
68
+ */
69
+ export function ReadTime({ minutes, icon: Icon, className, }) {
70
+ return (_jsxs("span", { className: cn("inline-flex items-center gap-1", className), children: [Icon && _jsx(Icon, { className: "size-3.5" }), minutes, " min read"] }));
71
+ }
72
+ /** Topic tags, as quiet pills. */
73
+ export function TagPills({ tags, className, }) {
74
+ if (tags.length === 0)
75
+ return null;
76
+ 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))) }));
77
+ }
@@ -0,0 +1,15 @@
1
+ import { type ComponentProps, type ReactElement, type ReactNode } from "react";
2
+ /**
3
+ * The line is drawn once on the list, with the active item marking itself with a
4
+ * thumb on top — a border per link breaks the rail into segments that jump on
5
+ * hover. No client hooks, so a server-rendered listing can use it.
6
+ */
7
+ export declare function Rail({ className, ...props }: ComponentProps<"ul">): import("react").JSX.Element;
8
+ export declare function RailLink({ active, nested, className, children, render, ...props }: Omit<ComponentProps<"a">, "children"> & {
9
+ active?: boolean;
10
+ /** A sub-heading under the item above it, indented a step further in. */
11
+ nested?: boolean;
12
+ children: ReactNode;
13
+ /** Swap the anchor for another link element, e.g. `<Link href={…} />`. */
14
+ render?: ReactElement<ComponentProps<"a">>;
15
+ }): import("react").JSX.Element;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cloneElement } from "react";
3
+ import { cn } from "../cn.js";
4
+ /**
5
+ * The line is drawn once on the list, with the active item marking itself with a
6
+ * thumb on top — a border per link breaks the rail into segments that jump on
7
+ * hover. No client hooks, so a server-rendered listing can use it.
8
+ */
9
+ export function Rail({ className, ...props }) {
10
+ return (_jsx("ul", { className: cn("flex flex-col border-l border-border", className), ...props }));
11
+ }
12
+ export function RailLink({ active = false, nested = false, className, children, render, ...props }) {
13
+ const anchor = {
14
+ ...props,
15
+ "aria-current": active ? "page" : undefined,
16
+ className: cn("relative block py-1 text-sm leading-snug transition-colors", nested ? "pl-6" : "pl-3",
17
+ // The thumb overlaps the list's hairline rather than replacing it, so an
18
+ // inactive neighbour keeps its line and nothing shifts when the active
19
+ // item changes.
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"
22
+ : "text-muted-foreground hover:text-foreground", className),
23
+ children,
24
+ };
25
+ return _jsx("li", { children: render ? cloneElement(render, anchor) : _jsx("a", { ...anchor }) });
26
+ }
@@ -0,0 +1,17 @@
1
+ import type { TocHeading } from "./toc.js";
2
+ /**
3
+ * Hairline progress bar, rendered even where the rail is hidden. A CSS
4
+ * transition, not a spring: the value only feeds a transform, and this keeps the
5
+ * essay layer from dragging in an animation runtime.
6
+ */
7
+ export declare function ReadingProgressBar({ className }: {
8
+ className?: string;
9
+ }): import("react").JSX.Element;
10
+ /**
11
+ * Sticky rail with live scroll-spy. Both it and `ReadingProgressBar` read shared
12
+ * stores, so mounting them together costs one scroll subscription, not two.
13
+ */
14
+ export declare function ReadingRail({ headings, className, }: {
15
+ headings: TocHeading[];
16
+ className?: string;
17
+ }): import("react").JSX.Element | null;