From e9dacfdd5a8345f4b52b8b67cbf50f52a7f0e39b Mon Sep 17 00:00:00 2001 From: Nayan Date: Sun, 26 Jul 2026 22:11:00 +0530 Subject: [PATCH] feat(site-tokens): generate the site's --pk-* tokens from DESIGN.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same generator shape as @airship/editor-tokens, different document and a different prefix — the site's --pk-* tokens are kept apart from the editor's --ap-* set so the overlay can be injected into any page without a collision. apps/web#dev depends on this package's build: Vite has no dist/tokens.css to import until the postbuild has emitted it, which is why the site starts through turbo rather than a bare `vite dev`. --- packages/site-tokens/DESIGN.md | 316 +++++++++++++++++++ packages/site-tokens/package.json | 35 ++ packages/site-tokens/scripts/gen.mjs | 43 +++ packages/site-tokens/scripts/postbuild.mjs | 101 ++++++ packages/site-tokens/src/css.ts | 86 +++++ packages/site-tokens/src/fonts.css | 31 ++ packages/site-tokens/src/generated/design.ts | 167 ++++++++++ packages/site-tokens/src/index.ts | 40 +++ packages/site-tokens/src/tokens.ts | 34 ++ packages/site-tokens/tsconfig.json | 10 + 10 files changed, 863 insertions(+) create mode 100644 packages/site-tokens/DESIGN.md create mode 100644 packages/site-tokens/package.json create mode 100644 packages/site-tokens/scripts/gen.mjs create mode 100644 packages/site-tokens/scripts/postbuild.mjs create mode 100644 packages/site-tokens/src/css.ts create mode 100644 packages/site-tokens/src/fonts.css create mode 100644 packages/site-tokens/src/generated/design.ts create mode 100644 packages/site-tokens/src/index.ts create mode 100644 packages/site-tokens/src/tokens.ts create mode 100644 packages/site-tokens/tsconfig.json diff --git a/packages/site-tokens/DESIGN.md b/packages/site-tokens/DESIGN.md new file mode 100644 index 0000000..bae6f93 --- /dev/null +++ b/packages/site-tokens/DESIGN.md @@ -0,0 +1,316 @@ +--- +name: Airship Site +description: >- + The design system for airship's home page — a warm neutral stone ramp, hairline + rules instead of shadows, a single near-black call to action, and no accent hue + in the page chrome at all. Hierarchy comes from size, weight and space. This + front-matter is the canonical token source for `@airship/site-tokens` (emitted + under the `--pk-*` namespace) and is mapped into Tailwind's theme by apps/web + with `@theme inline`, so the utilities and this spec resolve to the same values + by construction. It is fully independent of the visual editor's own `--ap-*` + chrome tokens (packages/editor-tokens/EDITOR.md), which are dark-only and + evolve separately — the hero consumes those through a scoped `editor-mock.css` + this package emits, never through this block. +tokens: + colors: + white: "#ffffff" + stone-50: "#fafaf9" + stone-100: "#f5f5f4" + stone-200: "#e7e5e4" + stone-300: "#d6d3d1" + stone-400: "#a8a29e" + stone-500: "#78716c" + stone-600: "#57534e" + stone-700: "#44403c" + stone-800: "#292524" + stone-900: "#1c1917" + chrome-light: "#f6f6f6" + syntax-prop: "#0e7490" + syntax-old: "#b91c1c" + syntax-new: "#047857" + syntax-keyword: "#6d28d9" + editor-blue: "#0d99ff" + semantic: + text-primary: "#1c1917" + text-secondary: "#78716c" + text-tertiary: "#a8a29e" + text-muted: "#57534e" + text-faint: "#d6d3d1" + surface-page: "#fafaf9" + surface-panel: "#ffffff" + surface-shell: "#ffffff" + surface-input: "#f5f5f4" + surface-chrome: "#f6f6f6" + border-default: "#e7e5e4" + border-subtle: "rgba(0,0,0,0.06)" + border-faint: "rgba(0,0,0,0.05)" + cta-bg: "#1c1917" + cta-text: "#ffffff" + cta-hover: "#292524" + dot-inactive: "#d4d4d4" + term-dot-inactive: "rgba(0,0,0,0.12)" + selection-bg: "#cce1ec" + selection-text: "#1c1917" + syntax-prop: "#0e7490" + syntax-old: "#b91c1c" + syntax-new: "#047857" + syntax-keyword: "#6d28d9" + focus-ring: "#78716c" + scrim: "rgba(0,0,0,0.4)" + typography: + families: + sans: '"Inter", "Inter Fallback", system-ui, -apple-system, "Segoe UI", sans-serif' + mono: '"JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, monospace' + roles: + hero-heading: + size: 32 + weight: 500 + line: 1.25 + tracking: -0.45 + family: sans + section-heading: + size: 18 + weight: 500 + line: 1.5556 + tracking: -0.045 + family: sans + body: + size: 14 + weight: 400 + line: 1.5714 + tracking: -0.045 + family: sans + small: + size: 13 + weight: 400 + line: 1.6923 + tracking: -0.045 + family: sans + toc: + size: 12 + weight: 400 + line: 1.3333 + tracking: -0.045 + family: sans + mono-code: + size: 13 + weight: 400 + line: 1.8 + tracking: 0 + family: mono + mono-output: + size: 12.5 + weight: 400 + line: 1.8 + tracking: 0 + family: mono + mono-install: + size: 11 + weight: 400 + line: 1.6364 + tracking: 0 + family: mono + spacing: + hair: 1 + xxs: 4 + xs: 8 + sm: 12 + base: 16 + md: 20 + lg: 24 + xl: 32 + xxl: 48 + section: 64 + section-lg: 72 + rounded: + "none": 0 + xs: 4 + sm: 5 + md: 6 + lg: 8 + xl: 12 + pill: 61 + full: 9999 + elevation: + flat: "none" + hairline: "0 0 0 1px var(--pk-color-border-faint)" + card: "0 0 0 1px var(--pk-color-border-subtle)" + window: "0 0 0 0.5px rgba(0,0,0,0.15), 0 6px 20px 4px rgba(0,0,0,0.15)" + floating: "0 8px 32px rgba(0,0,0,0.18)" + motion: + duration-instant: "100ms" + duration-fast: "150ms" + duration-normal: "300ms" + duration-slow: "800ms" + ease-chrome: "cubic-bezier(0.215, 0.61, 0.355, 1)" + ease-panel: "cubic-bezier(0.23, 1, 0.32, 1)" + ease-reveal: "cubic-bezier(0.165, 0.84, 0.44, 1)" + ease-overshoot: "cubic-bezier(0.34, 1.56, 0.64, 1)" + layout: + nav: 1120 + column: 640 + hero: 800 + band-window: 1040 + breakpoint-desktop: 848 + breakpoint-mobile: 768 + breakpoint-tight: 640 +--- + +# Airship Site Design Tokens + +The design system for `apps/web`. `@airship/site-tokens` generates its TypeScript +token objects and its CSS custom properties (`--pk-*`) directly from the +front-matter above, so this file and the code cannot drift. The app maps those +variables into Tailwind's theme with `@theme inline`, which means a Tailwind +utility and this spec resolve to the same value by construction. + +This system belongs to the **page**, not to the editor. It is independent of +`packages/editor-tokens/EDITOR.md` (`--ap-*`), which is dark-only chrome for the +visual editor and evolves on its own schedule. The hero does render a miniature +of that editor — but it reaches those variables through `editor-mock.css`, a +block this package's postbuild emits scoped to `.ap-mock`, never through the +tokens here. Two namespaces, two owners, one build pipeline. + +## Character + +- **The palette is `stone`, not `gray`.** Every neutral carries a trace of warmth + (`{colors.stone-500}` is `#78716c`, not `#737373`). On a page that is almost + entirely neutral, that warmth is the difference between restraint and + coldness, and it is the reason the near-black CTA reads as ink rather than as + a UI chip. Swapping in a true gray ramp changes the character of every surface + at once, which is exactly why it is a token and not a literal. + +- **There is no accent hue in the page chrome.** `{semantic.cta-bg}` is + `{colors.stone-900}` — near-black on near-white. Contrast, not hue, is what + makes a thing look clickable, and there is plenty of it. With no accent to + spend, hierarchy has to come from size, weight and space. + + Four chromatic values do exist, and all four are **semantic, not decorative**: + `{colors.syntax-old}` and `{colors.syntax-new}` are the two sides of a diff, + `{colors.syntax-prop}` is a CSS property name, `{colors.syntax-keyword}` is a + language keyword. They appear only inside the mono blocks that show what the + agent reads and writes. A fifth, `{colors.editor-blue}`, is the editor's + selection blue — it appears **only** inside `.ap-mock`, where it is quoting + the product rather than styling the page. + +- **Hairline rules, never shadows.** `{elevation.hairline}` and + `{elevation.card}` are `0 0 0 1px` rings. Sections separate with a + `border-bottom`, not a drop shadow; cards have no fill and no border box, only + a rule underneath. The one real shadow in the system is + `{elevation.window}` — and it exists to make the hero's Safari window read as + a *window*, which is a different job from making a card read as a card. + +- **The shell is the page; the page surface is what the page quotes.** + `{semantic.surface-shell}` (`#ffffff`) sits under `html`, `.layout` and the + sticky header — the whole chrome is one white, so the bar can scroll over the + content it covers without ever showing a seam. `{semantic.surface-page}` + (`#fafaf9`) is one step off it and is reserved for surfaces the page is + *showing* rather than *being*: the browser content area in the hero, the + miniature app inside it, the fill behind a figure. That single step of warmth + is the whole difference between a screenshot and the page around it. + +- **Every text pair clears WCAG AA (4.5:1).** That is a constraint on the + palette, not an afterthought, and it is why the syntax accents are the `700` + tier rather than the `600` tier the rest of this family suggests: + `emerald-600` measures 3.8:1 on white and `cyan-600` 3.7:1, so a diff's `+` + and `-` lines — the two colours on this page that actually carry meaning — + would have been the least legible text in the system. + + `{semantic.text-tertiary}` is the one value that does NOT clear it (2.5:1), + and it is therefore reserved for the hero illustration, which is `aria-hidden` + and decorative. No prose and no icon uses it. If you reach for it in the page + chrome, reach for `{semantic.text-secondary}` instead. + +- **Everything is 14px.** `{typography.roles.body}` is the size of nearly all + prose on the page; `{typography.roles.section-heading}` is 18px and + `{typography.roles.hero-heading}` is 32px, and that is the entire scale. Three + sizes, two weights (400 and 500), and no bold anywhere. The restraint is the + design. + +- **`body { letter-spacing: -0.045px }` is global.** Every sans role therefore + carries `tracking: -0.045` explicitly, so applying a `.pk-*` class never + silently resets it back to zero. The one exception is + `{typography.roles.hero-heading}` at `-0.45px`, ten times tighter, because + 32px type needs it and 14px type does not. Mono roles set `0`. + +## Type scale + +`line` is a unitless ratio, which is what `line-height` wants. The pixel values +the ratios were derived from, since those are what the ported CSS was measured +in: + +| role | px | ratio | +| --- | --- | --- | +| `hero-heading` | 32 / 40 | 1.25 | +| `section-heading` | 18 / 28 | 1.5556 | +| `body` | 14 / 22 | 1.5714 | +| `small` | 13 / 22 | 1.6923 | +| `toc` | 12 / 16 | 1.3333 | +| `mono-install` | 11 / 18 | 1.6364 | +| `mono-code` | 13 / 23.4 | 1.8 | +| `mono-output` | 12.5 / 22.5 | 1.8 | + +## Radius + +`{rounded.pill}` is `61px`, not `9999px`, and the oddness is deliberate: it is +the value the hero's primary CTA was measured at, and at a 40px-tall button `61` +and `9999` are visually identical while `61` survives being scrubbed in the +inspector as a number. `{rounded.full}` is the real `9999` for anything that +must stay a capsule at any height. + +The rest is a tight ladder — `{rounded.xs}` 4px on small controls, `{rounded.sm}` +5px, `{rounded.md}` 6px on inputs, `{rounded.lg}` 8px on the browser window and +code-block chrome, `{rounded.xl}` 12px on the output and install cards. Nothing +in the system is rounder than 12px except a pill. + +## Motion + +Four easings, each with a job: + +- `{motion.ease-chrome}` — anything that behaves like UI chrome moving into + place. The default. +- `{motion.ease-panel}` — panels and docks sliding in. Slower out, longer tail. +- `{motion.ease-reveal}` — disclosure: the FAQ accordion, the TOC indicator, the + collapsed nav opening. +- `{motion.ease-overshoot}` — reserved for the hero's payoff beat, where a value + the agent changed lands with a slight overshoot. It is the only easing in the + system that goes past its target, and using it anywhere else would make that + moment ordinary. + +Every one of them is honoured only when `prefers-reduced-motion` is not +`reduce`. + +## Layout + +Three widths, deliberately not one. `{layout.nav}` 1120px is both the sticky bar +and every section under it, so a section heading starts on the same x as the +wordmark. `{layout.column}` 640px is the reading measure — a cap on the prose +*inside* a section, not on the section itself. `{layout.hero}` 800px is the +hero's own cap. + +That split is recent and worth the sentence. Sections used to be 640px, which +fused "how wide is this box" with "how long is a line of text" into one number. +It held until the first two-up card grid, where 640px meant ~310px per card — +too narrow for a card to carry an illustration. Widening the sections and +capping the prose separately lets a section hold things that want the room (a +card grid, a terminal block, a row of code) without making its paragraphs +unreadable. + +The page's one full-bleed element is the wallpaper band under the hero, which is +uncapped: it spans the viewport at every width. What it carries is capped, at +`{layout.band-window}` 1040px, and that number sets the band's proportion — the +window's height follows from its width via the mock's 1200×636 ratio, and the +band's height follows from the window. 1040 is also 1040/1200 of `.ap-mock`'s +authored width, which lands `--ap-mock-scale` near 0.87 and renders the mock at +very nearly the size it was drawn at. + +Breakpoints: `{layout.breakpoint-desktop}` 848px is where the hero's animated +cursor is dropped, because past it the controls it points at are too small to +follow; `{layout.breakpoint-mobile}` 768px is where the nav folds behind a +hamburger, and where the desktop-only CTA row gives way to the "this is a +desktop tool" callout; `{layout.breakpoint-tight}` 640px tightens everything. + +There is no `breakpoint-nav`. The bar used to be a 220px fixed rail that turned +into a sticky top bar at 1240px, and both the rail and that breakpoint are gone — +it is a top bar at every width now, and the only thing that changes is whether +its links are inline or collapsed. diff --git a/packages/site-tokens/package.json b/packages/site-tokens/package.json new file mode 100644 index 0000000..ae26c5d --- /dev/null +++ b/packages/site-tokens/package.json @@ -0,0 +1,35 @@ +{ + "name": "@airship/site-tokens", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./tokens.css": "./dist/tokens.css", + "./editor-mock.css": "./dist/editor-mock.css", + "./fonts.css": "./dist/fonts.css", + "./fonts/*": "./dist/fonts/*" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "node scripts/gen.mjs && tsup src/index.ts --format esm --dts --sourcemap --clean && node scripts/postbuild.mjs", + "clean": "rm -rf dist .turbo", + "gen": "node scripts/gen.mjs", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@airship/editor-tokens": "workspace:*" + }, + "devDependencies": { + "@fontsource-variable/inter": "^5.1.1", + "@fontsource/jetbrains-mono": "^5.1.1", + "yaml": "^2.6.1" + } +} diff --git a/packages/site-tokens/scripts/gen.mjs b/packages/site-tokens/scripts/gen.mjs new file mode 100644 index 0000000..aa173dc --- /dev/null +++ b/packages/site-tokens/scripts/gen.mjs @@ -0,0 +1,43 @@ +// Generates src/generated/design.ts from the canonical DESIGN.md front-matter. +// DESIGN.md (this package's root) is the single source of truth; this keeps the +// TypeScript token objects in lock-step with it. Run automatically before every +// build. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse } from "yaml"; + +const here = dirname(fileURLToPath(import.meta.url)); +const designPath = resolve(here, "../DESIGN.md"); +const outPath = resolve(here, "../src/generated/design.ts"); + +if (!existsSync(designPath)) { + throw new Error(`gen: cannot find canonical spec at ${designPath}`); +} + +const raw = readFileSync(designPath, "utf8"); +const match = raw.match(/^---\n([\s\S]*?)\n---/); +if (!match) { + throw new Error("gen: DESIGN.md is missing its YAML front-matter block"); +} + +const front = parse(match[1]); +const tokens = front?.tokens; +if (!tokens) { + throw new Error("gen: DESIGN.md front-matter has no `tokens:` section"); +} + +const banner = + "// AUTO-GENERATED from packages/site-tokens/DESIGN.md front-matter by scripts/gen.mjs.\n" + + "// Do not edit by hand — edit DESIGN.md and run `pnpm --filter @airship/site-tokens gen`.\n\n"; + +const body = `${banner}export const design = ${JSON.stringify( + tokens, + null, + 2 +)} as const;\n\nexport type Design = typeof design;\n`; + +mkdirSync(dirname(outPath), { recursive: true }); +writeFileSync(outPath, body); + +console.log(`gen: wrote ${outPath}`); diff --git a/packages/site-tokens/scripts/postbuild.mjs b/packages/site-tokens/scripts/postbuild.mjs new file mode 100644 index 0000000..af30bfd --- /dev/null +++ b/packages/site-tokens/scripts/postbuild.mjs @@ -0,0 +1,101 @@ +// Post-build: emit dist/tokens.css from the built token source (single source +// of truth — no second hand-maintained CSS file), copy the @font-face stylesheet, +// and self-host the woff2 assets by copying them out of the @fontsource packages. +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const pkgRoot = resolve(here, ".."); +const dist = join(pkgRoot, "dist"); +const fontsOut = join(dist, "fonts"); +const require = createRequire(import.meta.url); + +mkdirSync(fontsOut, { recursive: true }); + +const log = (m) => { + console.log(`postbuild: ${m}`); +}; + +// 1. tokens.css — generated from the compiled token module. +const { css } = await import(pathToFileURL(join(dist, "index.js")).href); +writeFileSync(join(dist, "tokens.css"), css); +log("wrote dist/tokens.css"); + +// 2. editor-mock.css — the *editor's* `--ap-*` palette, scoped to `.ap-mock`. +// +// The hero recreates airship's inline-mode overlay in miniature, so the mock's +// palette is the editor's palette. Emitting it from @airship/editor-tokens here +// means it is the same artifact rather than a copy of one: change EDITOR.md and +// the hero follows, with no way for the two to drift silently apart. +// +// Scoped, not `:root`, because that palette is dark editor chrome and this is a +// light marketing page. `buildCss` emits nothing but custom property +// declarations inside the given selector — no `color-scheme`, no `@property` — +// so a non-root scope is safe. +const { buildCss: buildEditorCss } = await import("@airship/editor-tokens"); +writeFileSync( + join(dist, "editor-mock.css"), + buildEditorCss({ scope: ".ap-mock" }) +); +log("wrote dist/editor-mock.css"); + +// 3. fonts.css — authored @font-face rules, copied verbatim. +// +// Deliberately only this package's copy. @airship/editor-tokens ships its own +// fonts.css declaring the SAME family names ("Inter", "JetBrains Mono") from +// different URLs; importing both would download every face twice. +copyFileSync(join(pkgRoot, "src", "fonts.css"), join(dist, "fonts.css")); +log("wrote dist/fonts.css"); + +// 4. Self-host the actual font binaries from @fontsource. +function filesDir(pkg) { + return join(dirname(require.resolve(`${pkg}/package.json`)), "files"); +} +function pick(dir, re) { + return readdirSync(dir).find((f) => re.test(f)); +} +function copyFont(pkg, re, dest) { + try { + const dir = filesDir(pkg); + const file = pick(dir, re); + if (!file) { + throw new Error(`no file matching ${re} in ${dir}`); + } + cpSync(join(dir, file), join(fontsOut, dest)); + log(`bundled ${dest} (from ${pkg})`); + return true; + } catch (err) { + log(`WARN could not self-host ${dest}: ${err.message}`); + log(" → the font stack will fall back to system fonts at runtime."); + return false; + } +} + +copyFont( + "@fontsource-variable/inter", + /^inter-latin-wght-normal\.woff2$/, + "inter-variable.woff2" +); +copyFont( + "@fontsource/jetbrains-mono", + /^jetbrains-mono-latin-400-normal\.woff2$/, + "jetbrains-mono-400.woff2" +); +copyFont( + "@fontsource/jetbrains-mono", + /^jetbrains-mono-latin-700-normal\.woff2$/, + "jetbrains-mono-700.woff2" +); + +if (!existsSync(join(fontsOut, "inter-variable.woff2"))) { + log("NOTE no self-hosted fonts present; relying on system fallback stack."); +} diff --git a/packages/site-tokens/src/css.ts b/packages/site-tokens/src/css.ts new file mode 100644 index 0000000..95a5177 --- /dev/null +++ b/packages/site-tokens/src/css.ts @@ -0,0 +1,86 @@ +// Emits CSS custom properties + the size-invariant typography classes from the +// design tokens. Pure string building — no Node APIs, so this is safe to bundle +// into a browser build. One palette, emitted once under `scope` (`:root` by +// default) — the page has a single intended look, so there is no second block +// and no theme attribute to select between them. +// +// Component classes (buttons, cards, inputs) are deliberately NOT emitted here. +// The app composes those with Tailwind, which reads these same variables through +// `@theme inline` — so there is exactly one definition of a button, at its call +// site, rather than a CSS one here competing with a utility one there. +import { design } from "./generated/design"; +import type { RoleSpec } from "./tokens"; + +export interface CssOptions { + /** Include the typography role classes (`.pk-*`). Default `true`. */ + components?: boolean; + /** Selector the variables attach to. Default `:root`. */ + scope?: string; +} + +/** `cssVar("color-canvas")` → `var(--pk-color-canvas)`. */ +export function cssVar(name: string): string { + return `var(--pk-${name})`; +} + +function semanticVars(): string { + return Object.entries(design.semantic) + .map(([k, v]) => ` --pk-color-${k}: ${v};`) + .join("\n"); +} + +function baseVars(): string { + const lines: string[] = []; + lines.push(` --pk-font-sans: ${design.typography.families.sans};`); + lines.push(` --pk-font-mono: ${design.typography.families.mono};`); + for (const [k, v] of Object.entries(design.spacing)) { + lines.push(` --pk-space-${k}: ${v}px;`); + } + for (const [k, v] of Object.entries(design.rounded)) { + lines.push(` --pk-radius-${k}: ${v}px;`); + } + for (const [k, v] of Object.entries(design.elevation)) { + lines.push(` --pk-elevation-${k}: ${v};`); + } + for (const [k, v] of Object.entries(design.motion)) { + lines.push(` --pk-motion-${k}: ${v};`); + } + for (const [k, v] of Object.entries(design.layout)) { + lines.push(` --pk-layout-${k}: ${v}px;`); + } + return lines.join("\n"); +} + +function roleDecls(spec: RoleSpec): string { + const family = + spec.family === "mono" ? "var(--pk-font-mono)" : "var(--pk-font-sans)"; + const decls = [ + `font-family: ${family}`, + `font-size: ${spec.size}px`, + `font-weight: ${spec.weight}`, + `line-height: ${spec.line}`, + `letter-spacing: ${spec.tracking}px`, + ]; + if (spec.transform) { + decls.push(`text-transform: ${spec.transform}`); + } + return decls.join("; "); +} + +function typographyClasses(): string { + return Object.entries(design.typography.roles) + .map(([role, spec]) => `.pk-${role} { ${roleDecls(spec as RoleSpec)}; }`) + .join("\n"); +} + +export function buildCss(opts: CssOptions = {}): string { + const { scope = ":root", components = true } = opts; + const parts: string[] = [`${scope} {\n${baseVars()}\n${semanticVars()}\n}`]; + if (components) { + parts.push(typographyClasses()); + } + return `${parts.join("\n\n")}\n`; +} + +/** The full stylesheet (variables + components) — emitted to dist/tokens.css. */ +export const css = buildCss(); diff --git a/packages/site-tokens/src/fonts.css b/packages/site-tokens/src/fonts.css new file mode 100644 index 0000000..259f0db --- /dev/null +++ b/packages/site-tokens/src/fonts.css @@ -0,0 +1,31 @@ +/* + * The two families named by the font stacks in DESIGN.md, self-hosted. + * The woff2 files are copied into ./fonts/ at build time from @fontsource + * (see scripts/postbuild.mjs). If they are absent the stacks fall back to + * system fonts, which is a visible but survivable downgrade. + * + * @airship/editor-tokens ships a fonts.css declaring these SAME family names + * from its own copies of the same binaries. apps/web must import exactly one of + * the two — this one — or every face is downloaded twice. + */ +@font-face { + font-family: "Inter"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url("./fonts/inter-variable.woff2") format("woff2"); +} +@font-face { + font-family: "JetBrains Mono"; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url("./fonts/jetbrains-mono-400.woff2") format("woff2"); +} +@font-face { + font-family: "JetBrains Mono"; + font-style: normal; + font-display: swap; + font-weight: 700; + src: url("./fonts/jetbrains-mono-700.woff2") format("woff2"); +} diff --git a/packages/site-tokens/src/generated/design.ts b/packages/site-tokens/src/generated/design.ts new file mode 100644 index 0000000..8b76942 --- /dev/null +++ b/packages/site-tokens/src/generated/design.ts @@ -0,0 +1,167 @@ +// AUTO-GENERATED from packages/site-tokens/DESIGN.md front-matter by scripts/gen.mjs. +// Do not edit by hand — edit DESIGN.md and run `pnpm --filter @airship/site-tokens gen`. + +export const design = { + "colors": { + "white": "#ffffff", + "stone-50": "#fafaf9", + "stone-100": "#f5f5f4", + "stone-200": "#e7e5e4", + "stone-300": "#d6d3d1", + "stone-400": "#a8a29e", + "stone-500": "#78716c", + "stone-600": "#57534e", + "stone-700": "#44403c", + "stone-800": "#292524", + "stone-900": "#1c1917", + "chrome-light": "#f6f6f6", + "syntax-prop": "#0e7490", + "syntax-old": "#b91c1c", + "syntax-new": "#047857", + "syntax-keyword": "#6d28d9", + "editor-blue": "#0d99ff" + }, + "semantic": { + "text-primary": "#1c1917", + "text-secondary": "#78716c", + "text-tertiary": "#a8a29e", + "text-muted": "#57534e", + "text-faint": "#d6d3d1", + "surface-page": "#fafaf9", + "surface-panel": "#ffffff", + "surface-shell": "#ffffff", + "surface-input": "#f5f5f4", + "surface-chrome": "#f6f6f6", + "border-default": "#e7e5e4", + "border-subtle": "rgba(0,0,0,0.06)", + "border-faint": "rgba(0,0,0,0.05)", + "cta-bg": "#1c1917", + "cta-text": "#ffffff", + "cta-hover": "#292524", + "dot-inactive": "#d4d4d4", + "term-dot-inactive": "rgba(0,0,0,0.12)", + "selection-bg": "#cce1ec", + "selection-text": "#1c1917", + "syntax-prop": "#0e7490", + "syntax-old": "#b91c1c", + "syntax-new": "#047857", + "syntax-keyword": "#6d28d9", + "focus-ring": "#78716c", + "scrim": "rgba(0,0,0,0.4)" + }, + "typography": { + "families": { + "sans": "\"Inter\", \"Inter Fallback\", system-ui, -apple-system, \"Segoe UI\", sans-serif", + "mono": "\"JetBrains Mono\", \"JetBrains Mono Fallback\", ui-monospace, SFMono-Regular, Menlo, monospace" + }, + "roles": { + "hero-heading": { + "size": 32, + "weight": 500, + "line": 1.25, + "tracking": -0.45, + "family": "sans" + }, + "section-heading": { + "size": 18, + "weight": 500, + "line": 1.5556, + "tracking": -0.045, + "family": "sans" + }, + "body": { + "size": 14, + "weight": 400, + "line": 1.5714, + "tracking": -0.045, + "family": "sans" + }, + "small": { + "size": 13, + "weight": 400, + "line": 1.6923, + "tracking": -0.045, + "family": "sans" + }, + "toc": { + "size": 12, + "weight": 400, + "line": 1.3333, + "tracking": -0.045, + "family": "sans" + }, + "mono-code": { + "size": 13, + "weight": 400, + "line": 1.8, + "tracking": 0, + "family": "mono" + }, + "mono-output": { + "size": 12.5, + "weight": 400, + "line": 1.8, + "tracking": 0, + "family": "mono" + }, + "mono-install": { + "size": 11, + "weight": 400, + "line": 1.6364, + "tracking": 0, + "family": "mono" + } + } + }, + "spacing": { + "hair": 1, + "xxs": 4, + "xs": 8, + "sm": 12, + "base": 16, + "md": 20, + "lg": 24, + "xl": 32, + "xxl": 48, + "section": 64, + "section-lg": 72 + }, + "rounded": { + "none": 0, + "xs": 4, + "sm": 5, + "md": 6, + "lg": 8, + "xl": 12, + "pill": 61, + "full": 9999 + }, + "elevation": { + "flat": "none", + "hairline": "0 0 0 1px var(--pk-color-border-faint)", + "card": "0 0 0 1px var(--pk-color-border-subtle)", + "window": "0 0 0 0.5px rgba(0,0,0,0.15), 0 6px 20px 4px rgba(0,0,0,0.15)", + "floating": "0 8px 32px rgba(0,0,0,0.18)" + }, + "motion": { + "duration-instant": "100ms", + "duration-fast": "150ms", + "duration-normal": "300ms", + "duration-slow": "800ms", + "ease-chrome": "cubic-bezier(0.215, 0.61, 0.355, 1)", + "ease-panel": "cubic-bezier(0.23, 1, 0.32, 1)", + "ease-reveal": "cubic-bezier(0.165, 0.84, 0.44, 1)", + "ease-overshoot": "cubic-bezier(0.34, 1.56, 0.64, 1)" + }, + "layout": { + "nav": 1120, + "column": 640, + "hero": 800, + "band-window": 1040, + "breakpoint-desktop": 848, + "breakpoint-mobile": 768, + "breakpoint-tight": 640 + } +} as const; + +export type Design = typeof design; diff --git a/packages/site-tokens/src/index.ts b/packages/site-tokens/src/index.ts new file mode 100644 index 0000000..498b86f --- /dev/null +++ b/packages/site-tokens/src/index.ts @@ -0,0 +1,40 @@ +// @airship/site-tokens — the marketing site's design source of truth, generated +// from the DESIGN.md front-matter at this package's root (see scripts/gen.mjs). +// Sole consumer: apps/web, which imports ./tokens.css + ./fonts.css and maps +// the `--pk-*` variables into Tailwind's theme with `@theme inline`. +// +// One palette, not two: the page has a single intended look and there is no +// theme to switch between, so ./tokens.css is a single `:root` block. +// +// Distinct from @airship/editor-tokens (`--ap-*`), which is the *editor's* +// palette: dark chrome, generated from EDITOR.md by the same pipeline shape. +// The two namespaces cannot collide. apps/web does consume both — the hero +// recreates the editor in miniature — but the editor palette reaches it as +// ./editor-mock.css, a scoped block this package's postbuild emits so a dark +// chrome palette can never leak onto the light marketing page. + +export type { CssOptions } from "./css"; +export { buildCss, css, cssVar } from "./css"; +export { design } from "./generated/design"; +export type { + ElevationToken, + LayoutToken, + MotionToken, + PaletteColor, + RadiusToken, + RoleSpec, + SemanticColor, + SpacingToken, + TypographyRole, +} from "./tokens"; +export { + colors, + elevation, + fonts, + layout, + motion, + radius, + semantic, + spacing, + typography, +} from "./tokens"; diff --git a/packages/site-tokens/src/tokens.ts b/packages/site-tokens/src/tokens.ts new file mode 100644 index 0000000..8c1fbe3 --- /dev/null +++ b/packages/site-tokens/src/tokens.ts @@ -0,0 +1,34 @@ +// Typed views over the generated design tokens. The values originate in this +// package's ./DESIGN.md front-matter (see scripts/gen.mjs); this module only +// re-shapes and types them for ergonomic consumption. +import { design } from "./generated/design"; + +export const { + colors, + semantic, + spacing, + elevation, + motion, + typography, + layout, +} = design; +export const radius = design.rounded; +export const fonts = design.typography.families; + +export type SemanticColor = keyof typeof design.semantic; +export type PaletteColor = keyof typeof design.colors; +export type SpacingToken = keyof typeof design.spacing; +export type RadiusToken = keyof typeof design.rounded; +export type ElevationToken = keyof typeof design.elevation; +export type MotionToken = keyof typeof design.motion; +export type LayoutToken = keyof typeof design.layout; +export type TypographyRole = keyof typeof design.typography.roles; + +export interface RoleSpec { + family: "sans" | "mono"; + line: number; + size: number; + tracking: number; + transform?: string; + weight: number; +} diff --git a/packages/site-tokens/tsconfig.json b/packages/site-tokens/tsconfig.json new file mode 100644 index 0000000..a2744fc --- /dev/null +++ b/packages/site-tokens/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": [], + "strictNullChecks": true + }, + "include": ["src"] +}