@old-school-chronicle/vellum
v0.6.3
Published
OSC "Vellum" brand tokens, fonts, vm- component CSS and icon set — framework-agnostic, shared by the Foundry module and the web app
Readme
@old-school-chronicle/vellum
The OSC Vellum brand token layer — one source of truth for colour, type, spacing, radii, and the "vellum"/brass-gold surface treatments shared across the OSC ecosystem (the Foundry character-sheet module and the web app).
Extracted from the Vellum system in the osc-character-sheet repo (OSC-77).
What's in it
tokens.css— the primary artifact. Plain CSS custom properties on:root(dark theme, default) and[data-theme="cream"]. Framework-agnostic, self-contained, zero dependencies. This is the source of truth for styling.src/tokens.scss+src/_scales.scss— the SCSS source._scales.scssholds the type/radius/spacing scale maps;tokens.scssloops them to emit the--fs-*/--r-*/--spacer-*custom properties.tokens.cssis generated from these (pnpm build), so the scales can't drift.tokens.js+tokens.d.ts— a convenience JS mirror (scale maps + raw palette) for JS/TS consumers (inline styles, Tailwind theme extension, a token reference UI). Not required for styling.fonts.cssandcomponents.css— opt-in; see Fonts and Components.icons,icons/react— the OSC icon set (game-icons.net, CC BY 3.0): path data,iconSvg()markup, item/class defaults and credits; and an optional ReactOscIcon. SeeICONS.md.
Tokens include: the teal/crimson palette + warm brass-gold accent (--accent-alt,
--gold*), surface ramp (--bg/--surface*/--border*), text ramp, ink stamp,
type families (--display/--serif/--sans/--mono), the --fs-* type scale,
--spacer-* spacing, --r-* radii, line-heights, the --vellum-grain paper
texture, and semantic/functional tokens (--hit/--damage/--magic/--gold/
--on-accent/--section-rule), the *-ink accents for text (--teal-ink/--danger-ink/
--accent-alt-ink, AA on every ground in both themes — --teal/--crimson stay fills), and the
--tint-hover/--tint-press/--tint-warm overlays and --stamp-drop. Legacy aliases (--brass/--oxblood/…) are kept
so downstream components can migrate incrementally.
Install
pnpm add @old-school-chronicle/vellumZero dependencies. Inside this monorepo, use "@old-school-chronicle/vellum": "workspace:^".
Consume
Import the CSS once at your app's entry (works in Vite, Astro, plain bundlers — no framework coupling):
import "@old-school-chronicle/vellum/tokens.css";Import order matters: any stylesheet that reads these custom properties assumes
they are already on :root, so this import comes first.
Or pull the SCSS source if you want the raw scale maps:
@use "@old-school-chronicle/vellum/scales" as scales; // src/_scales.scss — $type-scale, $radius, $spacers
@forward "@old-school-chronicle/vellum/tokens"; // src/tokens.scss — emits the custom propertiesOr the JS object:
import { palette, typeScale, spacers } from "@old-school-chronicle/vellum";Switch themes by setting data-theme="cream" on any ancestor element (defaults
to the dark theme on :root).
The role layer (roles.css)
Opt-in, additive, and visually inert on its own. A font stack answers what
faces exist; it does not answer what jobs exist. roles.css declares that
middle layer — eight type roles (--font-head/-name/-key/-figure/
-status/-bar/-ui/-prose), their size roles plus --fs-statkey /
--fs-statsub, six --case-* tokens, and ten density tokens — each assigned to
the value tokens.css already produces.
import "@old-school-chronicle/vellum/tokens.css";
import "@old-school-chronicle/vellum/roles.css"; // after tokens.cssComponents read roles, never families. That is the whole point: a style
direction (or a font swap) becomes a token diff on one selector instead of a
survey of every component. The seam is words vs figures, not key vs value — a
key is words ("Hit Dice"), a value may be digits ("13") or words ("Ready"), and
mono only earns its place where digits align in a column. Roles compose and the
more specific wins: a figure inside a bar still takes --font-figure.
Density is the second axis, independent of type:
data-density="compact" | "airy" on the scope element or any subtree below it.
--row-min-h sits in that group but never drops below 44px — density trades
whitespace, not reachability.
Emitted on :root like the rest of the package. To confine it to a subtree, use
the SCSS source instead of the CSS:
@use "@old-school-chronicle/vellum/roles.scss" with (
$scope: ".osc-sheet"
);No look lives here. The terminal direction is a preset in its consumer.
Tailwind v4 (OSC-139 spike)
Additive. tokens.css is untouched and stays the zero-dependency path — nothing
below is required to consume Vellum.
Entry points
| Entry | What it is |
| -------------------------------------------- | ----------------------------------------------------------- |
| @old-school-chronicle/vellum/tokens.css | Custom properties only. No Tailwind, no build step. |
| @old-school-chronicle/vellum/theme.css | The @theme block alone — no Tailwind import, no opinions. |
| @old-school-chronicle/vellum/roles.css | The role layer. Opt-in, visually inert on its own. |
| @old-school-chronicle/vellum/postcss-scope | PostCSS plugin that confines the output to a root selector. |
theme.css maps every colour to var(--…) from tokens.css, so utilities keep
following data-theme="cream". Load tokens.css first or the colours resolve to
nothing.
That block is @theme inline, and the inline is load-bearing. A plain
@theme lands on :root, so --color-bg: var(--bg) is substituted there and
the resulting colour inherits down as a fixed value. A descendant setting
data-theme="cream" moves --bg, but nothing re-substitutes — so utilities
stay on the dark palette while hand-written CSS using var(--bg) switches, and
half the UI changes theme. With inline, bg-surface emits var(--surface)
directly and resolves against the element. The theme scope is normally a
descendant (a web app toggling a wrapper; the Foundry sheet owning a subtree
rather than the document), so this is the case that matters.
There is deliberately no preset entry point. prefix(), layer() and
source() are import options — a package cannot parametrise them, so shipping a
preset just hard-codes the three things each consumer must own. Write the entry
yourself; it is six lines.
The consumer entry — two shapes, and the choice is not cosmetic
A. An app whose document you own (a web app, Storybook). Layer the utilities. Precedence is then decided by layer order rather than specificity, so your own component CSS beats a utility by design and nothing can surprise you.
@layer theme, base, components, utilities;
@import "@old-school-chronicle/vellum/tokens.css";
@import "tailwindcss/theme.css" layer(theme);
@import "@old-school-chronicle/vellum/theme.css";
@import "tailwindcss/utilities.css" layer(utilities) source(none);
@source "./src";B. A host DOM you don't own (a Foundry module, a browser extension, an embed). Leave the utilities unlayered and import them last.
@layer theme, base, components; /* note: no `utilities` */
@import "@old-school-chronicle/vellum/tokens.css";
@import "tailwindcss/theme.css" layer(theme) prefix(tw);
@import "@old-school-chronicle/vellum/theme.css";
/* ...your own stylesheets... */
@import "tailwindcss/utilities.css" prefix(tw) source(none); /* LAST, unlayered */
@source "./src";Why: unlayered CSS beats every layered rule regardless of specificity. In a shared document, any installed third party can ship a global unscoped rule and it will outrank your layered utilities — and no amount of specificity rescues them, because layering is decided before specificity is even consulted.
This is not hypothetical. The Foundry sheet's coin fields collapsed to 28px and
clipped their values because the ose-advancedfantasytome module ships:
input[type="number"] {
width: 2em;
} /* unlayered, unscoped, global */which beat tw:w-full sitting in @layer utilities. Chasing individual
offenders is unbounded — there is no limit on what modules a user installs — so
the fix is to leave the layer.
The tradeoff, stated plainly: unlayered, precedence between a utility and your
own CSS falls back to specificity then source order. A scoped utility lands at
(0,2,0), so a bespoke rule with three classes beats it. Importing utilities
last handles ties; anything more specific needs to be a deliberate choice.
prefix(tw) belongs with shape B for the same reason — Foundry ships its own
.hidden/.flex/.active, and unprefixed utilities collide in both
directions.
fixtures/plain.css (A) and fixtures/prefixed.css (B) are exactly these, and
tools/verify.mjs compiles both on every check — so the snippets can't rot.
The escape hatches
Layer order — the
@layerline must be the first statement, above every import, or precedence falls back to first-encounter order. That's where the surprises come from when injecting into a host DOM. Declaringbaseis free even though nothing here fills it: an empty layer just reserves the slot, and it gives a consumer's own reset somewhere to sit that utilities still beat. Tailwind hoists its own@layer properties;above yours — expected, it reserves the lowest slot for the@propertypolyfill.Prefix — set it on the
tailwindcssimports; it reaches Vellum's@themetoo (--tw-color-surface,tw:bg-surface).Layer assignment —
layer(theme)/layer(utilities), or omit to sit unlayered against a host stylesheet.Preflight — off, always.
tailwindcss/base.cssis imported nowhere in this package; the Foundry sheet'sall: unsetreset must survive. Preflight-off is not the same as no universal selector: Tailwind still emits*, ::before, ::after, ::backdropto polyfill@propertyfallbacks. It only assigns--tw-*variables and sits behind an@supportsgated to old Safari/Firefox, so it is inert on Chromium — but it lives in@layer properties, which a scoping post-pass must therefore also cover.Content scanning — pin
source(none)and list@sourceexplicitly. An unscanned class emits nothing, with no error, and default detection will happily invent utilities out of prose in a comment. "It built" is not evidence.Root scoping — Tailwind has no primitive for this, so it's a post-pass:
postcss([vellumScope({ root: ".osc-sheet", layers: ["theme", "utilities", "properties"] })]);:root/htmlcollapse onto the root selector, everything else nests under it.
Token lock-in
theme.css resets --color-*, --font-*, --text-*, --spacing, --radius-*
and --leading-* to initial before declaring Vellum's. Tailwind's stock palette
and its dynamic spacing scale are gone: p-7 and text-slate-500 don't compile,
and off-scale must be written p-[28px] — greppable in review. tools/verify.mjs
asserts this on every check.
Fonts (fonts.css)
Opt-in: @import "@old-school-chronicle/vellum/fonts.css"; declares the @font-face rules for
IM Fell English SC, IM Fell English (regular, italic), Libre Franklin (variable, both styles) and
JetBrains Mono, with the woff2 files and OFL notices beside it in fonts/. tokens.css stays
tokens-only. The url()s are ./-relative so a bundler resolves them; under Tailwind's PostCSS
plugin, import it from its own stylesheet, since Tailwind's @import inlining does not rebase
URLs.
IM Fell English SC carries ascent-override: 98.02%, descent-override: 28.83% and
line-gap-override: 0%, or its caps sit 7.4% of an em high in a fixed-height box.
Components (components.css)
Opt-in, framework-agnostic component CSS. Every class is vm- prefixed (daisyUI owns the bare
names) and every rule sits in @layer components, reading Vellum tokens only. Rules restate every
property they rely on, because the Foundry sheet's base layer unsets form controls. For a host DOM,
run it through postcss-scope with layers: ["components"]; tools/components.test.mjs proves
the result stays under .osc-sheet.
Sources are src/components/_*.scss, one partial per group, wired by src/components.scss.
vm-paper— the--vellum-grainpaper texture. Opt-in;background-attachmentis the consumer's call.- Type roles:
vm-title,vm-heading(+vm-heading-sm),vm-action,vm-folio,vm-prose,vm-flavor,vm-help; atomsvm-display,vm-caps,vm-serif,vm-mono,vm-dim,vm-mute. Sans (vm-help) is the voice for UI prose; upright Fell (vm-flavor) is only for in-world flavor; italic only for emphasis within text. - Buttons (
_buttons.scss):vm-btnwithvm-btn-primary,-danger,-secondary,-link,-quiet;vm-bevelfor raised controls. Addvm-btn-mutedtovm-btn-secondaryorvm-bevelfor a quieter face mixed toward its surface; set--btn-groundwhen that surface isn't--bg. - Data (
_data.scss):vm-table(comfortable;vm-table-dense), withvm-table-num,vm-table-select,vm-table-sort+vm-table-sort-icon(driven by theth'saria-sort), and two phone modes —vm-table-scroll(a wrapper that scrolls) orvm-table-stack(rows become label/value lists from each cell'sdata-label). A row is selected viavm-selected,aria-selectedor a checked control in itsvm-table-selectcell, and disabled likewise.vm-meter(-head,-label,-value,-track,-fill,-mark,-caption;vm-meter-sm,vm-meter-warn,vm-meter-over) — putrole="meter"and thearia-value*on the track.vm-tag-edit+vm-tag-removeandvm-tag-inputin avm-tag-list.vm-bulk-bar(-count,-actions,-clear), sticky to the viewport bottom; render it after the list. - Icons (
_icons.scss):vm-iconwith tintsvm-icon-dim/-gold/-blood/-mute/-ink, and slotsvm-icon-slot+vm-icon-well/vm-icon-gold-edge(orvm-stamp). Emitted byOscIconfrom./icons/react; seeICONS.md.
Contrast
--text-mute and every *-ink token reach 4.5:1 on --bg, --bg-2, --surface and
--surface-2 (composited over --bg, since they are translucent) and on the -solid twins in
both themes. There is no fainter text step: use --hair/--hair-soft for decoration.
tools/contrast.test.mjs asserts it.
Excluded (deliberately)
- The
.themed/ theme-scope wiring and Foundry window-frame theming. - The enricher / rich-text CSS (
rich-text.scss). - Modals, menus, popovers and tooltips: consumers compose them.
- Element resets.
Maintenance
Edit scales in src/_scales.scss and other tokens in src/tokens.scss, then
regenerate the CSS:
pnpm --filter @old-school-chronicle/vellum build # sass -> tokens.css, theme.css, roles.css, components.css
pnpm --filter @old-school-chronicle/vellum check # drift diff on every artifact + escape-hatch smoke test
pnpm --filter @old-school-chronicle/vellum test # postcss-scope, contrast and component-layer teststheme.scss loops the same _scales.scss maps as tokens.scss, so the utility
scale and the custom-property scale are one source.
Everything is generated; nothing here is hand-kept. tokens.css and
theme.css come from the SCSS, and tokens.js is derived from tokens.css —
so the chain SCSS → tokens.css → tokens.js is diffed end to end by check.
tokens.js used to be hand-maintained and had silently drifted a full type step;
that is now structurally impossible.
tokens.js also derives the palette, families and line-heights, which are
literals in tokens.scss and never lived in _scales.scss — deriving from the
built CSS rather than the Sass maps is what closes that half of the hole.
