@nomiedge/tokens
v1.0.0
Published
Nomiedge design tokens — color, typography, spacing, radius, motion, shadow and breakpoints.
Readme
@nomiedge/tokens
Design tokens: color, typography, spacing, radius, motion, shadow and breakpoints — every token
category Pack 03 named is now built, plus a generated Tailwind v4 theme. A full token review
(30-token-review.md) remains.
Tailwind integration
Tailwind v4 is CSS-first — there's no tailwind.config.js preset to extend. Instead, this
package generates a @theme CSS file (tailwind-theme.css, built by tailwind-theme.ts) and a
semantic light/dark CSS-custom-property file (semantic.css), both built entirely from this
package's own token exports — nothing in either is a value typed a second time. A consumer imports
both from their own CSS entrypoint, alongside Tailwind itself:
/* src/app/globals.css */
@import "tailwindcss";
@import "@nomiedge/tokens/tailwind-theme.css";
@import "@nomiedge/tokens/semantic.css";
@source "../../node_modules/@nomiedge/ui/dist";(@source tells Tailwind v4's automatic content detection to also scan @nomiedge/ui's compiled
output for classes — without it, utilities this package's components use but a consumer's own
source never types wouldn't be generated. Adjust the relative path to wherever your app's CSS
entrypoint actually lives — see apps/brand-portal/src/app/globals.css or any of
packages/templates/src/{app-shell,marketing,docs}/app/globals.css for the real pattern in use
elsewhere in this repo.)
tailwind-theme.css wires up every primitive token category (raw neutral/accent color
scales, type scale, spacing, radius, shadow, motion, breakpoints) as Tailwind theme values.
semantic.css wires up the semantic light/dark color layer (color.light/color.dark) as
CSS custom properties (--color-*) with a .dark override, resolved against
@nomiedge/ui/the brand portal (Pack 04/05) as the real consumer. See
docs/adr/0007-tailwind-v4-migration.md for why this replaced the old nomiedgePreset JS preset.
Breakpoints
Aligned with Tailwind's default scale (28-breakpoints.md), not a bespoke set — anything on this
system built with Tailwind (the likely case, since most Next.js apps are) gets breakpoints that
already match theme(screens.*), no translation layer needed.
import { breakpoints } from "@nomiedge/tokens";
// breakpoints.md = "768px", breakpoints.lg = "1024px", ...Shadow
sm/md/lg (27-shadow-elevation.md) — minimal, built from the brand ink color at low
opacity rather than generic black, and restrained relative to most component libraries' shadow
scales: prefer color.*.border.default alone over reaching for shadow where either would do.
import { shadow } from "@nomiedge/tokens";
// box-shadow: shadow.mdShadow barely works in dark mode — a low-opacity black shadow is nearly invisible against a
near-black background. Dark-mode elevation should come from color.dark.surface.default being
lighter than the background (established in colors.ts) plus color.dark.border.default, not
from shadow.*. shadow.lg still has a real, narrow use in dark mode: large overlays (modals)
above a dimmed backdrop, where the backdrop itself gives the shadow something to read against.
Motion
duration (fast/base/slow) and easing (standard/decelerate/accelerate — Material's
well-established curve set, not a spring/bounce; overshoot reads as playful, ruled out by
.claude/project.md's "calm, precise, premium").
import { duration, easing, reducedMotionDuration } from "@nomiedge/tokens";
// transition: `${duration.base} ${easing.standard}`Reduced motion is a real, applied mechanism, not a note to add one later: reducedMotionDuration
(0.01ms, not 0ms, so transitionend still fires) is meant to be swapped in via
@media (prefers-reduced-motion: reduce) in CSS — not a JS matchMedia runtime check, which can
cause a hydration mismatch in SSR. See the usage example in motion.ts.
Radius
none/sm/md/lg/xl/2xl/full (25-radius-system.md) — a consistent progression in px
(not rem: corner rounding shouldn't scale with text zoom).
import { radius } from "@nomiedge/tokens";
// radius.md = 8px, radius.xl = 16px, radius.full = 9999px (pills, avatars), ...Resolves a tracked gap: KNOWN_ISSUES.md flagged that brand/logo-inverted.svg's rx=24
wasn't tied to a token. It still isn't, on purpose — the mark's corner radii (16/14/18/24 across
brand/*.svg) are fixed geometric constants chosen for optical balance in the logo's own
construction, not UI radius decisions. radius["2xl"] happens to equal 24px too (radius.xl is
16px, a different step), but that's a coincidence — the same way color.dark.background.default
(neutral[950]) happens to land close to, but not exactly on, the brand ink color — noted in
radius.ts/colors.ts, not a real dependency between packages/logo and packages/tokens.
Spacing
A 4px-based scale (24-spacing-system.md) — 4px steps through 24px, 8px steps beyond. Keyed by
the 4px multiplier, Tailwind's convention: spacing[4] is 16px (4 × 4px).
import { spacing } from "@nomiedge/tokens";
// spacing[2] = 8px, spacing[6] = 24px, spacing[16] = 64px, ...No semantic layer (spacing.componentGap, etc.) yet — that depends on real layout decisions
@nomiedge/ui hasn't made yet. Adding semantic names now would be guessing.
Typography
Geist Sans (UI, body, display) and Geist Mono (code) — confirmed system-wide in
23-typography-tokens.md, the same choice ADR 0002 made for the wordmark specifically.
Reference typography.*, a complete, ready-to-apply style object per semantic use — not
fontSize.lg and fontWeight.bold assembled separately at the call site:
import { typography } from "@nomiedge/tokens";
// typography.display.lg, typography.body.md, typography.code.sm, ...The type scale (fontSize) is a major-third (1.25) ratio, base 16px, rounded to whole pixels —
see the inline comments in typography.ts for the exact math behind each step.
Known duplication (tracked in ../../KNOWN_ISSUES.md): @nomiedge/logo's LogoWordmark
hard-codes its own copy of the Geist font stack rather than importing fontFamily.sans from
here, because packages/logo doesn't depend on packages/tokens (a deliberate choice — see
ARCHITECTURE.md). Now that both independently arrived at Geist, that's a real argument for
adding the dependency; not done in this milestone since it's outside its scope.
Color
neutral and accent — a monochrome base plus exactly one cobalt/indigo accent, per
prompts/Pack-03-Design-Tokens/21-color-system.md. No other hues exist in this package on
purpose: no separate green/red/yellow status palette.
This is a known gap, not an oversight (tracked in ../../KNOWN_ISSUES.md): a serious product
UI eventually needs to communicate success/warning/danger, and this token set doesn't have colors
for that yet. When that need becomes concrete (building @nomiedge/ui's form validation states,
for instance), it needs a deliberate decision — add a scoped exception (e.g. a danger hue for
destructive actions, still no rainbow of status colors), or lean on icon/weight/pattern instead of
color for status. Don't quietly bolt on a red and a green the first time a component needs one.
Import semantic tokens, not the raw scale, from component code — pick the theme, then the token:
import { color } from "@nomiedge/tokens";
// color.light.foreground.primary, color.dark.background.default, color.light.accent.default, ...neutral/accent (the raw 11-step scales) are exported too, for the rare case a semantic token
doesn't exist yet — but a component reaching for neutral[700] directly instead of a semantic
token is a sign a semantic token is missing, not license to skip the semantic layer.
Dark mode is a different mapping, not an inverted formula
color.dark maps the same semantic keys to different steps of the same neutral/accent
scales — it isn't 1 - lightness computed automatically, because that produces genuinely wrong
results for at least one case here: accent at its light-mode step (52% lightness) reads as
muddy against a 6%-lightness dark background, so dark mode uses a lighter accent step (62%)
instead, and lightens further on hover/active (the opposite direction from light mode, which
darkens on hover/active). Surfaces also read as "elevated" by being lighter than the page
background in dark mode — the reverse of light mode's card-on-lighter-background/shadow-driven
elevation. Every one of these choices is deliberate, not derived; see the inline comments in
colors.ts for the reasoning behind each.
Contrast is tested, not eyeballed
colors.test.ts computes real WCAG contrast ratios (contrast.ts, a standard relative-luminance
implementation per the WCAG 2.x formula) for every semantic pair that needs to meet AA — normal text (4.5:1)
or UI-component boundaries (3:1, WCAG 1.4.11) — in both themes. This caught two real bugs
during development, one per theme: light mode's border.default only reached 1.41:1 (fixed to
4.15:1); dark mode's first attempt at border.default only reached 1.86:1 (fixed to 4.24:1) —
the same category of mistake, caught the same way, in an independently-chosen value.
Run pnpm --filter @nomiedge/tokens test after changing any color value, in either theme.
