web/src/styles/tokens/glinr.tokens.json, and everything else is generated from it.
The source is framework-neutral on purpose (a DTCG-like JSON shape, no CSS or Tailwind specifics), so other GLINCKER products can reuse the same file and generate their own output.
Page structure
Every dashboard page follows these rules. Tokens say what a value is. These say where things go.- The
PageTemplatehero is the only title and description on a page. The first card never repeats either. Provider connection cards render no header of their own under the hero. - Cards are single level. No bordered boxes inside a
Card, except a semantic callout, which must beui/banner.tsx. Lists inside a card usedivide-y, not a box per row. - Vertical rhythm inside a card is
space-y-4between blocks and 24px (space-y-6) between sections. - Action rows sit 16px below the content they act on (
border-t pt-4, seeConnectionActions) and are aligned the same way on every page, primary action last. - Empty states are compact:
EmptyState compact(icon, one line, one muted line, no dashed outline, about 96px) inside a page section. The full dashed state is for a whole empty page. - A callout never contains the primary form field. The banner explains; the labelled field and its Save button sit below it as their own block.
actions slot, not beside the title badge. A Banner with only a title renders it as 14px regular body text; 14px 600 is for banners that also have a description.
Files
A vitest test (
web/scripts/build-tokens.test.mjs) fails if the committed CSS drifts from the source, if a legacy variable disappears, or if any var(--glinr-*) in web/src names a variable that does not exist.
Naming rules
- Variables are
--glinr-<group>-<name>, for example--glinr-color-fg,--glinr-space-4,--glinr-motion-fast. - Variables that predate the groups keep their original names through
$extensions.glinr.name(--glinr-bg,--glinr-radius,--glinr-elev-card). Never rename or remove one; add a new token and migrate callers instead. - Each token has a
$descriptionand a$extensions.glinr.stabilityofstableorexperimental. Experimental tokens may change value or be removed after a release note. - A token value is plain, or
{ "light": ..., "dark": ... }, or an alias such as{space.6}. $extensions.glinr.tailwindemits a Tailwind v4 theme variable with aglinrnamespace (text-glinr-sm,bg-glinr-surface,max-w-glinr-content) so it cannot collide with Tailwind defaults.
Type roles
One heading family (Display, Geist Variable) and one body family (system stack). Weight 300 is reserved for numerals and display sizes of 28px or more (the ReadinessRing percentage, big metric numbers, the sign-in showcase). Headings never use 300 below 28px.
Weights come from
--glinr-type-heading-weight (600), --glinr-type-greeting-weight (500) and --glinr-display-weight (300, numerals and display only).
Page hero
PageTemplate renders the hero as one row: tile (--glinr-hero-tile-size, 40px at every density), 12px gap, text block, spacer, actions. The eyebrow sits 4px above the title; the title and status badge share a baseline. With a one line description the tile and text block are vertically centered to each other. When the description wraps the tile top-aligns with the title (below the eyebrow) and the actions align to the title row. Pages with a brand mark pass <BrandTile size="lg"> as logo; they never build their own header markup.
Fonts are the @fontsource-variable/geist and @fontsource-variable/geist-mono packages, latin subset, font-display: swap, with the system stack as fallback.
Elevation scale
Dark mode uses a darker translucent shadow plus a 1px top highlight (
--glinr-highlight) or ring so depth stays visible. There are no glow or gradient tokens.
Spacing, radius, motion, widths
- Space:
--glinr-space-1,2,3,4,6,8,12,16on a 4px rhythm;--glinr-page-gapaliasesspace-6. - Radius:
sm 6px,md 8px,lg 10px(the legacy--glinr-radius),xl 14px. - Motion:
--glinr-motion-fast120ms,base200ms,slow320ms,--glinr-motion-ease. Honourprefers-reduced-motion. - Content:
--glinr-content-narrow40rem,--glinr-content-max96rem,--glinr-content-max-wide120rem.
Layout
- The shell column fills the space beside the sidebar up to
--glinr-content-maxand centers withmx-autoonly once the viewport is wider. Padding is symmetric: 16px belowmd,--glinr-page-pad-x(24px) frommdup. Pages never anchor left or center themselves. PageTemplatewidth:defaultcaps at 96rem,wideat 120rem (dense tables, dashboards),fullhas no cap.- Right rail: pass
aside(andasideLabel) toPageTemplate. The page column is a container (@container/page). At@min-[80rem]of that container (xl, 1280px of content area) the body is a two column grid,minmax(0,1fr)main plus a fixed--glinr-aside-width(20rem) rail, gap 24px, rail sticky at--glinr-aside-topinside the scrolling main. Below that the rail renders after the main content in normal flow. Withoutasidethe page is a single full width column. FormSectioncaps its fields column atmax-w-2xlso inputs never stretch across a wide column.
Adding a token
- Add it to
glinr.tokens.jsonin the right group with$value,$descriptionand$extensions.glinr.stability(startexperimental). - Add
$extensions.glinr.tailwindonly if a utility is wanted. - Run
npm run tokensinweb/and commit the regenerated CSS. - Use it as
var(--glinr-...)or the Tailwind utility. Do not hardcode hex or px in a component when a token exists.
Changelog
2026-10-12, type scale and hero fix. Page titles 24 to 20px, banner and section titles 18 and 16 to 14 and 15px, all weight 600 in the Display family (previously 300 Geist next to 500 system-ui). Greeting 24/30 to 28px weight 500. Eyebrow 12 to 11px.--glinr-hero-tile-size-comfortable now equals --glinr-hero-tile-size (2.5rem) so the hero tile no longer changes size with density. New tokens: type-heading-weight, type-greeting-weight, type-size-banner|section|page|greeting|eyebrow, type-leading-banner|section|page|greeting.
2026-10-12, token source introduced. Value changes to existing variables:
--glinr-font-display: wasvar(--font-sans)(system stack), now'Geist Variable'then the same system stack. Visible on Display headings.--glinr-font-eyebrow: wasvar(--font-mono), now'Geist Mono Variable'then the same mono stack. Visible on eyebrows.--glinr-page-gap: same value (1.5rem), now an alias of--glinr-space-6.--shadow-header|card|popover|dialog: same values, now emitted fromglinr-theme.cssinstead ofindex.css.
color-border-strong, color-grid-line, color-fg, color-fg-muted, color-fg-subtle, color-accent, color-showcase-muted, color-showcase-rule, space-*, radius-sm|md|xl, content-narrow, type-* scale, leading, tracking, weight and numeric features, font-body, motion-*.
GLIN UI
@glinui/ui and @glinui/tokens (0.1.1, MIT) are installed in web/. Only Skeleton is used so far, through web/src/components/ui/skeleton.tsx, the single adapter. Every existing skeleton (ListSkeleton, TableSkeleton, settings skeletons) goes through it, so the look changes in one place.
- Variants: the adapter accepts
glinr(default),solid,plain,soft,outline,ghost, the names on the docs page. Version 0.1.1 publishesdefault | glass | liquid | matte | outline | ghost, soglinrmaps tomatte,solidandplaintodefault,softtoghost. Drop the map once the docs names ship. - Motion: the shimmer is
motion-reduce:hidden. Theskeleton-shimmerkeyframes are not shipped by the package, sosrc/index.cssdefines them. - Tailwind does not scan
node_modules, sosrc/index.csslists the Skeleton classes with@source inline(...). A file-path@sourcedoubled the compiled stylesheet. - Bundle:
@glinui/uihas no"sideEffects": false, so its barrel pulls every Radix package.vite.config.tsmarks the package side-effect free for the production build, which keeps the main chunks flat (vendor +2 KB). Vitest inlines the package because its ESM uses extensionless imports Node cannot load. - Not installed:
@glinui/cliis unpublished on npm (404), so the docs’dlx @glinui/cli add skeletonfails. That needs fixing on the glinui side. Use the package route until then.
Token overlap
@glinui/tokens/theme.css is not imported, on purpose: the mapped Skeleton variants need none of its variables. It redefines names we already own: --color-background, --color-foreground, --color-border, --color-accent, --color-accent-foreground, --radius-sm|md|lg|xl|2xl (different values, e.g. --radius-lg: 1rem against our var(--radius)), --motion-fast, --motion-slow. Importing it plain would change those values app wide. If the glass or liquid variants are adopted, import it as @import '@glinui/tokens/theme.css' layer(glinui-tokens) with @layer glinui-tokens; declared before @import 'tailwindcss', and re-check the overlap above.