feat(site-tokens): generate the site's --pk-* tokens from DESIGN.md

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`.
This commit is contained in:
Nayan
2026-07-26 22:11:00 +05:30
parent 8d9079e13b
commit e9dacfdd5a
10 changed files with 863 additions and 0 deletions
+316
View File
@@ -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.
+35
View File
@@ -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"
}
}
+43
View File
@@ -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}`);
+101
View File
@@ -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.");
}
+86
View File
@@ -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();
+31
View File
@@ -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");
}
@@ -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;
+40
View File
@@ -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";
+34
View File
@@ -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;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": [],
"strictNullChecks": true
},
"include": ["src"]
}