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:
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": [],
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user