Changelog

4a5639f

status means "the page differs from your ask" — not "a note exists"

  • Same-day fix to the note model shipped with /compose.json. Two bugs, both of which punished exactly the behavior the surface is built to encourage.
  • statusOf was `notes.length ? degraded : ok` — it computed a severity field and then ignored it. So `?bogus=1` (an ignored param, nothing rendered differently) reported `degraded`, and so did a brand color deliberately rendered below AA. An agent optimizing for a clean status would rationally stop stating colors, which is the opposite of the hex freedom shipped hours earlier. `status` is now `degraded` only when a note is `warn`.
  • `contrast_below_bar` meant two opposite things. It fired at the page-chrome site, where the color WAS adjusted, and at the card-theme site, where it was NOT — so a consumer branching on it could not tell whether its hex survived. Split into `contrast_below_bar` (advisory, your hex rendered untouched, info) and `contrast_adjusted` (we moved it, warn).
  • Severity now answers one question everywhere: did the rendered page end up different from what was asked? warn = card_dropped, font_substituted, pairs_truncated, pairs_fallback, theme_fallback, hex_invalid, url_stripped, text_truncated, contrast_adjusted. info = param_ignored, value_clamped, slug_resolved, contrast_below_bar. text_truncated moved info -> warn (your headline got cut) and pairs_truncated was mixed across its two sites, now warn at both.
  • Net: the yoga and party URLs go back to `status: ok` with the contrast fact still attached as an advisory note. The information is preserved; the false verdict is gone.
Key files
  • lib/compose-notes.ts — statusOf keys off severity; contrast_adjusted added; the severity rule documented as one question
  • lib/card-themes.ts — the two contrast sites take different codes and severities
  • lib/compose-params.ts — text_truncated and pairs_truncated corrected to warn
  • lib/compose-resolve.test.ts — 6 new tests pinning ok-vs-degraded per case
  • public/agent.md — status documented as the differs-from-your-ask question, with an explicit 'do not avoid stating colors to keep this clean'
48e187f

/compose.json — the composed page as data, with colors that say where they came from

  • Swap `/compose?` for `/compose.json?`, keep every param identical, and get the resolved page as JSON. This closes the revision loop: a user says 'let's go with the second one' and the agent already holds that card's fonts, palette and tokens. Previously the payload existed only as visually-hidden HTML, so consuming it meant regex over markup while stepping around the Next RSC payload.
  • A mirror, not a scrape. New lib/compose-resolve.ts holds the single resolved model; the page and the JSON are two serializers over it, so they cannot drift. parseComposeParams was already ~90% of this — the extraction only had to absorb param normalization, the default-direction fallback, voice defaulting, and buildHandoff.
  • Color provenance is the load-bearing addition and the one thing HTML could not express. Every role in cards[].colors is { hex, source }: `stated` (the user wrote it — rendered exactly, contrast bar or not), `derived` (we computed it, so it was clamped), or `from:<role>` (title from fg, subtitle/label from accent, paragraph from muted). `source` names the origin, `hex` is what rendered, and they need not match — a from:accent subtitle is routinely darker because derived values get clamped and stated ones don't. This is what tells a developer which colors are the client's and must be pinned.
  • Fixed the themes[0] handoff defect: buildHandoff emitted deck-wide Tailwind/CSS tokens from the first card's palette only, so a three-direction page handed off one card's colors and silently misdescribed the other two. Token builders are extracted and now run per card as well as deck-wide.
  • Notes are structured. All 28 push sites across compose-params.ts and card-themes.ts now emit { code, severity, target, message } with the prose byte-identical, so the rendered page is unchanged and a consumer can branch on a stable token instead of regexing English. Codes ship WITH the endpoint rather than after it — adding them later would have been a breaking change to a published contract.
  • Cards are numbered 01/02/03 on the page. The revision loop is spoken ('the second one') and nothing carried a number, so the human's reference and the JSON's index were related only by counting. Cards also carry requestedIndex: if a card drops for an unknown slug, render position and the order the agent asked for diverge, and both are now reported.
  • Deliberate deviations from the draft spec: camelCase throughout (the existing /api routes are camelCase; two conventions on one surface makes agents guess wrong); content fields are always { text, source } rather than sometimes a bare string; contrast covers every text role rather than three, since subtitle — the accent-derived, clamped one — was the notable omission; no Accept negotiation, .json path only, so a CDN never has to Vary.
Key files
  • app/compose.json/route.ts — new; the serializer, with provenance, per-card contrast, and per-card tokens
  • lib/compose-resolve.ts — new; the shared resolved model behind both surfaces
  • lib/compose-notes.ts — new; Note type, codes, severities, statusOf/messagesOf
  • lib/compose-params.ts — structured notes; ThemeSpec carries stated roles; ComposePair.requestedIndex
  • lib/card-themes.ts — themeProvenance(); structured notes
  • lib/handoff.ts — extracted token builders, per-card tailwind/cssVars, requestedIndex
  • app/compose/page.tsx — consumes resolveCompose; renders note.message with data-code/data-severity
  • app/components/ComposeCard.tsx — the 01/02/03 ordinal
  • next.config.ts — /compose.json gets the same one-year cache as /compose
  • lib/compose-resolve.test.ts — new; 14 tests on provenance, per-card palettes, note codes, requestedIndex
  • public/agent.md, public/llms.txt, app/page.tsx — the handoff now points at /compose.json
7739979

Every painted slot takes a hex; omitted ones come from taste, not arithmetic

  • A composed page came back flat: the subtitle and the paragraph rendered the same dull grey, and an explicitly chosen accent did no visible work. Three separate causes, all fixed.
  • The subtitle and paragraph were identical BY CONSTRUCTION — neither was ever derived. ComposeCard painted both from `theme.muted` via `?? theme.muted`, so omitting either guaranteed they matched. The CardTheme doc comment already named this as a known defect.
  • `muted` itself was `mix(fg, bg, 0.42)`. Blending the ink into the field is an average, and averages trend to grey; no coefficient fixes that. It now borrows from the curated palette nearest the background — a designer's actual choice — the same way `accent` already did.
  • A stated hex did not survive: `accent:EF5DA8` shipped as `E358A0`, silently walked toward black until it cleared 4.5:1. Contrast handling now splits by who chose the color. A hex an agent wrote down is an intention (a brand color, a palette from a reference) and renders exactly, with a note if it sits under the bar; a value we derived carries no intention and is still clamped. The tradeoff is real — a fully-specified palette can now be made unreadable — and the note is how the contract stays honest instead of silently substituting.
  • Every painted element is now addressable: `rule` (the hairline above the font-name line) and `label` (the line itself) join the existing roles, so nothing on the card is off-limits to a hex. New `resolveTheme` fills whatever is unstated and is applied to EVERY card theme, curated ones included, so `theme=3` gets the same treatment as a hand-written palette. Dominant text roles come from curated relationships; ancillary chrome derives off the accent, which is where derivation is genuinely fine.
  • Net effect: `bg` + `fg` + `accent` — the three an agent actually knows — now produce a page with a point of view rather than a neutral box with one colored word in it.
  • The color key follows the cards. With per-card `themes=` it was flattening three palettes into a single row, leaving the reader to work out which swatch belonged to which card; each palette now sits in a column under its card, sharing the card row's flex sizing. A single shared palette has nothing to align to, so it stays a left-aligned strip of fixed chips rather than stretching five swatches to card width.
  • Nudges agents toward `themes=` (plural) when showing more than one direction — the run that prompted this used `theme=` singular for a three-option request, which makes three options look like one option typeset three ways.
Key files
  • lib/card-themes.ts — ResolvedCardTheme + resolveTheme; curated `muted` default; contrast split by stated-vs-derived; `rule`/`label` roles
  • lib/compose-params.ts — resolveTheme applied to every card theme; `rule`/`label` in THEME_ROLES
  • app/components/ComposeCard.tsx — paints resolved slots directly, no per-use-site fallback
  • app/components/ComposeColorKey.tsx — a column per card under the cards; left-aligned chips when one palette is shared
  • lib/handoff.ts — carries the new slots as CSS vars and Tailwind tokens
  • lib/compose-params.test.ts — 4 new tests: distinct subtitle/paragraph, honored-exact hex, derived still clamped, ancillary slots
  • public/agent.md — role table, what-you-state-renders, what-you-omit-comes-from-taste
  • app/page.tsx — quickstart: full role list, honored-exact note, reach-for-themes nudge
f1806bc

Near-miss font slugs resolve; the surface stops asking agents to verify

  • An evaluation run showed /compose working but over-processed: asked for 'three cards for a kid's party', the agent spent several sequential round-trips (query /api/fonts, query /api/pairings, fetch the composed page back to read #agent-notes) before emitting a URL. Asked afterward to hand-write one from the grammar, it produced an equally good result in zero tool calls. This closes the gap between those two runs.
  • New lib/font-match.ts: a slug an agent guessed from memory now resolves to the nearest real family instead of dropping its card. Four ordered tiers — exact, normalized (`Playfair_Display`), prefix expansion for a missing version suffix (`source-serif` → Source Serif 4), then Levenshtein ≤ 2 gated on a 25%-of-input ratio and a 5-character floor. The guard is as load-bearing as the match: `helvetica` still resolves to nothing, because a matcher that always finds something turns a wrong guess into a silently wrong specimen. Every substitution is named in the render notes, and the canonical URL already re-emitted resolved slugs, so a corrected URL comes back for free.
  • This is the real fix for latency. An agent's verification round-trip was a rational response to genuine residual risk — 'guessing is safe' covered params but not slugs. Removing the risk removes the round-trip; telling the agent not to worry would not have.
  • One verification stance everywhere, resolving a contradiction an agent had to arbitrate: the root quickstart said 'Do NOT fetch the URL back', agent.md's worked example said 'Fetch that URL back, confirm data-status=ok'. Fetch-back is now documented as a debugging tool, never a step. The honest exception is stated rather than papered over — #agent-specs only exists on the composed page, so a user who is going to *build* the direction does cost one fetch, for the payload, not to check the work.
  • agent.md gains a front-loaded 'If you read nothing else' block: the fill-in URL (byte-identical to the root quickstart's), the full param table, the slug rule, and the guessing-is-safe guarantee — enough to emit correctly from the first screen alone. The JSON endpoints are reframed as worth a round-trip only for data the agent can't recall (feeling tags, curated pairings, real axis ranges).
  • Fixed: `?strict=1` carried over from an /api route no longer produces a spurious 'is not a compose parameter' note on /compose (it's inert there by design). Removed `kind` from /api/pairings, which was accepted and echoed in `applied` but filtered nothing and was documented nowhere.
Key files
  • lib/font-match.ts — new; matchSlug + a ceiling-abandoning Levenshtein, self-contained (depends only on lib/slug.ts)
  • lib/font-match.test.ts — new; 7 tests, half of them asserting what must NOT match
  • lib/font-index.ts — the resolver closure falls through to matchSlug on a miss
  • lib/compose-params.ts — noteNearMatch reports substitutions; `strict` added to KNOWN_PARAMS
  • public/agent.md — one-shot block up top, verify stance rewritten, API round-trips reframed, near-miss added to 'Nothing here fails'
  • app/page.tsx — quickstart: guessing-is-safe covers slugs, APIs demoted to 'only when you need data you can't recall'
  • public/llms.txt — compose entry: nothing to check
  • app/api/pairings/route.ts — dropped the no-op `kind` param
  • docs/agent-story.md — friction #7, the round-trip tax, and the generalizable lesson
  • docs/plans/agent-surface-v1.md — acceptance step 4 marked superseded
467f8a4

Per-card palettes: three distinct looks in one /compose URL

  • New `themes=` param (plural): a ;-separated list of palettes, one per card, so a single URL can show three cards with three distinct color looks — the reaction-loop the product is built around ('love the red one, warm up the first'). Each item uses the exact same grammar as `theme` (a curated index or named bg:/fg:/accent: roles). One item applies to all cards; N map by card index; fewer-than-cards cycle; an unusable item falls back to a curated palette with a note. `themes` wins over `theme`/`mood`.
  • The render layer already indexed a themes array per card (ComposeCard, ComposeColorKey which de-dupes distinct palettes, and buildHandoff), so this was a parser-only change — parseOneTheme was extracted as the shared unit behind both single `theme=` and each `themes=` item.
  • Contract clarity, no behavior change: agent.md now states the 'two facets per element' model plainly (title/subtitle/paragraph each have a text facet as a top-level param and a color facet as a role inside theme=/themes= — not a collision), reframes #agent-notes fetch-back as optional (sandboxed single-fetch agents can't do it and don't need to), and explicitly blesses guess-and-degrade (an unknown param is ignored and the page still renders, so guessing beats dropping intent).
  • The root quickstart gains a 'three distinct looks' worked example (a comic-book birthday party in three palettes) and a themes= reference entry.
  • Added docs/agent-story.md — the real cold-agent 'three birthday-invite looks' run that drove this work, kept as the rationale behind the surface.
Key files
  • lib/compose-params.ts — parseOneTheme (shared), parseThemesList (per-card), themes precedence + canonicalization; `themes` added to KNOWN_PARAMS
  • lib/compose-params.test.ts — 8 new tests: apply-to-all, by-index, cycle, derive, fallback, precedence, canonical round-trip
  • public/agent.md — themes row + Per-card palettes section, two-facets model, optional fetch-back, guess-and-degrade blessing
  • app/page.tsx — quickstart: themes= reference, three-looks example, guess-safe line
  • docs/agent-story.md — the cold-agent friction log and acceptance test
  • CLAUDE.md — feature-docs row pointing at docs/agent-story.md
fa1f7a3

Fill-in template: the agent edits a finished URL, the human just says 'give me ideas'

  • The root quickstart now leads with a single fully-formed /compose URL — every parameter filled with a real, working value. The instruction to the agent is just 'change the values to match your user, delete what you don't need, hand it over.' Editing a finished URL is far more reliable for a weak agent than composing one from grammar, and it's the floor: even a swap-the-copy-and-colors pass yields a working page.
  • The human never touches a URL. They point at the domain and say 'give me ideas'; the finished template lives in the response for the agent to adapt.
  • The ceiling, stated right after the floor: don't reuse the default fonts — query /api/fonts?feeling=calm (20 feelings) for candidates and /api/pairings?font=<slug> for a display partner, each carrying a ready-to-paste composeValue, then drop it into pairs=. So a capable agent researches real faces that fit the request; a limited one still succeeds by editing the template.
  • The parameter list is demoted to reference ('only if you need to hand-tune a value') — it's no longer the primary path.
Key files
  • app/page.tsx — the AgentQuickstart now leads with the fully-formed fill-in URL and the /api enrichment note; grammar demoted to reference
  • content/changelog.json — this entry
5b779e5

The one-fetch fix: the agent contract now lives in the root response

  • The bug: a coding agent that could fetch /agent.md composed links fine, but a plain chat agent given only the site URL failed cold — it saw the 'Agent Ready' badge, couldn't make the second hop to the grammar, and gave up. The badge promised a capability the first (and often only) reliable fetch didn't deliver.
  • The fix: the whole construct-a-URL contract is now inlined into the home page's server-rendered HTML — the /compose grammar, the real param names (title/subtitle/paragraph, not body), the slug rule, a dark starting palette, a worked example URL, and an explicit 'hand it to the user, don't fetch it back, nothing here fails' instruction. One fetch of the bare domain is now enough; /agent.md and /llms.txt stay as enrichment for agents that can follow a link, never the critical path.
  • Every route (not just the home page) now carries a short agent pointer in its markup, so whatever URL a user pastes, the agent learns the contract exists and where the full quickstart is.
  • Both blocks are present in the DOM as real, text-extractable content but aria-hidden and visually clipped — silent to screen readers, invisible on screen, and surviving the HTML-to-text conversion most fetch tools apply. This replaces the old sr-only pointer, which was both incomplete (a prose 'go fetch /agent.md' with no grammar) and read aloud to screen-reader users who wanted fonts, not a css2 URL.
Key files
  • app/page.tsx — replaced the thin sr-only /agent.md pointer with a full, self-contained AgentQuickstart block inlined into the root response
  • app/layout.tsx — a site-wide, text-extractable agent pointer on every route
  • content/changelog.json — this entry
c4c710c

Agent surface V1: the site is now usable by agents, not just readable

  • A cold agent can discover the whole contract in one fetch: /llms.txt indexes /agent.md, which carries the query surface, the /compose grammar, the palettes with hexes, and a worked example. Plus /robots.txt and a generated sitemap covering every pairing page.
  • The JSON APIs got honest. Unknown query params are still ignored (a stale URL shouldn't strand a human on an empty page), but every response now returns an `ignored` array naming them and an `applied` object echoing the filters that actually ran — so a guessed param name no longer looks identical to a broken feature. `?strict=1` opts into a 400 instead.
  • The feelings filter is documented and reachable as `feeling` as well as its original `tag`, and takes a `minWeight` threshold to narrow a tag to fonts that genuinely lean that way.
  • Two new endpoints: /api/palettes (the 11 curated palettes with computed WCAG contrast ratios and the four moods) and /api/pairings (curated + algorithmic partners, each with a ready-to-paste composeValue).
  • New /compose route: an agent writes a plain-query-param URL and gets a curated, server-rendered page — up to four pairing cards, a curated palette or bring-your-own hex, optional copy, and coarse dials for size, density, weight and measure. No valid URL produces an ugly page; every malformed one degrades and reports itself at #agent-notes rather than failing.
  • Every composed page carries a paste-ready implementation block: the css2 link and @import, a Tailwind v4 @theme block, CSS custom properties, and per-face stacks, weights and variable-axis ranges — all derived from the same catalog metadata that drove the render.
  • The composed page became a design surface rather than a report: no site nav or footer, no page header, and the render notes and implementation config moved into a visually-hidden block (still in the DOM as real text, so agents fetching the page still get them — HTML comments would have been stripped by the markdown conversion most fetch tools apply).
  • Agents now control the whole viewport, not just the cards. A new `page` param sets the field behind them; omitted, it derives from the card theme, so a light composition gets a light page and a dark one a near-black. The `for` param finally has a home as the single small line above the cards.
  • Per-element color overrides: `theme=title:…,subtitle:…,paragraph:…` each override one text element and fall back to the palette roles. Previously `muted` carried both the subtitle and the paragraph, so there was no way to color the deck without dragging the body copy along.
  • A color key below the cards shows every specified color as a labeled swatch with its hex — including the page field. It exists for the person, not the agent: it gives them the vocabulary to revise with ("make A32B25 darker" rather than "the red's too bright").
  • Cards are centered at any count and size themselves to it: one renders wide as the direction being refined, two or more share the width evenly, converging on the old fixed 22rem at four. The shared browse `Grid` used `auto-fill`, which left one or two cards stranded in the leftmost track.
  • An 'Agent-ready' note on the Fonts page announces the capability to humans — the contract that powers it is machine-readable and therefore invisible from the UI.
Key files
  • lib/compose-params.ts — the /compose grammar: parse, validate, clamp, default, canonicalize, and collect degradation notes (pure, unit-tested)
  • lib/color.ts — hex parsing and WCAG luminance/contrast math, palette-agnostic
  • lib/card-themes.ts — MOODS (ordered subsets of CARD_THEMES) and completeTheme(), which derives and contrast-checks the roles an agent omits
  • lib/handoff.ts — the implementation block, built from catalog metadata via lib/css2-url.ts
  • lib/font-stack.ts — fontStack/fallbackFor split out of the "use client" font-loader so server components can call them
  • lib/api-params.ts — the ignored[] / ?strict=1 contract shared by the JSON routes
  • lib/font-index.ts — memoized slug→family lookup over the catalog
  • app/compose/page.tsx — the server-rendered compose route, notes block and specs block
  • app/components/ComposeCard.tsx — the one template; deliberately not SpecimenCard, so a viewer's saved voice/theme can't leak into a page composed for someone else
  • app/api/palettes/route.ts, app/api/pairings/route.ts — new endpoints
  • app/api/fonts/route.ts — ignored[]/applied, feeling alias, minWeight
  • public/llms.txt, public/agent.md, public/robots.txt, app/sitemap.ts — discovery
  • app/components/ComposeColorKey.tsx — the swatch key beneath a composed page
  • app/components/AgentInvitation.tsx — the human-facing 'agent-ready' note on Fonts
  • app/components/SiteChrome.tsx — hides global nav/footer on /compose
  • public/agent.md — contract updated for `page`, the per-element color roles, the hidden payload location, and the variable-axis ceiling caveat
ca11b2f

Visual category selector on Fonts, plus a grid breakpoint fix

  • The Fonts category filter is now visual: a row of tiles above the cards, each showing a big 'Aa' in a font representative of that style (Playfair Display for Serif, Space Grotesk for Sans, Anton for Display, Space Mono for Mono, Pacifico for Script; All in the UI sans). The selected tile inverts to dark-on-light.
  • The search box moved to the far right and gained a magnifying-glass icon; the old text pills are gone.
  • Fixed a layout gap: between roughly 640 and 784px the grid showed a single narrow card stranded in the middle. It now stays a full-width single card until two cards genuinely fit.
  • The Colors page now opens on the Swatches view by default.
Key files
  • app/components/CategoryTiles.tsx — the Aa specimen tiles (representative fonts in a single TILES constant)
  • app/components/BrowseView.tsx — search moved right + icon, pills replaced by CategoryTiles
  • app/components/ui/Grid.tsx — switch to 22rem columns at min-[49rem] (when two fit), not sm
  • app/components/ColorsView.tsx — default to the Swatches view
305ce07

Pairing cards: paragraph support, always-on fonts, and pivot to a partner

  • Pairing cards now include the paragraph too (in the text font) when you turn paragraph on in the gear — so the visibility setting reaches pairings, not just single fonts.
  • A pairing always shows both of its fonts: the title and subtitle stay visible on pairing cards no matter your hide settings, since hiding one would hide half the pairing. Only the paragraph is optional there.
  • Click the partner font's name in a pairing's footer to open its own pairings in a new tab — so you can branch off to explore it without losing your place. The font you're already viewing isn't a link; on the home and favorites cards both names link.
Key files
  • app/components/PairingCard.tsx — paragraph passthrough, pairing flag, partner-name links (new tab)
  • app/components/SpecimenCard.tsx — `pairing` forces title+subtitle visible; only paragraph follows visibility
  • app/components/PairingsView.tsx — passes the source font so only the partner links
  • app/components/SuggestedPairings.tsx + FavoritesView.tsx — paragraph override threaded through
990bc89

Show/hide title, subtitle, and paragraph on cards from the gear

  • The voice editor (the gear) now has an eyeball next to each element — title, subtitle, paragraph — to show or hide it on every card across the app.
  • Cards now show just the title and subtitle by default; the longer paragraph is hidden until you turn it on (a cleaner, more compact default).
  • At least one element always stays visible — the last one's eyeball is disabled so a card can never be blank.
  • Your choice is remembered across visits.
Key files
  • app/components/VoiceProvider.tsx — global, persisted visibility state + constrained toggle
  • app/components/TypographicVoiceModal.tsx — per-element eyeball toggles in the gear
  • app/components/SpecimenCard.tsx — every card honors the visibility preference
  • lib/types.ts — VoiceVisibility
c01964d

Discover fonts by feeling — clickable tag pills on every card

  • Every font card now shows its Google 'feeling' tags (Cute, Playful, Futuristic, Sophisticated…) as small pills beneath the name.
  • Click a feeling to focus the Fonts grid on fonts that share it, strongest match first — browse by mood instead of by name.
  • The focused view is a shareable URL (e.g. /?tag=cute) with an active-feeling chip you can clear; browser back/forward and direct links work.
  • These tags come from Google Fonts' own semantic data, now refreshed into the catalog — 99% of families are tagged.
Key files
  • scripts/build-catalog.mjs — fetches FAMILY_TAGS, writes per-font feelings into data/fonts.json
  • lib/feelings.ts — the 20 /Expressive moods + slug/label helpers
  • app/api/fonts/route.ts — ?tag filter, sorted by feeling strength
  • app/components/BrowseView.tsx — URL-driven feeling filter + active-feeling chip
  • app/components/FontSpecimenCard.tsx — feeling pills on each card
  • app/page.tsx — Suspense boundary so the URL filter keeps the route static
f7e9ee7

Customizable page-chrome colors, and the card-theme toggle in the gear

  • You can now recolor the whole app's chrome — the shell behind the cards, the nav and footer, and the active-control highlight. The Colors page has a swatch editor under Page chrome with a color picker per role and a Restore defaults button; changes apply site-wide instantly and persist.
  • The Randomize / Use selected theme switch is now also in the gear menu (it has an app-wide effect, so it's reachable from anywhere). The gear and the Colors page share one setting and stay in sync; pick which theme on the Colors page.
  • Page-chrome colors now flow through CSS variables, so a single edit repaints the entire shell at once. Defaults are unchanged — nothing looks different until you customize.
Key files
  • lib/card-themes.ts — PAGE_THEME/HIGHLIGHT now reference --page-* CSS vars; literals kept as *_DEFAULT + PAGE_CHROME_DEFAULTS for the editor
  • app/globals.css — --page-bg/-fg/-muted/-accent/-highlight defaults; body grounds on --page-bg
  • app/components/CardThemeProvider.tsx — per-role page-chrome overrides written to the document root and persisted
  • app/components/ColorsView.tsx — PageChromeEditor (color picker per role + Restore defaults)
  • app/components/TypographicVoiceModal.tsx — Card color (Randomize / Use selected theme) section in the gear
3890452

Colors page: preview, swatch view, and a pick-a-theme-for-everything setting

  • Added a Colors page (in the nav) that shows every card color theme. It opens as a split panel like the pairings page: a sample font card on the left, the themes on the right — click a theme and it paints onto the sample.
  • A Preview / Swatches toggle flips the theme cards between full sample cards and tight square-swatch strips for scanning the palette at a glance.
  • You can now pick one theme and apply it to every card across the whole app. A Randomize / Use selected theme toggle under the sample card decides whether cards cycle through all themes (the default) or all use your chosen one.
  • Your chosen theme now persists — it survives a refresh and is remembered across the app, instead of resetting each visit.
  • The page also shows the fixed page-chrome colors (the shell behind the grid and the active-control highlight).
Key files
  • app/colors/page.tsx — the /colors route
  • app/components/ColorsView.tsx — split-panel preview, Preview/Swatches toggle, Randomize/Use-selected toggle
  • app/components/CardThemeProvider.tsx — global card-color preference (selected theme + apply mode), persisted to localStorage
  • app/components/SpecimenCard.tsx — every card reads the preference: one selected theme app-wide, or cycle as before
  • app/components/GlobalNav.tsx — Colors nav item
  • app/layout.tsx — wraps the app in CardThemeProvider
477476d

Split-panel pairings page and scroll memory on the way back

  • Redesigned the per-font pairings page: the font you picked now sits on its own to the left as a full specimen, with its partners laid out in their own grid to the right — so the pairing relationship reads at a glance.
  • The selected font stays put (sticky) as you scroll its partners, keeping the comparison anchored.
  • Gave the pairings page room to breathe — it now spreads to the full width of the window so more partners show at once, instead of the four-across measure the rest of the app uses.
  • Returning from a pairings page now drops you right back where you were on the Fonts grid — your search, category, how many fonts you'd loaded, and your scroll position are all remembered, instead of resetting to the top.
Key files
  • app/components/PairingsView.tsx — two-column split layout, sticky source card, scroll-to-top on open
  • app/pairings/[slug]/page.tsx — full-bleed page, back-caret gutter guard, scroll={false} on the back link
  • app/components/BrowseView.tsx — per-tab snapshot restores filters, loaded count, and scroll on return
  • app/components/FontSpecimenCard.tsx — Get Pairings link opts out of scroll-to-top so the browse position survives
5ea4f53

Bookmarkable pairings, a Fonts-first nav, and backlog status

  • Opening a font's pairings now changes the URL instead of a pop-up, so any pairing can be bookmarked and shared — every font has its own /pairings/{font} page.
  • Renamed and reordered the nav: the font browser is now the home page under "Fonts", the old home page of suggested pairings moved under "Pairings", and Favorites is unchanged.
  • Cleaned up the pairings page — one "Pairings for {Font}" heading lined up with the cards, all pairings shown as a single set, and a round back button tucked into the left margin opposite the voice gear.
  • Gave the backlog an open/closed view: two pills toggle between active ideas and shipped ones, with closed items set apart in blue.
  • Marked "Bookmarkable pairing routes" as shipped — the first closed backlog item.
Key files
  • app/pairings/[slug]/page.tsx — per-font pairings page (SSG, one per font)
  • app/pairings/page.tsx — the suggested-pairings showcase (was the home page)
  • app/page.tsx — root is now the Fonts browser; /explorer redirects here
  • app/components/PairingsView.tsx — shared pairings grid (replaced the modal)
  • lib/slug.ts — font-name <-> URL-slug, the single source of truth
  • app/backlog/BacklogList.tsx — Open/Closed pills and the closed (blue) card theme
52e9f0e

Unified page header, neutral palette, and a footer

  • Unified every view's header into one element — the title sits in the same place and style on Home, Explorer, Favorites, and Changelog; the eyebrow and descriptive sub-text were dropped for a cleaner read.
  • Folded the Explorer search and category filters into that shared header, and removed the sort options — results default to most popular.
  • Made page headers line up with the cards at every width, and the changelog header line up with its narrower card column.
  • Repainted the whole site in a neutral near-black, with the nav blending into the page.
  • Settled on a single amber highlight for active controls (filter pills, the voice gear), keeping the red as a small accent.
  • Added a global footer with the license and a GitHub link — it sits below the content, or at the bottom of short pages.
  • Gave the light voice pop-up bold labels and trimmed its heading.
Key files
  • app/components/ui/PageHeader.tsx — the one shared page header
  • app/components/ui/GridAlign.tsx — pins headers to the centered card block
  • app/components/Footer.tsx + app/layout.tsx — the global footer and sticky-footer layout
  • app/components/BrowseView.tsx — Explorer in the unified layout (filters in header, no sort)
  • lib/card-themes.ts — neutral PAGE_THEME background + the HIGHLIGHT accent
  • app/components/TypographicVoiceModal.tsx — bold light-mode labels
6a66e3e

Global typographic voice, unified cards, and this changelog

  • Made the typographic voice global: one editor pop-up, opened from a gear at the right of the nav, now drives Explorer specimens, Home and favorite pairings alike — replacing the two separate editors that had drifted apart.
  • Reworked that editor into a light-mode pop-up with stacked, auto-growing fields, reachable from any page and any scroll depth.
  • Unified every card surface (Explorer, Home, pairings, favorites) onto one card component and one grid — a single fluid column on small screens, then fixed-width cards instead of stretching.
  • Added this changelog at /changelog — a card per dated entry — plus the /changes command and README notes for keeping it current.
Key files
  • app/components/VoiceProvider.tsx — global voice state + the single editor pop-up
  • app/components/SpecimenCard.tsx — the shared card; FontSpecimenCard / PairingCard wrap it
  • app/components/ChangelogCard.tsx + app/changelog/page.tsx — the changelog surface
  • app/globals.css — the theme-light inverted-surface convention