framejs.io ⇐ framejs.app design system ("Offprint")
This repo's surfaces are styled to copy framejs.app, not invented here.
framejs.app (the framejs-nhost sibling repo) owns the design system; we mirror
it so everything reads as one product. See [[framejs-origins]] for the repo split.
Two surfaces mirror it, each with its own bridge because each uses a different
framework:
| Surface |
Framework |
Bridge file |
| Editor |
Chakra UI v2 (React) |
editor/src/styles/theme.ts |
| Docs |
VitePress |
docs/.vitepress/theme/blueprint.css |
The rest of this doc covers the editor first, then the docs.
Design language: "Offprint" — bone stock (#f4f3ef), near-black ink
(#111110), one dusk blue (#465775), rules and space in place of cards, cut
2px corners. IBM Plex Serif (headings) + Sans (body/chrome) + Mono
(labels/wordmark/data/code).
Migration status (2026-09-19). The accent is now dusk blue
#465775 (dark peer #9cb2d2). It replaced a vermillion #c9350a, which had
itself replaced a "Blueprint / drafting table" world built on cobalt
#1f2edb. Both predecessors were rejected for the same reason: a saturated
accent at mid lightness reads as AI-generated, whatever the hue. Dusk blue
sits at 40% saturation / 46% value — see the Low Chroma Rule in the app's
DESIGN.md.
All three mirrors are now on the same palette: the docs mirror, the runtime
(worker/static/blueprint.css, worker/index.html), and the editor
(editor/src/styles/blueprint.css, theme.ts, editor/index.html) — the
editor's neutrals and status colours came across too, not just the accent, so
its cool blue-black dark ground is gone. Radii there are still 3–4px against
the app's 2px; that is shape, not colour, and is the remaining divergence.
Both mirrors are still named blueprint.css; rename them together whenever
that churn is acceptable.
What the editor still needs (the full re-merge checklist below applies):
swap the token hexes in blueprint.css and the mirrored const block in
theme.ts, copy the three ibm-plex-serif-* woff2 into editor/public/fonts/,
set headings to the serif, drop radii to 2px, retint shadows to
rgba(32,26,18,…), and change editor/index.html's theme-color from
#f4f3ef to #f4f3ef.
The core tension
- framejs.app is Tailwind v4 + Preact (Deno Fresh). Its entire design
system is one file:
assets/styles.css (CSS custom properties + @theme inline
Tailwind mapping + component classes .btn/.plate/.eyebrow/.field + fonts).
- This editor is Chakra UI v2 + React (Vite). No Tailwind.
So we can't share the file verbatim. Instead we mirror the tokens, component
classes, and fonts as plain CSS, and bridge Chakra to the same values.
Source of truth (in the sibling repo)
| What |
Path (in framejs-nhost) |
| Stylesheet |
frontend/worker/assets/styles.css |
| Fonts (9 woff2) |
frontend/worker/static/fonts/ibm-plex-*.woff2 |
| HTML shell/theme |
frontend/worker/routes/_app.tsx (theme-color, data-theme) |
Sibling repo root on this machine: /Users/dion/dev/git/metapages/framejs-nhost.
Where it lands here
| File |
Role |
editor/src/styles/blueprint.css |
Plain-CSS mirror of styles.css — tokens + classes + fonts |
editor/public/fonts/*.woff2 |
The IBM Plex woff2, served by Vite at /fonts/ |
editor/src/styles/theme.ts |
Chakra bridge: maps blueprint token values onto Chakra |
editor/src/index.tsx |
import "/@/styles/blueprint.css" |
editor/index.html |
<html data-theme="light"> + theme-color = #f4f3ef |
How styles.css → blueprint.css (the transform)
blueprint.css is styles.css with the Tailwind-only bits removed. When
re-merging, apply exactly these transforms and copy everything else verbatim:
- Drop the first line
@import "tailwindcss";.
- Drop the
@theme inline { … } block (it only generates Tailwind
utilities). BUT preserve the non-color tokens it defines by adding them to
:root as plain custom properties: --font-sans, --font-mono,
--radius-plate, --radius-control, --ease-out.
- Unwrap the
@layer base { }, @layer components { },
@layer utilities { } wrappers into plain rules. (Emotion injects Chakra's
styles unlayered, which would out-prioritize layered rules — so don't keep the
layers.)
- Move
body { background/color } out. Chakra's CSS reset sets the body
bg/color and wins by injection order, so the paper/ink body styles live in
theme.ts styles.global, not here. Keep ::selection and :focus-visible.
- Keep verbatim: all
@font-face blocks (paths are /fonts/…, same as
framejs.app), the full :root light palette, the dark palette blocks (kept
dormant — see below), and the component classes .eyebrow/.btn*/.field/
.plate/.bg-grid/.ticks/.tabular.
Hero-specific animation classes (.hero-plot*, .live-dot, .overlay-in,
.panel-in) are framejs.app landing-page-only — omit them here unless a matching
component is added.
The Chakra bridge (theme.ts)
Chakra can't consume .btn/.plate classes, so theme.ts remaps Chakra's own
scales onto the blueprint neutrals + accent and sets the fonts:
fonts.body/heading → IBM Plex Sans; fonts.mono → IBM Plex Mono.
colors.gray.{50..900} → blueprint neutrals (surface / surface-2 / line /
line-strong / ink-3 / ink-2 / ink), so existing components (header, panels,
dividers that use gray.*) adopt the palette with no per-component edits.
colors.blue.{...} → dusk accent (--accent / --accent-hover / --accent-soft).
borders."1px" → warm hairline (--line) so borderBottom="1px" follows.
styles.global.body → paper bg + ink text.
Important: theme.ts uses literal hex, NOT var(--token). Chakra runs
build-time color math (transparentize, etc.) that chokes on CSS variables.
This means token values are duplicated between blueprint.css (:root) and
the const block at the top of theme.ts. When a hex value changes upstream,
update both.
Re-merge checklist (when framejs.app's style changes)
diff <(sed …) … — compare the sibling assets/styles.css against our
editor/src/styles/blueprint.css to see what moved. (They won't match line-for
-line because of the transforms above; scan for changed token values, new
component classes, and new @font-face.)
- Re-apply the transforms in "How styles.css → blueprint.css" for any changed/new
rules.
- If any color token hex changed: update the mirrored
const in theme.ts
to match. This is the easiest step to forget.
- If fonts were added/changed: copy the new woff2 from the sibling
static/fonts/ into editor/public/fonts/.
- If theme-color / data-theme default changed: update
editor/index.html.
just check (types), then visually verify — run the stack and screenshot the
editor header + Settings panel. The editor is embedded at /editor/; see
[[framejs-origins]] for the local dev URL. (If the editor window is blank after
a git op, that's the bind-mount gotcha, not CSS — recreate the editor container.)
Surface: docs (VitePress)
The docs (docs/, VitePress with the default theme) get the blueprint look by
overriding VitePress's own --vp-* CSS variables — you never touch Chakra here.
Files:
| File |
Role |
docs/.vitepress/theme/blueprint.css |
Design tokens + --vp-* mapping (light+dark) |
docs/.vitepress/theme/fonts/*.woff2 |
The 9 IBM Plex woff2 (serif + sans + mono) |
docs/.vitepress/theme/index.ts |
import "./blueprint.css" |
docs/.vitepress/theme/HomeLayout.vue |
Hero URL callout set to --vp-font-family-mono |
How it works:
blueprint.css declares the framejs.app tokens twice: light in :root, dark
in .dark (VitePress's dark-mode class). Unlike the editor, docs support
both modes — VitePress has a built-in appearance toggle and framejs.app
ships both palettes, so wire both.
- It then maps VitePress variables onto the tokens (here
var(--token) is safe —
no Chakra color math): surfaces (--vp-c-bg{,-alt,-soft,-elv}), text
(--vp-c-text-{1,2,3}), hairlines (--vp-c-border/-divider/-gutter),
brand→accent (--vp-c-brand-{1,2,3}, -soft), brand buttons, and the home
hero name (--vp-home-hero-name-color = accent, -background = transparent to
kill the default gradient). Fonts via --vp-font-family-{base,mono}.
- A few structural rules restore the cut-corner feel VitePress otherwise rounds
heavily:
.VPButton, .VPFeature, code blocks → --radius-control/plate.
- Remap VitePress's own neutral scale.
--vp-c-gray-{1,2,3,soft} and
--vp-c-default-{1,2,3,soft} are blue-biased out of the box and back every
"default" surface (alt buttons, custom blocks, badges). Left alone they put
lilac-gray chips on bone paper. They are pointed at the warm tokens, and the
alt-button vars are set to reproduce framejs.app's .btn-secondary (plate
fill + line-strong hairline) rather than a filled gray chip.
- The serif is scoped, not global:
.vp-doc headings, .VPHero .name/.text
and .VPFeature .title only. Nav and sidebar stay sans — in framejs.app the
chrome is sans/mono and only headings take the serif. Hero weight is forced
to 600 (VitePress ships 900; nothing in the system passes 600).
Two deliberate docs-only divergences:
--warning-soft is declared here but does not exist in framejs.app —
nothing there needs a warning fill, and VitePress's warning container does. It
used to point at the green wash, which was simply a bug.
- VitePress's "tip" container is mapped to
--ink-2 / --surface-2, not the
accent. A tip is an aside, not an action; on the accent it made the accent the
loudest thing on a page of prose, breaking the one-bold-move rule.
Fonts — the base-path catch: docs run under base: "/docs/" (config.ts), so a
root-absolute /fonts/… would resolve to the site root, not /docs/. So the docs
fonts are co-located in theme/fonts/ and referenced relatively
(url("./fonts/…")) — Vite processes them and rewrites to the correct
/docs/assets/… hashed URL. (This differs from the editor, which serves fonts
from public/fonts/ at /fonts/.) Validate with just docs/build and grep the
built dist/assets/*.css for url(/docs/assets/ibm-plex-…).
Re-merge for docs: same idea as the editor checklist — diff the sibling
styles.css, and if token values changed, update them in
docs/.vitepress/theme/blueprint.css :root/.dark. Because the --vp-*
mapping uses var(--token) (not hex), only the token declarations need editing,
not the mapping. Verify with npm run build in docs/ + a visual check of the
home page and one content page in both light and dark.
Two things that only surface at build/preview time:
- Grep the built CSS for the font URLs —
grep -o "url(/docs/assets/ibm-plex-[^)]*)" .vitepress/dist/assets/*.css should list all nine faces. A face that fails the
base-path rewrite silently falls back to Georgia/system.
npm run preview + Playwright: the home page embeds a live framejs.io iframe
that never goes idle, so screenshot with waitUntil: "domcontentloaded" and a
fixed wait. networkidle will always time out there.
Deliberate divergences (don't "fix" these)
- Light-only.
editor/index.html pins data-theme="light", so the dark
palette in blueprint.css never activates. It's kept verbatim (dormant) so a
future theme toggle can match framejs.app in one step. framejs.app itself is
3-way (System/Light/Dark).
- Text inputs keep the external
@metapages/metaframe-chakra-theme package's
#ECECEC !important background — it can't be overridden cleanly from theme.ts.
Not blueprint-warm, but low-priority; changing it needs a full Chakra Input
override or editing the package.
- Code editor pane is an external Monaco metaframe (
editor.mtfm.io) with its
own styling — out of scope for this sync.
1---2name: framejs-css-sync3description: How this repo's visual style (the editor AND the docs) is derived from framejs.app's design system, and the exact steps to re-merge it when framejs.app changes. Load whenever restyling the editor or docs, updating palette/fonts/tokens, syncing the "Offprint" look from the framejs-nhost sibling repo, or editing editor/src/styles/blueprint.css, theme.ts, or docs/.vitepress/theme/blueprint.css.4---56# framejs.io ⇐ framejs.app design system ("Offprint")78This repo's surfaces are **styled to copy framejs.app**, not invented here.9framejs.app (the `framejs-nhost` sibling repo) owns the design system; we mirror10it so everything reads as one product. See [[framejs-origins]] for the repo split.1112Two surfaces mirror it, each with its own bridge because each uses a different13framework:1415| Surface | Framework | Bridge file |16| -------- | -------------------- | --------------------------------------- |17| Editor | Chakra UI v2 (React) | `editor/src/styles/theme.ts` |18| Docs | VitePress | `docs/.vitepress/theme/blueprint.css` |1920The rest of this doc covers the **editor** first, then the **docs**.2122**Design language:** "Offprint" — bone stock (`#f4f3ef`), near-black ink23(`#111110`), one dusk blue (`#465775`), rules and space in place of cards, cut242px corners. IBM Plex **Serif** (headings) + Sans (body/chrome) + Mono25(labels/wordmark/data/code).2627> **Migration status (2026-09-19).** The accent is now **dusk blue**28> `#465775` (dark peer `#9cb2d2`). It replaced a vermillion `#c9350a`, which had29> itself replaced a "Blueprint / drafting table" world built on cobalt30> `#1f2edb`. Both predecessors were rejected for the same reason: a *saturated*31> accent at mid lightness reads as AI-generated, whatever the hue. Dusk blue32> sits at 40% saturation / 46% value — see the Low Chroma Rule in the app's33> DESIGN.md.34>35> **All three mirrors are now on the same palette**: the docs mirror, the runtime36> (`worker/static/blueprint.css`, `worker/index.html`), and the editor37> (`editor/src/styles/blueprint.css`, `theme.ts`, `editor/index.html`) — the38> editor's neutrals and status colours came across too, not just the accent, so39> its cool blue-black dark ground is gone. Radii there are still 3–4px against40> the app's 2px; that is shape, not colour, and is the remaining divergence.41> Both mirrors are still *named* `blueprint.css`; rename them together whenever42> that churn is acceptable.4344**What the editor still needs** (the full re-merge checklist below applies):45swap the token hexes in `blueprint.css` **and** the mirrored `const` block in46`theme.ts`, copy the three `ibm-plex-serif-*` woff2 into `editor/public/fonts/`,47set headings to the serif, drop radii to 2px, retint shadows to48`rgba(32,26,18,…)`, and change `editor/index.html`'s `theme-color` from49`#f4f3ef` to `#f4f3ef`.5051## The core tension5253- **framejs.app** is **Tailwind v4 + Preact (Deno Fresh)**. Its entire design54 system is one file: `assets/styles.css` (CSS custom properties + `@theme inline`55 Tailwind mapping + component classes `.btn`/`.plate`/`.eyebrow`/`.field` + fonts).56- **This editor** is **Chakra UI v2 + React (Vite)**. No Tailwind.5758So we can't share the file verbatim. Instead we mirror the **tokens, component59classes, and fonts** as plain CSS, and bridge Chakra to the same values.6061## Source of truth (in the sibling repo)6263| What | Path (in `framejs-nhost`) |64| --------------- | ---------------------------------------------------------- |65| Stylesheet | `frontend/worker/assets/styles.css` |66| Fonts (9 woff2) | `frontend/worker/static/fonts/ibm-plex-*.woff2` |67| HTML shell/theme| `frontend/worker/routes/_app.tsx` (theme-color, data-theme)|6869Sibling repo root on this machine: `/Users/dion/dev/git/metapages/framejs-nhost`.7071## Where it lands here7273| File | Role |74| ----------------------------------- | ---------------------------------------------------------- |75| `editor/src/styles/blueprint.css` | Plain-CSS mirror of `styles.css` — tokens + classes + fonts|76| `editor/public/fonts/*.woff2` | The IBM Plex woff2, served by Vite at `/fonts/` |77| `editor/src/styles/theme.ts` | Chakra bridge: maps blueprint token values onto Chakra |78| `editor/src/index.tsx` | `import "/@/styles/blueprint.css"` |79| `editor/index.html` | `<html data-theme="light">` + `theme-color` = `#f4f3ef` |8081## How `styles.css` → `blueprint.css` (the transform)8283`blueprint.css` is `styles.css` with the Tailwind-only bits removed. When84re-merging, apply exactly these transforms and copy everything else **verbatim**:85861. **Drop** the first line `@import "tailwindcss";`.872. **Drop** the `@theme inline { … }` block (it only generates Tailwind88 utilities). BUT preserve the non-color tokens it defines by adding them to89 `:root` as plain custom properties: `--font-sans`, `--font-mono`,90 `--radius-plate`, `--radius-control`, `--ease-out`.913. **Unwrap** the `@layer base { }`, `@layer components { }`,92 `@layer utilities { }` wrappers into plain rules. (Emotion injects Chakra's93 styles unlayered, which would out-prioritize layered rules — so don't keep the94 layers.)954. **Move `body { background/color }` out.** Chakra's CSS reset sets the body96 bg/color and wins by injection order, so the paper/ink body styles live in97 `theme.ts` `styles.global`, not here. Keep `::selection` and `:focus-visible`.985. **Keep verbatim:** all `@font-face` blocks (paths are `/fonts/…`, same as99 framejs.app), the full `:root` light palette, the dark palette blocks (kept100 dormant — see below), and the component classes `.eyebrow`/`.btn*`/`.field`/101 `.plate`/`.bg-grid`/`.ticks`/`.tabular`.102103Hero-specific animation classes (`.hero-plot*`, `.live-dot`, `.overlay-in`,104`.panel-in`) are framejs.app landing-page-only — omit them here unless a matching105component is added.106107## The Chakra bridge (`theme.ts`)108109Chakra can't consume `.btn`/`.plate` classes, so `theme.ts` remaps Chakra's own110scales onto the blueprint neutrals + accent and sets the fonts:111112- `fonts.body/heading` → IBM Plex Sans; `fonts.mono` → IBM Plex Mono.113- `colors.gray.{50..900}` → blueprint neutrals (surface / surface-2 / line /114 line-strong / ink-3 / ink-2 / ink), so existing components (header, panels,115 dividers that use `gray.*`) adopt the palette with no per-component edits.116- `colors.blue.{...}` → dusk accent (`--accent` / `--accent-hover` / `--accent-soft`).117- `borders."1px"` → warm hairline (`--line`) so `borderBottom="1px"` follows.118- `styles.global.body` → paper bg + ink text.119120> **Important:** `theme.ts` uses **literal hex**, NOT `var(--token)`. Chakra runs121> build-time color math (`transparentize`, etc.) that chokes on CSS variables.122> This means token **values are duplicated** between `blueprint.css` (`:root`) and123> the `const` block at the top of `theme.ts`. When a hex value changes upstream,124> update **both**.125126## Re-merge checklist (when framejs.app's style changes)1271281. `diff <(sed …) …` — compare the sibling `assets/styles.css` against our129 `editor/src/styles/blueprint.css` to see what moved. (They won't match line-for130 -line because of the transforms above; scan for changed **token values**, new131 **component classes**, and new **@font-face**.)1322. Re-apply the transforms in "How styles.css → blueprint.css" for any changed/new133 rules.1343. **If any color token hex changed:** update the mirrored `const` in `theme.ts`135 to match. This is the easiest step to forget.1364. **If fonts were added/changed:** copy the new woff2 from the sibling137 `static/fonts/` into `editor/public/fonts/`.1385. **If theme-color / data-theme default changed:** update `editor/index.html`.1396. `just check` (types), then visually verify — run the stack and screenshot the140 editor header + Settings panel. The editor is embedded at `/editor/`; see141 [[framejs-origins]] for the local dev URL. (If the editor window is blank after142 a git op, that's the bind-mount gotcha, not CSS — recreate the editor container.)143144## Surface: docs (VitePress)145146The docs (`docs/`, VitePress with the default theme) get the blueprint look by147overriding VitePress's own `--vp-*` CSS variables — you never touch Chakra here.148149**Files:**150151| File | Role |152| ------------------------------------------ | ------------------------------------------------ |153| `docs/.vitepress/theme/blueprint.css` | Design tokens + `--vp-*` mapping (light+dark) |154| `docs/.vitepress/theme/fonts/*.woff2` | The 9 IBM Plex woff2 (serif + sans + mono) |155| `docs/.vitepress/theme/index.ts` | `import "./blueprint.css"` |156| `docs/.vitepress/theme/HomeLayout.vue` | Hero URL callout set to `--vp-font-family-mono` |157158**How it works:**1591601. `blueprint.css` declares the framejs.app tokens twice: light in `:root`, dark161 in `.dark` (VitePress's dark-mode class). Unlike the editor, **docs support162 both modes** — VitePress has a built-in appearance toggle and framejs.app163 ships both palettes, so wire both.1642. It then maps VitePress variables onto the tokens (here `var(--token)` is safe —165 no Chakra color math): surfaces (`--vp-c-bg{,-alt,-soft,-elv}`), text166 (`--vp-c-text-{1,2,3}`), hairlines (`--vp-c-border`/`-divider`/`-gutter`),167 brand→accent (`--vp-c-brand-{1,2,3}`, `-soft`), brand buttons, and the home168 hero name (`--vp-home-hero-name-color` = accent, `-background` = transparent to169 kill the default gradient). Fonts via `--vp-font-family-{base,mono}`.1703. A few structural rules restore the cut-corner feel VitePress otherwise rounds171 heavily: `.VPButton`, `.VPFeature`, code blocks → `--radius-control/plate`.1724. **Remap VitePress's own neutral scale.** `--vp-c-gray-{1,2,3,soft}` and173 `--vp-c-default-{1,2,3,soft}` are blue-biased out of the box and back every174 "default" surface (alt buttons, custom blocks, badges). Left alone they put175 lilac-gray chips on bone paper. They are pointed at the warm tokens, and the176 alt-button vars are set to reproduce framejs.app's `.btn-secondary` (plate177 fill + `line-strong` hairline) rather than a filled gray chip.1785. **The serif is scoped**, not global: `.vp-doc` headings, `.VPHero .name/.text`179 and `.VPFeature .title` only. Nav and sidebar stay sans — in framejs.app the180 chrome is sans/mono and only headings take the serif. Hero weight is forced181 to 600 (VitePress ships 900; nothing in the system passes 600).182183**Two deliberate docs-only divergences:**184185- `--warning-soft` is declared here but does **not** exist in framejs.app —186 nothing there needs a warning fill, and VitePress's warning container does. It187 used to point at the *green* wash, which was simply a bug.188- VitePress's "tip" container is mapped to `--ink-2` / `--surface-2`, not the189 accent. A tip is an aside, not an action; on the accent it made the accent the190 loudest thing on a page of prose, breaking the one-bold-move rule.191192**Fonts — the base-path catch:** docs run under `base: "/docs/"` (config.ts), so a193root-absolute `/fonts/…` would resolve to the site root, not `/docs/`. So the docs194fonts are **co-located in `theme/fonts/` and referenced relatively**195(`url("./fonts/…")`) — Vite processes them and rewrites to the correct196`/docs/assets/…` hashed URL. (This differs from the editor, which serves fonts197from `public/fonts/` at `/fonts/`.) Validate with `just docs/build` and grep the198built `dist/assets/*.css` for `url(/docs/assets/ibm-plex-…)`.199200**Re-merge for docs:** same idea as the editor checklist — diff the sibling201`styles.css`, and if token **values** changed, update them in202`docs/.vitepress/theme/blueprint.css` `:root`/`.dark`. Because the `--vp-*`203mapping uses `var(--token)` (not hex), only the token declarations need editing,204not the mapping. Verify with `npm run build` in `docs/` + a visual check of the205home page and one content page in both light and dark.206207Two things that only surface at build/preview time:208209- Grep the built CSS for the font URLs — `grep -o "url(/docs/assets/ibm-plex-[^)]*)"210 .vitepress/dist/assets/*.css` should list all nine faces. A face that fails the211 base-path rewrite silently falls back to Georgia/system.212- `npm run preview` + Playwright: the home page embeds a live framejs.io iframe213 that never goes idle, so screenshot with `waitUntil: "domcontentloaded"` and a214 fixed wait. `networkidle` will always time out there.215216## Deliberate divergences (don't "fix" these)217218- **Light-only.** `editor/index.html` pins `data-theme="light"`, so the dark219 palette in `blueprint.css` never activates. It's kept verbatim (dormant) so a220 future theme toggle can match framejs.app in one step. framejs.app itself is221 3-way (System/Light/Dark).222- **Text inputs** keep the external `@metapages/metaframe-chakra-theme` package's223 `#ECECEC !important` background — it can't be overridden cleanly from `theme.ts`.224 Not blueprint-warm, but low-priority; changing it needs a full Chakra `Input`225 override or editing the package.226- **Code editor pane** is an external Monaco metaframe (`editor.mtfm.io`) with its227 own styling — out of scope for this sync.