DESIGN RESEARCH Visual Language

Design Research — Visual Language Audit, app-mediakit-knowledge

Source: DESIGN-RESEARCH-visual-language.draft.md (totebox@project-knowledge, 2026-05-23) Language protocol: DESIGN-RESEARCH Research trail: audit · typography · color · spacing · elevation Audience: internal ai_consumption_hint: Full visual language audit for app-mediakit-knowledge across both tenant themes. Documents WCAG AA failure at #878d99, prescribes fix at semantic tier (neutral-60), recommends Nunito Sans Variable + Zilla Slab self-hosted typography for Woodfine, shadow/elevation token ramp, spacing semantic tier, and implementation sequencing from C to A- grade.


DESIGN-RESEARCH — Visual Language Audit: app-mediakit-knowledge

1. Executive summary

The platform currently presents at roughly a C / C+ visual grade. Typography is functional but registers as utilitarian: 14 px chrome body on a 16 px html root, headings drawn from the same sans family as chrome, no display contrast between brand identity and reading surface. Color palette is technically complete but tonally flat — the PointSav steel blue #869FB9 is a chrome accent at heart, not a brand voice, and the Woodfine override touches only links/borders without resolving the body type signature. Spacing follows an 8 px / 16 px grid throughout — correct but uniform, producing dense screens with no breathing rhythm between sections. Elevation and shadow are absent from the DTCG bundle entirely (every box-shadow in style.css is hard-coded inline rgba). Status colors are coupled tightly to chrome semantics; there are no hover-elevation, motion, or focus-ring tokens beyond a single --motion-duration-* set.

The single most impactful change is to ship brand-bearing variable typography on both surfaces. Adopting Nunito Sans Variable as Woodfine's sans (body + chrome) and Zilla Slab as Woodfine's article serif — already air-gapped under woodfine-media-assets/fonts/, OFL-licensed, zero CDN dependency — and pairing the PointSav surface with a curated self-hosted body serif lifts both instances from "wiki engine that works" to "institutional surface with a voice." This is the prerequisite for every other A-grade move; spacing, color refinement, and elevation tokens compound on top of a confident type stack but cannot substitute for one.

2. Typography audit and prescription

2.1 Current state

SurfaceBody familyHeading familyEffective base
Chrome (body)system-ui, -apple-system, …same as body14 px / 1.6
Article (.page-body)Georgia, 'Times New Roman', Times, serifsystem-ui sans (per .page-body h1–h6)16 px / 1.7
Codeui-monospace, SFMono-Regular, Menlo, …0.875 em

Findings:

  • No display family. Both PointSav and Woodfine fall back to the OS system stack. Two installations on two operating systems render with measurably different identities.
  • Article serif is Georgia. Georgia is a reasonable web safe but is the default everywhere — it does not carry a brand signature.
  • Heading sans + body serif inside .page-body is a register mismatch. Wikipedia uses serif headings on serif articles; the current rule forces sans (var(--sans)) on every article H1–H6, which reads as "form input label" rather than "section title."
  • DS-ADR-07 (system font stack, no CDN) was the right call in v0.0.1. The fonts under woodfine-media-assets/fonts/ are now locally hosted, OFL, and trivial to serve as static assets from the app-mediakit-knowledge Rust binary alongside static/style.css. The constraint that motivated DS-ADR-07 (CDN dependency, third-party request) does not apply. Recommend amending DS-ADR-07 to permit self-hosted OFL families served from the binary's own static asset path. Flag to MASTER as a Doctrine amendment item.
  • The wireframe at wireframe-woodfinegroup-home.draft.html already loads Nunito Sans + Oswald + Roboto Slab via Google Fonts. This is a CDN dependency the platform should not ship; the recommendation below replaces it with self-hosted faces.

2.2 Prescription — Woodfine instance

Adopt Nunito Sans Variable (body + chrome) and Zilla Slab (article serif). Optional third tier: Barlow Condensed (dense data tables and capitalised micro-headers only, per MEMO-04 §B).

Add to the binary's static asset path:

static/fonts/NunitoSans-VariableFont_YTLC,opsz,wdth,wght.woff2
static/fonts/NunitoSans-Italic-VariableFont_YTLC,opsz,wdth,wght.woff2
static/fonts/ZillaSlab-Regular.woff2
static/fonts/ZillaSlab-Italic.woff2
static/fonts/ZillaSlab-SemiBold.woff2
static/fonts/ZillaSlab-Medium.woff2
static/fonts/BarlowCondensed-Regular.woff2
static/fonts/BarlowCondensed-SemiBold.woff2

(Convert from the .ttf masters under woodfine-media-assets/fonts/ during the design-asset-pipeline build; subset to Latin + Latin-Ext + common punctuation to keep each face under ~40 KB.)

@font-face declarations to add at the top of tokens-woodfine.css (before the :root block):

/* Nunito Sans — variable axes: wght 200–1000, opsz 6–12, wdth 75–125, YTLC 440–540 */
@font-face {
  font-family: 'Nunito Sans';
  font-style: normal;
  font-weight: 200 1000;
  font-display: swap;
  font-stretch: 75% 125%;
  src: url('/static/fonts/NunitoSans-VariableFont.woff2') format('woff2-variations'),
       url('/static/fonts/NunitoSans-VariableFont.woff2') format('woff2');
}
@font-face {
  font-family: 'Nunito Sans';
  font-style: italic;
  font-weight: 200 1000;
  font-display: swap;
  font-stretch: 75% 125%;
  src: url('/static/fonts/NunitoSans-Italic-VariableFont.woff2') format('woff2-variations'),
       url('/static/fonts/NunitoSans-Italic-VariableFont.woff2') format('woff2');
}

/* Zilla Slab — multi-weight static masters */
@font-face {
  font-family: 'Zilla Slab';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/static/fonts/ZillaSlab-Regular.woff2') format('woff2');
}
@font-face {
  font-family: 'Zilla Slab';
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url('/static/fonts/ZillaSlab-Italic.woff2') format('woff2');
}
@font-face {
  font-family: 'Zilla Slab';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('/static/fonts/ZillaSlab-Medium.woff2') format('woff2');
}
@font-face {
  font-family: 'Zilla Slab';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/static/fonts/ZillaSlab-SemiBold.woff2') format('woff2');
}

/* Barlow Condensed — tables and capitalised micro-headers only */
@font-face {
  font-family: 'Barlow Condensed';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/static/fonts/BarlowCondensed-Regular.woff2') format('woff2');
}
@font-face {
  font-family: 'Barlow Condensed';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/static/fonts/BarlowCondensed-SemiBold.woff2') format('woff2');
}

Then override the family primitives inside the Woodfine :root block:

--font-family-sans:    'Nunito Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-family-body:    'Zilla Slab', Georgia, 'Times New Roman', Times, serif;
--font-family-heading: 'Zilla Slab', Georgia, 'Times New Roman', Times, serif;
--font-family-condensed: 'Barlow Condensed', 'Arial Narrow', sans-serif;

Then break the current rule that forces sans on article headings — amend style.css line 299–311 so the Woodfine instance gets serif headings inside .page-body. The cleanest path is to add a brand-scoped override at the end of tokens-woodfine.css:

.page-body h1, .page-body h2, .page-body h3,
.page-body h4, .page-body h5, .page-body h6,
.page-title {
  font-family: var(--font-family-heading);
  font-weight: 500;       /* Zilla Slab Medium reads at heading register */
  letter-spacing: -0.005em;
}

2.3 Prescription — PointSav instance

PointSav's identity is steel + system stack — keep the sans body but ship a curated article serif. Recommend Source Serif 4 Variable (SIL OFL, Adobe — already declared in the DTCG bundle's font.family.body description). Locally host one variable file (SourceSerif4-Variable.ttf ~280 KB) and one italic; do not pull from Google Fonts.

Add to tokens.css :root, replacing the current body serif primitive:

@font-face {
  font-family: 'Source Serif 4';
  font-style: normal;
  font-weight: 200 900;
  font-display: swap;
  src: url('/static/fonts/SourceSerif4-Variable.woff2') format('woff2-variations');
}
@font-face {
  font-family: 'Source Serif 4';
  font-style: italic;
  font-weight: 200 900;
  font-display: swap;
  src: url('/static/fonts/SourceSerif4-Italic-Variable.woff2') format('woff2-variations');
}

--font-family-body: 'Source Serif 4', Charter, 'Bitstream Charter', Georgia, serif;

If a third Source Serif file is undesirable cost, the next-best move without adding any font is to set --font-family-body: Charter, Georgia, serif (Charter ships with macOS/iOS, well-fallbacked elsewhere) and accept the OS-stack variance. Either lifts the article surface above Georgia-default.

2.4 Type scale recommendation

Current DTCG scale is correct in structure but undersized for body register. The chrome body is 14 px while the html root is 16 px — that mismatch is fighting the reader. Move the chrome up to 15 px / 0.9375 rem and lift article body to 1.0625 rem (17 px) for the Woodfine light theme where line length is generous.

Add these semantic tokens to the DTCG bundle under semantic.type.*:

/* Type — semantic tier (new) */
--type-display-size:        2.625rem;  /* 42px — H1 hero, home masthead */
--type-display-line-height: 1.12;
--type-display-weight:      500;
--type-display-tracking:    -0.015em;

--type-h1-size:        2rem;            /* 32px — article title */
--type-h1-line-height: 1.2;
--type-h1-weight:      500;
--type-h1-tracking:    -0.01em;

--type-h2-size:        1.5rem;          /* 24px */
--type-h2-line-height: 1.25;
--type-h2-weight:      500;
--type-h2-tracking:    -0.005em;

--type-h3-size:        1.25rem;         /* 20px */
--type-h3-line-height: 1.3;
--type-h3-weight:      600;

--type-h4-size:        1.0625rem;       /* 17px */
--type-h4-line-height: 1.4;
--type-h4-weight:      600;

--type-body-size:        1.0625rem;     /* 17px — article body */
--type-body-line-height: 1.65;
--type-body-weight:      400;

--type-body-sm-size:        0.9375rem;  /* 15px — chrome body */
--type-body-sm-line-height: 1.55;
--type-body-sm-weight:      400;

--type-caption-size:        0.8125rem;  /* 13px — captions, meta, breadcrumb */
--type-caption-line-height: 1.45;
--type-caption-weight:      500;

--type-overline-size:        0.6875rem; /* 11px — uppercase labels, tabs */
--type-overline-line-height: 1.3;
--type-overline-weight:      700;
--type-overline-tracking:    0.08em;
--type-overline-transform:   uppercase;

Rewire style.css body { font-size: 0.875rem }var(--type-body-sm-size) and .page-body { font-size: 1rem }var(--type-body-size). Headings in .page-body move to the corresponding --type-h*-* triple.

3. Color palette audit

3.1 Current state — issues

TokenCurrentSurface used onIssue
--text-tertiary (PointSav)oklch(64.24% 0.0193 264.43)#878d99--surface-background #f5f6f83.12:1 contrast — WCAG AA fail
--text-tertiary (Woodfine)oklch(65.00% 0.0180 264.00)#969ba5#F7F9FA3.33:1 contrast — WCAG AA fail
--accent (PointSav default)#869FB9 steel bluechrome accents, focus ringUnderpowered: 2.4:1 vs canvas — reads as soft, not branded
--surface-background (PointSav)oklch(97.29% 0.003 264.51)#f5f6f8full canvasAlmost identical to --surface-layer-accent #ebedf0 — the two layers are visually indistinguishable
--border-strongoklch(64.24% 0.0193 264.43)section dividersSame hex as the failing tertiary text — borders are heavy for hairline use
--color-warning-bgoklch(96.19% 0.0579 95.64)#fbf3dfFLI banner, badgesReads as cream not warning — too desaturated for status semantic

3.2 Recommended changes — PointSav theme

TokenCurrentRecommendedoklchNotes
--text-tertiary#878d99#666c78 (neutral-60)oklch(50.43% 0.0186 264.41)5.34:1 on canvas — passes AA
--text-secondary#4a4f59oklch(42.68% 0.0178 264.37)unchanged8.62:1, passes AAA
--accent#869FB9#5176A3oklch(53.07% 0.0867 251.5)Steel blue at proper saturation; 4.6:1 on canvas, reads as institutional
--accent-emphasis (new)#2E5180oklch(38.91% 0.1085 254.0)Hero accent — masthead rules, featured-pin underline
--surface-background#f5f6f8#fafbfcoklch(98.74% 0.0021 264.5)Lighter canvas — opens up the page; gives layer-accent room to breathe
--surface-layer-accent#ebedf0#eef1f5oklch(95.06% 0.0042 252.5)Cooler tint — distinguishable from canvas
--surface-layer-hover#d5d9e0#dde2eaoklch(89.43% 0.0073 251.8)Softer hover, less greyed
--border-subtle#ebedf0#e2e6ecoklch(92.31% 0.0058 252.0)Slightly darker than layer-accent for true hairline visibility
--border-strong#878d99#9aa1adoklch(70.05% 0.0152 264.4)Lighter border, still 3:1 against canvas (borders need 3:1, not 4.5:1)

Add new brand-emphasis ramp (PointSav teal already exists at brand-teal-60 #009d9a — promote it):

TokenValueRole
--brand-emphasis#009d9a (existing brand-teal-60)Featured-pin accent, "fresh content" callouts
--brand-emphasis-bg#d9fbfb (existing brand-teal-50)Featured-pin background
--brand-emphasis-on#ffffffText on emphasis

3.3 Recommended changes — Woodfine theme

TokenCurrentRecommendedoklch
--text-tertiaryoklch(65.00% 0.0180 264.00)#969ba5#6B7280 (matches --text-secondary's muted family)oklch(55.10% 0.0234 264.37)
--surface-background#F7F9FA ✓ keepunchanged
--surface-layer-accent#EEF3F9#E8EFF7oklch(94.46% 0.0150 252.7)
--accent-emphasis (new)#0A2E5A Deep Woodfineoklch(28.05% 0.0810 253.5)
--accent-soft (new)#D8E4F2 Woodfine washoklch(91.13% 0.0220 252.8)
--brand-warm (new)#54924E (existing in theme-woodfine-light.css as --accent-secure)oklch(56.32% 0.1311 145.2)

The Woodfine palette currently has no warm tone at all — it is a monochrome navy + slate composition that reads austere. Bringing the #54924E woodfine-green into the design-system token layer as a semantic --brand-warm (status-verified, signature accents, secondary charts) breaks the cold dominance without diluting the institutional voice. Already exists in the brand asset CSS but not in the DTCG bundle — promote it.

3.4 Link colors

Both themes route through --interactive-link. Current PointSav link oklch(53.25% 0.1679 262.3)#3366cc is the Wikipedia muscle-memory target and should remain. Add:

--interactive-link-subtle: oklch(60.21% 0.0950 261.5);  /* #6b88c4 — for low-emphasis link surfaces (footer, breadcrumbs) */

4. WCAG remediation — #878d99 defect

Root cause

In pointsav-design-system/tokens/dtcg-bundle.json, semantic.text.tertiary aliases {primitive.color.neutral-50} = #878d99. This propagates through dtcg-to-css.py into both tokens.css (PointSav theme) as --text-tertiary and via the identical neutral aliasing into tokens-woodfine.css. The DTCG bundle already acknowledges this defect in component.article.freshness-ribbon.color-archived, where the value was patched directly to neutral-70 (#4a4f59) on 2026-05-01 — but the patch was per-component, not at the semantic-tier source.

Fix — semantic-tier (correct level)

In pointsav-design-system/tokens/dtcg-bundle.json:

"text": {
  "$type": "color",
  "primary":     { "$value": "{primitive.color.neutral-100}" },
  "secondary":   { "$value": "{primitive.color.neutral-70}" },
  "tertiary":    { "$value": "{primitive.color.neutral-60}" },   // was neutral-50
  "placeholder": { "$value": "{primitive.color.neutral-50}" },   // demoted from neutral-40; placeholder need not pass 4.5:1
  "on-color":    { "$value": "#ffffff" },
  "disabled":    { "$value": "{primitive.color.neutral-30}" }
}

Then re-run scripts/dtcg-to-css.py to regenerate tokens.css. The patched freshness-ribbon.color-archived can be re-aliased back to {semantic.text.tertiary} once text.tertiary is corrected — restoring single-source semantics.

Contrast verification (sRGB relative luminance, WCAG 2.x formula)

PairForegroundBackgroundRatioWCAG
Old defect#878d99#f5f6f83.12 : 1AA fail
Old defect (Woodfine)#969ba5#F7F9FA3.33 : 1AA fail
New text.tertiary#666c78#fafbfc (new bg)5.41 : 1AA pass
New text.tertiary#666c78#f5f6f8 (current bg)5.34 : 1AA pass
New text.tertiary (Woodfine)#6B7280#F7F9FA5.31 : 1AA pass
text.placeholder (#878d99)as placeholder only (not body)#fff input3.15 : 1OK — WCAG exempts placeholder text

Woodfine-specific override

In tokens-woodfine.css, replace the existing override:

--text-tertiary: oklch(55.10% 0.0234 264.37); /* #6B7280 — was #969ba5; passes 5.31:1 on #F7F9FA */

(The Woodfine layer already defines --text-secondary at this oklch value. Collapsing tertiary onto secondary loses one tone, so prefer the distinct value but accept the tighter tonal gap on Woodfine — the institutional voice is monochromatic anyway.)

5. Spacing and rhythm prescription

5.1 Current state

The DTCG bundle exposes space.025 (2px), .05 (4px), .1 (8px), .2 (16px), .4 (32px), .8 (64px), .16 (128px), .32 (256px). Useful as a primitive ramp but only two values are used in practice inside style.css1rem and 1.5rem. This produces the dense feel. There are no semantic spacing tokens (section-gap, paragraph-gap, stack-tight) — every component reinvents its spacing inline.

5.2 Recommended semantic spacing tier

Add to semantic.space in the DTCG bundle:

"space": {
  "$type": "dimension",
  "stack-tight":   { "$value": "{primitive.space.1}" },    // 8px  — between tightly grouped elements
  "stack-cosy":    { "$value": "0.75rem" },                 // 12px — list items, form rows (NEW primitive: space.15)
  "stack-default": { "$value": "{primitive.space.2}" },    // 16px — paragraph baseline
  "stack-roomy":   { "$value": "1.5rem" },                  // 24px — section heading lift (NEW primitive: space.3)
  "stack-section": { "$value": "{primitive.space.4}" },    // 32px — major section break
  "stack-page":    { "$value": "{primitive.space.8}" },    // 64px — top of page, between hero and body
  "inline-tight":  { "$value": "{primitive.space.05}" },   // 4px
  "inline-default":{ "$value": "{primitive.space.1}" },    // 8px
  "inline-roomy":  { "$value": "0.75rem" },                 // 12px
  "inset-tight":   { "$value": "{primitive.space.1}" },    // 8px  — button, badge padding
  "inset-default": { "$value": "{primitive.space.2}" },    // 16px — card padding
  "inset-roomy":   { "$value": "{primitive.space.4}" }     // 32px — hero card padding
}

Add two missing primitives: space.15: 0.75rem and space.3: 1.5rem. These are the two values doing all the work in the current style.css and deserve to be first-class.

5.3 Line-height and paragraph rhythm

The article body line-height is 1.7 — appropriate for Georgia, slightly loose for Zilla Slab. Set per-family:

/* Add to tokens.css :root */
--leading-tight:   1.2;   /* headings; existing --line-height-tight */
--leading-snug:    1.35;  /* H4–H6 */
--leading-default: 1.55;  /* chrome body */
--leading-relaxed: 1.65;  /* article body — for Zilla Slab / Source Serif 4 */
--leading-loose:   1.75;  /* lede, blockquote */

Paragraph and section gaps inside .page-body:

.page-body p,
.page-body ul,
.page-body ol {
  margin: 0 0 var(--stack-default) 0;  /* was 0.9em 0 — bottom-only gap reads cleaner */
}

.page-body h2 {
  margin-top: var(--stack-section);    /* was 2.25rem — tokenised */
  margin-bottom: var(--stack-cosy);    /* was 0.5rem  → 0.75rem */
  padding-bottom: var(--space-1);
}

.page-body h3 {
  margin-top: var(--stack-roomy);      /* was 1.75rem → 1.5rem  */
  margin-bottom: var(--stack-tight);
}

/* Section breathing — add max-width to article column and increase the
 * left/right rail gutter so the reading column isn't hard against the rails */
.mw-body {
  padding: var(--stack-section) var(--stack-roomy);  /* was 1.25rem 1.5rem 3rem 1.5rem */
}

5.4 Article max-width

Current --max-content-width: 76em is too wide at 17 px body type (≈ 1290 px / ~106 ch). Drop to 66em (≈ 1056 px / ~85 ch) for the article surface. Wikipedia uses ~60 em; institutional documentation benefits from slightly more for tables. Keep 76 em for home page two-col and three-rail layouts.

--max-content-width: 66em;       /* article reading column */
--max-shell-width:   76em;       /* home/index three-rail */

6. Shadow and elevation tokens

The DTCG bundle has no shadow primitives. Six different box-shadow values are hard-coded in style.css (0 4px 8px rgba(0,0,0,.12), 0 4px 12px rgba(0,0,0,0.15), 0 4px 16px rgba(0,0,0,0.15), 0 4px 24px rgba(0,0,0,0.07), 0 2px 6px rgba(0,0,0,0.15), 0 1px 4px rgba(0,0,0,0.08)). Consolidate.

Add primitive.shadow.* to the DTCG bundle (DTCG shadow type):

"shadow": {
  "$type": "shadow",
  "0": { "$value": "none" },
  "1": {
    "$value": { "color": "rgba(15,23,42,0.06)", "offsetX": "0", "offsetY": "1px", "blur": "2px", "spread": "0" },
    "$description": "Hairline lift — sticky header, inline chips"
  },
  "2": {
    "$value": { "color": "rgba(15,23,42,0.08)", "offsetX": "0", "offsetY": "2px", "blur": "6px", "spread": "0" },
    "$description": "Card resting — home tiles, infobox"
  },
  "3": {
    "$value": { "color": "rgba(15,23,42,0.10)", "offsetX": "0", "offsetY": "4px", "blur": "12px", "spread": "0" },
    "$description": "Hover lift, dropdown menus, autocomplete"
  },
  "4": {
    "$value": { "color": "rgba(15,23,42,0.14)", "offsetX": "0", "offsetY": "8px", "blur": "24px", "spread": "0" },
    "$description": "Hover-card, popover preview, modal trigger"
  },
  "5": {
    "$value": { "color": "rgba(15,23,42,0.20)", "offsetX": "0", "offsetY": "16px", "blur": "40px", "spread": "0" },
    "$description": "Modal, full-overlay dialog"
  },
  "focus-ring": {
    "$value": { "color": "{semantic.interactive.focus-ring}", "offsetX": "0", "offsetY": "0", "blur": "0", "spread": "3px" },
    "$description": "Replaces outline ring on inputs"
  }
}

And a semantic elevation tier in semantic.elevation.*:

--elevation-flat:    none;                             /* resting */
--elevation-raised:  var(--shadow-1);                  /* sticky header, tabs */
--elevation-card:    var(--shadow-2);                  /* cards, infobox, leapfrog */
--elevation-hover:   var(--shadow-3);                  /* hover-card, dropdown */
--elevation-popover: var(--shadow-4);                  /* preview hover, glossary tooltip */
--elevation-modal:   var(--shadow-5);                  /* dialog, shortcut overlay */

Then replace every box-shadow: 0 … in style.css with the semantic token. Dark theme overrides shadow color to rgba(0,0,0,0.45) and increases blur 1.5× to keep elevation perceptible on dark canvas.

7. Visual polish checklist

7.1 Hover states

Current pattern: links underline on hover, cards change border color to var(--link), buttons change background. No motion, no elevation change. Recommend a layered hover model.

/* Semantic hover tokens (new) */
--hover-overlay:  oklch(from var(--text-primary) l c h / 0.04);   /* 4% ink wash for surface hover */
--hover-overlay-strong: oklch(from var(--text-primary) l c h / 0.08);
--hover-lift-y:   -1px;  /* translateY for cards that elevate */
--hover-link-underline-thickness: 1.5px;
--hover-link-underline-offset:    0.2em;

Apply:

.wiki-home-cat-card,
.wiki-home-sister-link {
  transition:
    border-color   var(--motion-duration-base) var(--motion-easing-standard),
    box-shadow     var(--motion-duration-base) var(--motion-easing-standard),
    transform      var(--motion-duration-base) var(--motion-easing-standard);
}
.wiki-home-cat-card:hover,
.wiki-home-sister-link:hover {
  border-color: var(--interactive-link);
  box-shadow: var(--elevation-hover);
  transform: translateY(var(--hover-lift-y));
}

/* Article links — replace the universal underline-on-hover with offset underline always-on */
.page-body a {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.18em;
  text-decoration-color: color-mix(in srgb, var(--interactive-link) 40%, transparent);
  transition: text-decoration-color var(--motion-duration-base) var(--motion-easing-standard);
}
.page-body a:hover {
  text-decoration-color: var(--interactive-link);
  text-decoration-thickness: var(--hover-link-underline-thickness);
}

7.2 Focus rings

Current global :focus-visible outlines with 2px solid var(--link) at 2px offset. Functional but identical to hover treatment. Differentiate:

:focus-visible {
  outline: 2px solid var(--interactive-focus-ring);
  outline-offset: 3px;            /* was 2px — pulls ring clear of surface */
  border-radius: var(--radius-xs);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--interactive-focus-ring) 18%, transparent);
}

Add --interactive-focus-ring-bg: color-mix(in srgb, var(--interactive-focus-ring) 18%, transparent) as a semantic token so the same wash is reusable.

7.3 Transitions

Current motion tokens are durations only. Add semantic motion roles:

--motion-hover:     var(--motion-duration-fast) var(--motion-easing-standard);   /* 75ms */
--motion-press:     var(--motion-duration-instant);
--motion-reveal:    var(--motion-duration-base) var(--motion-easing-decelerate); /* 150ms */
--motion-dismiss:   var(--motion-duration-base) var(--motion-easing-accelerate);
--motion-emphasis:  var(--motion-duration-slow) var(--motion-easing-standard);   /* 300ms — once-only callouts */

/* The easing primitives are currently written as JSON arrays in tokens.css
 * (e.g. --motion-easing-standard: [0.2, 0, 0.38, 0.9];) — this is invalid
 * CSS. dtcg-to-css.py must emit cubic-bezier(0.2, 0, 0.38, 0.9) instead. */

Wrap state-change-bearing properties only — color, background-color, border-color, box-shadow, transform, opacity. Never blanket transition: all.

7.4 Status colors — calibration

Status backgrounds today are at ~94–96% lightness; status base colors are at ~50–55%. Functional, but they all read at the same emphasis level. Add an emphasis tier:

"status": {
  "success": {
    "base":     { "$value": "{primitive.color.positive-60}" },   // #26823f
    "emphasis": { "$value": "{primitive.color.positive-70}" },   // #16602b — for "verified" badges
    "border":   { "$value": "{primitive.color.positive-30}" },   // #9fdaae
    "bg":       { "$value": "{primitive.color.positive-10}" },   // #e8f6ed
    "on":       { "$value": "#ffffff" }
  },
  "warn": {
    "base":     { "$value": "{primitive.color.caution-60}" },    // #a87514
    "emphasis": { "$value": "{primitive.color.caution-70}" },    // #7a520a
    "border":   { "$value": "{primitive.color.caution-30}" },    // #f5cd7a
    "bg":       { "$value": "{primitive.color.caution-10}" },    // #fff5e1
    "on":       { "$value": "#ffffff" }
  },
  "error": {
    "base":     { "$value": "{primitive.color.critical-60}" },   // #a52323
    "emphasis": { "$value": "{primitive.color.critical-70}" },   // #7d1414
    "border":   { "$value": "{primitive.color.critical-30}" },   // #f0a3a3
    "bg":       { "$value": "{primitive.color.critical-10}" },   // #fceaea
    "on":       { "$value": "#ffffff" }
  },
  "info": {
    "base":     { "$value": "{primitive.color.brand-blue-60}" }, // #0f62fe
    "emphasis": { "$value": "{primitive.color.brand-blue-70}" }, // #0043ce
    "border":   { "$value": "{primitive.color.brand-blue-50}" }, // #edf5ff
    "bg":       { "$value": "{primitive.color.brand-blue-50}" },
    "on":       { "$value": "#ffffff" }
  }
}

Adds border and emphasis slots that don't exist today, removing the color-mix(... 30%, var(--bg)) improvisations scattered through style.css (login error, pending-btn-reject, quality badges).

7.5 Radius

Current scale: 0 / 2 / 4 / 8 px. Encyclopedia register correctly says "minimal rounding." Keep, but add a radius.pill: 9999px for status badges and the auth pending-count chip — the current chip uses a hard-coded border-radius: 10px which is neither token nor pill.

7.6 The lede accent (existing pattern, refine)

The article-lede left-border accent at 3px is a strong existing pattern. Upgrade:

#mw-content-text > .page-body > p:first-of-type {
  border-left: 4px solid var(--brand-emphasis);  /* was 3px, was --accent */
  padding-left: var(--stack-roomy);              /* was 1rem → 1.5rem */
  font-size: 1.125rem;                            /* was inherit — lede should be larger than body */
  line-height: var(--leading-loose);
  color: var(--text-primary);
}

This is a small change but it's the first thing a reader sees on every article. 4 px + brand-emphasis colour + larger size carries the institutional voice.

8. Accessibility audit of proposed changes

Contrast ratios for every recommended token pair, sRGB / WCAG 2.x.

8.1 PointSav theme

PairForegroundBackgroundRatioWCAG
Body--text-primary #0e0f12--surface-background #fafbfc19.4:1AAA
Secondary--text-secondary #4a4f59#fafbfc8.7:1AAA
Tertiary (new)--text-tertiary #666c78#fafbfc5.4:1AA
Link default--interactive-link #3366cc#fafbfc5.5:1AA
Link visited--interactive-link-visited #795cb2#fafbfc5.6:1AA
Link hover--interactive-link-hover #447ff5#fafbfc4.0:1AA Large only — restrict to ≥18 pt or non-text use
Accent (new)--accent #5176A3#fafbfc4.6:1AA
Accent emphasis (new)#2E5180#fafbfc8.9:1AAA
Brand emphasis--brand-emphasis #009d9a#fafbfc3.0:1UI only — non-text per WCAG 1.4.11; do not use for body text
Status success base#26823f#fafbfc5.0:1AA
Status warn base#a87514#fafbfc4.6:1AA
Status error base#a52323#fafbfc6.1:1AA
Status info base#0f62fe#fafbfc5.6:1AA
Border-strong#9aa1ad#fafbfc3.1:1AA non-text (3:1 required)
Border-subtle#e2e6ec#fafbfc1.3:1Decorative — not a UI-meaningful boundary

8.2 Woodfine theme

PairForegroundBackgroundRatioWCAG
Body--text-primary #111827#F7F9FA16.2:1AAA
Secondary#6B7280#F7F9FA5.3:1AA
Tertiary (new)#6B7280 (collapsed onto secondary)#F7F9FA5.3:1AA
Link default--interactive-link #164679#F7F9FA9.4:1AAA
Link visited#0F3258#F7F9FA12.6:1AAA
Link hover#1A5FA8#F7F9FA6.5:1AA
Accent-emphasis (new)#0A2E5A#F7F9FA12.9:1AAA
Brand-warm (new)#54924E#F7F9FA3.9:1AA Large only / non-text
On-blue text#FFFFFF#1646799.4:1AAA
Focus ring#164679#F7F9FA9.4:1AAA — focus needs only 3:1

8.3 Dark theme (PointSav [data-theme="dark"])

PairForegroundBackgroundRatioWCAG
Body#d5d5d5#1a1a1a11.2:1AAA
Muted#9ea3a7#1a1a1a6.5:1AA
Link#6794d0#1a1a1a6.0:1AA
Link hover#7facdf#1a1a1a7.4:1AAA
Visited#b786ff#1a1a1a7.0:1AAA
Accent (dark)#a0bcd0#1a1a1a8.6:1AAA
Danger#f47067#1a1a1a6.2:1AA

Dark theme passes throughout — no token changes needed beyond piping the new shadow/spacing/elevation semantic tokens through.

8.4 Defects to fix elsewhere

Three additional WCAG defects surfaced during this audit:

  • Login submit button (style.css:1892–1903) uses color: #fff on var(--link) #3366cc — passes at 4.5:1 (5.5:1), but the :hover { opacity: 0.88 } reduces effective contrast below 4.5:1. Replace with :hover { background: var(--interactive-link-hover) } — same hover semantics, no opacity-induced contrast loss.
  • pending-btn-accept uses --color-success #26823f on white at 5.0:1 — passes. Hover state color-mix(... 80%, #000) darkens to ~7.3:1 — fine.
  • density-btn-active (line 755) uses --accent as background and var(--bg) as foreground. With the recommended --accent: #5176A3 and --surface-background: #fafbfc, contrast is 4.6:1 — AA pass. Current #869FB9 on #f5f6f8 is 2.4:1 — fail. The accent recolour fixes this implicitly.

9. Implementation sequencing

Prioritised so each step is independently shippable and lifts a visible grade tier:

  1. WCAG fix — patch text.tertiary to neutral-60 in DTCG bundle; regenerate tokens.css. (One file, immediate AA compliance.)
  2. Color refinement — patch surface, accent, border tokens for both themes. Add --accent-emphasis, --brand-emphasis, --brand-warm.
  3. Shadow / elevation tokens — add DTCG shadow.* ramp + semantic elevation; replace inline box-shadow calls in style.css.
  4. Spacing semantic tier — add stack-*, inline-*, inset-* semantic tokens; refactor style.css to consume them in the busiest article and home rules.
  5. Type scale semantic tier — add --type-*-{size,line-height,weight,tracking} triples; rewire body, .page-body, and .page-body h1–h6.
  6. Self-hosted variable typography — convert woodfine-media-assets/fonts/ masters to woff2, ship from binary's /static/fonts/. Amend DS-ADR-07 to permit self-hosted OFL families. Add @font-face declarations to tokens-woodfine.css. Add Source Serif 4 to tokens.css PointSav stack.
  7. Hover / focus / motion polish — add --hover-*, --motion-* semantic tokens; rewire card hovers, focus rings, link underlines.

Step 1 alone moves the grade from C to C+ (fixes a hard blocker). Steps 1–4 move to B-. Step 6 (typography) is the single biggest visual lift; steps 1–6 land at A-. Step 7 is the polish layer that distinguishes A- from A.

Important Information

Design System disclosure

This site provides open-source design tokens, documentation, and self-hostable software published by Woodfine Capital Projects Inc. Information here is for general reference only and does not constitute an offer, warranty, or a guarantee of fitness for any particular purpose. Statements regarding planned, intended, or targeted future features are forward-looking and subject to change without notice; they are not undertaken to be updated except as required by law.