Portfolio · component library · v4 · 2026-04-12

Component library
+ gestalt critique

Post-audit, post-decisions. Royal + Violet Signal is locked. Accessibility fixes from the WCAG 2.1 AA audit are applied (ink-faint restricted to decorative, button labels use #FFFFFF, transcript role labels mandatory). This doc mocks every reusable component the portfolio will need, then critiques the library through gestalt and other visual principles, with a prioritised fix list.

Structure: foundations → content blocks → interactive → cards → artefacts → structural. Each component has a label, one-line purpose, a rendered demo, and a "uses" footnote naming the tokens it consumes. Critique section starts after the library.

Colour scales & type scale at a glance

01a Royal Tonal · 12 steps
Chrome + work register. Step 8 is the brand solid; steps 10–12 are text-on-dark variants; steps 1–7 are surface/background tones.
1
2
3
4
5
6
7
8
9
10
11
12
uses · --royal-1 → --royal-12
01b Violet Signal · 5 steps
Thinking/writing register. Only 8–12 exist (no chrome tones); violet never fills a background, only carries text, borders, or brand surfaces.
8
9
10
11
12
uses · --violet-8 → --violet-12
01c Signature gradient
Royal 8 → Violet 8 at 135°. Used only in hero CTA and structural divider per gradient placement rules.
uses · --grad-rv
01d Type scale
Fraunces (serif display) × Geist (sans body) × Geist Mono (code + metadata). Weights: Fraunces 400/500, Geist 400/500/600, Geist Mono 400/500.
Hero · 72/0.98
Shipping with care
Display · 48/1.1
Section heading
Case · 32/1.15
Case study title
H3 · 24/1.2
Subhead
Body · 17/1.72
Body prose as it appears in writing posts and case studies.
Small · 15/1.6
Card body, tldr text, secondary UI.
Meta · 12/1.55
build-log / meta rows / screenshot filenames
Kicker · 11/1.55
— SELECTED WORK · 01 / ESSAY · ARCHITECTURE
uses · Fraunces, Geist, Geist Mono

Landing hero

Exactly one hero per page. Uses the signature gradient in one place (CTA button). Optional keyword underline in --grad-rv-soft for a stronger signature move.

02 Hero block · with signature CTA & keyword
The opening move of the landing page. Gradient CTA + gradient underline on one keyword. Everything else is solid royal.
— Product thinker · shipping with AI

I design product decisions, then ship them with Claude Code.

SYDNEY · AVAILABLE FOR WORK · Q2 2026
Get in touch →
uses · --grad-rv · --grad-rv-soft · --button-fg · Fraunces 400

Buttons, links, tags

All button labels are 18px minimum (post-audit fix) and use #FFFFFF rather than --royal-12 / --violet-12. Ghost button is the low-emphasis variant for secondary actions.

03a Buttons · primary, thinking, ghost, signature
uses · --primary · --thinking · --button-fg (#FFFFFF) · --grad-rv
03b Inline link · royal & thinking variants

In the planner app, I learned to put paid API calls behind a single choke point so that budget caps and killswitches can't be bypassed. The full writeup is in the essay on defense in depth.

uses · --royal-10 · --violet-10
03c Tags · work (royal) & thinking (violet)
CLOUDFLARE TYPESCRIPT D1 ARCHITECTURE POSTMORTEM
uses · --tag-border / --thinking-tag-border

Case study, featured, writing

Three card variants. Case study (royal) is the default. Featured is case study + gradient border, at most one per landing. Writing card uses a violet left rail to signal the thinking register.

Selected work · 02

The Weekly App

Meal-kit POC pulling Coles recipes into a consolidated trolley. Static React/TS/Vite/Tailwind, Netlify deploy.

REACT VITE NETLIFY
Read case study →
Essay · architecture

Three guardrails that saved the spend budget

Shipping a paid-API product without a single "oh god what have I done" moment. Defense in depth, with real numbers.

ARCHITECTURE POSTMORTEM
Read the essay →
uses · --card · --grad-rv (border-image) · --violet-8 (left rail)

Headings, prose, meta row

05a Section headings (h2 & h3)
The insert-before-send pattern
Why the constraint runs before the vendor call
uses · Fraunces 500 · --ink
05b Body prose · with inline code

The idempotency layer turned out to be the most interesting one. The naive approach is to check "have I sent this already" before calling Twilio, but that leaves a race window: two concurrent invocations both see no prior send, both insert the log after, both call Twilio.

The fix is to invert the check: insert a row representing the intent to send first, with a UNIQUE constraint on (recipient, occurrence_id, date), then call Twilio.

uses · Geist 400/17px · --ink · --royal-11 inline code
05c Article meta row
Published 2026-04-10 Reading 6 min Tags architecture · spend · cloudflare
uses · Geist Mono 12px · --ink-dim label · --ink value

Ticker, divider

06a Build log ticker · live indicator + entries
Each entry is a single date + project + action row. The live dot in the header signals the most recent commit. Project names colour-code by register (royal for work, violet for thinking).
Build log · live
2026-04-12 portfolio — component library v4 committed
2026-04-11 design-system — chunk 3 emitter shipped
2026-04-10 writing — draft on defense-in-depth started
2026-04-10 planner-app — phase F scheduler, 111 tests passing
uses · --bg-subtle · --royal-10 (live dot) · --royal-11 / --violet-11 (project)
06b Signature gradient divider
Structural marker separating the work zone from the thinking zone on the landing page. Used exactly once per page.
Selected work
Case study cards go above this line.
Writing
Essay cards go below this line.
uses · linear-gradient(90deg, transparent → royal-8 → violet-8 → transparent)

Transcript, diagram, screenshot, code block

The four artefact types that live inside case study bodies. All share a figure caption, padding system, and break out of the 62ch text column.

07a Chat transcript · variant D2 (no left border)
Gradient container, role label in brand colour, typography split: Dylan = Geist sans, Claude = Geist Mono. Role label is mandatory per accessibility rules (WCAG 1.4.1). Typography split carries identity in greyscale and high-contrast mode.
Transcript · idempotency design 2026-04-09 · 4 turns
Dylan
The spend module needs idempotency or the whole thing is a liability. How do I make "send twice" structurally impossible rather than probabilistically unlikely?
Claude
Insert the row before calling Twilio. Put a UNIQUE constraint on (recipient, occurrence_id, date) and let the database reject the second write. On vendor failure after the insert, delete the row or mark it failed; never leave it in limbo.
Dylan
Does this need to be in a transaction?
Claude
No. The constraint does the work. A transaction would actually hurt: it holds the row lock across the Twilio call, which might take hundreds of milliseconds.
uses · --grad-rv border · --royal-11 / --violet-11 role · Geist / Geist Mono split
07b Diagram frame · 4-layer flow (with critical path)
Flow chart with one node highlighted violet (the critical path). Title uses serif italic to feel hand-drawn.
01 · KILLSWITCH boolean check 02 · BUDGET atomic increment 03 · INSERT UNIQUE constraint 04 · EXECUTE Twilio call defense in depth · order matters
Figure · four-layer spend protection
uses · --bg-subtle · --royal-8 / --violet-8 strokes · --royal-11 / --violet-11 text
07c Screenshot frame · terminal output
Per imagery standards: gradient outer, radius, padding, shadow. Inner is the raw screenshot content (here mocked with styled mono text).
audit-query.sh · planner-app
$ wrangler d1 execute planner --remote --command \ "SELECT outcome, COUNT(*) FROM sms_log WHERE date = '2026-04-08'" ┌──────────────────────┬──────────┐ │ outcome │ COUNT(*) │ ├──────────────────────┼──────────┤ sent 47 rejected_duplicate 47 └──────────────────────┴──────────┘ [ok] 47 sends, 47 rejections caught by UNIQUE constraint
Figure · audit query output
uses · outer gradient 145deg · --royal-1 inner · shadow 48px
07d Code block
Inline code is token sized. Block code uses mono 13px with syntax colours that align with the palette.
// Insert-before-send: UNIQUE constraint does the dedupe async function sendReminder(userId, occurrenceId) { const result = await db.prepare( `INSERT INTO sms_log (recipient, occurrence_id, date) VALUES (?, ?, ?)` ).bind(userId, occurrenceId, today()).run(); if (!result.success) return { ok: false, reason: 'duplicate' }; return twilio.send(...); }
uses · --royal-1 bg · --royal-10 keywords · --royal-11 strings · --ink-dim comments

Gestalt critique & what to improve

Each entry names a principle, diagnoses where the library succeeds or falls short against it, and gives a concrete action. Verdict tags: FIX = change before shipping; WATCH = monitor in context; KEEP = working as intended.

Proximitygroup what belongs together

Card kicker sits too close to title

In the case study card, the kicker ("Selected work · 01") has only 12px of space below it before the title. The kicker acts as a label for the title, so tight proximity is correct, but the tags sit 12px below the body paragraph as well, meaning tags and body are proximity-grouped with the title instead of reading as separate metadata. This collapses the card into one visual group instead of three (label, content, metadata).

Action: increase the gap above .tags to 20px (space-5), keep kicker→title at 12px. Creates three clear proximity groups: label / content / metadata.

FIX
Similaritylike things look alike

Writing card and case study card diverge too quietly

The writing card is distinguished only by a 3px left border and a violet kicker. In the 3-card grid, the three cards look almost identical at a glance; the writing card barely registers as a different kind of artefact. Similarity is working too hard — the cards look so alike that the meaningful difference (work vs thinking) disappears.

Action: widen the left rail on writing cards to 4px, and consider adding a small violet dot or label prefix ("ESSAY ·") that's typographically distinct from "Selected work ·". The kicker itself should feel different, not just be a different colour.

FIX
Figure / groundforeground must separate

Cards rely on border only; fill contrast is 1.20:1

The accessibility audit flagged that --card vs --bg is 1.20:1 — technically exempt from WCAG, but users with low vision will perceive the card as "a rectangle floating on a slightly different void". The border saves it, but the border is also low contrast (1.94:1). Together they work; either alone would fail to separate figure from ground.

Action: add a subtle inner shadow (or brighter top edge highlight) on card hover so the card lifts off the page on interaction. Static cards rely on border + fill together, which is OK but borderline. Do not remove either.

WATCH
Hierarchyclear reading order

Kicker (11px) is brighter than body (17px) — unusual inversion

The kicker uses --royal-11 #A5B4FF at 9.14:1 contrast; the body uses --ink-dim on card at 5.16:1. The small, decorative element is more luminous than the primary content. Classic hierarchy says bright = important; here, bright = label. This is defensible (the kicker is a wayfinding device) but unusual. It works because the kicker is physically small and uses small-caps; the eye reads it as an annotation, not a headline.

Action: keep as-is for now, but watch in user testing. If readers report confusion about what to read first, swap card body text to --ink (6.18+:1) on the card variants so body is brighter than kicker. For now, keep.

WATCH
Continuityeye follows smooth paths

Card arrow ("Read case study →") is orphaned from the title

In the card layout, the arrow sits at the bottom below tags, separated from the title it references by body text and tag pills. The eye path from title → arrow breaks across three intervening elements. The arrow is also the same size and weight as the body text; nothing draws the eye back to it after reading the body.

Action: either (a) attach the arrow directly to the card title so the whole title is clickable and has a trailing arrow inline, or (b) pull the arrow out as a distinct "read more" strip at the card foot with its own visual treatment (slightly indented, accent colour, maybe a subtle left divider line). Option (a) is simpler and more web-native.

FIX
Closuremind completes shapes

Transcript container's gradient border reads as "closed system"

The gradient border on the transcript creates a strong closure — the conversation feels contained, discrete, set apart from the surrounding prose. This is exactly right: transcripts are verbatim quotes and should feel bounded rather than mixed into the author voice. Closure is doing identity work here, not just decoration.

Action: keep. The gradient border is not decorative; it carries the closure principle. Confirm that the transcript always renders with the gradient border applied even in long-form case studies where other figures use plain frames.

KEEP
Rhythm / repetitionconsistent intervals

Vertical rhythm drifts between components

The spacing scale (--s1 to --s9) is well-defined, but I'm not always hitting it. The hero uses 48px between kicker and title; the card uses 12px between kicker and title. That ratio difference is correct (the hero is bigger) but the kicker-to-title gap as a fraction of the title size drifts between components. This is a rhythm crack.

Action: define a rule: kicker-to-title gap is always equal to the kicker's line-height × 1.5. Apply this everywhere a kicker precedes a serif display. Documents a relationship rather than a fixed pixel value.

FIX
Alignmentshared edges anchor the eye

Build log ticker date column is fixed-width — good, but edge alignment is off

The date column is 96px fixed, which creates a clean left-edge alignment for all dates and a consistent action column. However, the actions wrap and their left edge (column 2 start) does not align with anything else in the library — not the ticker title, not the live dot, not the bottom-padding edge. The ticker has a local alignment grid that doesn't connect to the surrounding layout.

Action: align the ticker title and live dot to the 96px date-column edge, so the vertical stroke runs title → date column → action column all sharing a left edge. Pulls the ticker into a single unified rhythm.

FIX
Scale & proportionrelative sizing creates hierarchy

Tag pills feel slightly cramped against card body

Tag pills are 11px mono with 2px × 8px padding. Under a 17px serif title and a 15px body paragraph, they read as the smallest element in the card — which is correct, they're tertiary. But the vertical padding (2px) is tight enough that descenders in lowercase mono characters almost touch the bottom border. Reads as "type trapped in a box".

Action: bump vertical padding on .tag from 2px to 3px. Trivial change, visible improvement in the "breathing" of the pill.

FIX
Contrast (visual, not colour)dynamics between elements

Palette is all-cool; no warm anchor anywhere in the system

Royal, violet, and the off-white ink are all cool. The palette has no warm relief — every surface, accent, and text colour sits in the blue/purple/cool-grey range. This is intentional (monochromatic discipline) but it means long reading sessions can feel "chilled" or clinical. There's nothing to anchor warmth, which is part of what makes a portfolio feel human.

Action: this is a deliberate trade-off, not a fix. The off-white #E6E2D9 is slightly warm (hinted toward cream), which is what's preventing the palette from reading as "sterile". Keep ink as-is, and resist the temptation to add a warm status colour just for relief. If reader warmth becomes a concern, address it through the writing voice rather than the colour.

KEEP
Signal-to-noisewhat earns its pixels

Meta row has three segments; most readers only care about one

"Published · Reading · Tags" is three distinct data points across the meta row. Published date is load-bearing (trust + freshness); reading time is nice-to-have; tags are redundant with the kicker and the card tags. Three equal-weight segments means none of them gets emphasis — the row reads as a strip of grey metadata rather than useful orientation.

Action: either (a) drop the tags from the meta row (the kicker and inline tags already do that job), or (b) weight the three segments so published date is brighter (--ink) and the others dim to --ink-dim. Option (a) is cleaner.

FIX
Focal pointwhere does the eye land first

Hero has no single clear focal point

The hero currently has four elements competing for first attention: the kicker, the large serif title, the gradient keyword underline within the title, and the gradient CTA button. The underline and the CTA both carry the gradient signature, so they visually echo each other — this is good (repetition creates pattern) but it fragments the focal moment. The eye doesn't land; it ping-pongs.

Action: pick one. Either (a) keep the gradient CTA and drop the keyword underline (CTA is the commitment; word highlight is decoration), or (b) keep the keyword underline and make the CTA solid royal (the title becomes the signature, the CTA becomes quiet). Option (b) is more sophisticated: it lets the title carry meaning and makes the CTA functional-not-decorative. Recommend (b).

FIX
Prägnanzsimplicity; the simplest reading wins

Button collection is one variant too many

Four button variants (primary, thinking, ghost, gradient signature) on display. In practice, the page only needs three: primary royal (everyday CTA), ghost (secondary action), and gradient signature (the one hero CTA). The thinking-violet button is tempting but it duplicates what a ghost button + a "Read the essay →" link already do. Adding it as a full button variant increases the decision surface without adding clarity.

Action: drop .btn-thinking from the component library. Writing pages use inline "Read the essay →" links and the gradient hero CTA. If a writing-specific full-button CTA ever appears, promote ghost + violet border as the pattern rather than a solid violet fill — violet solids compete with the identity of royal as "the" primary brand.

FIX

Prioritised fix list

  1. Hero focal point: drop gradient CTA or drop keyword underline (not both). Recommend keeping underline; CTA becomes solid royal.
  2. Card proximity: add 20px above tags so kicker/title, body, and tags form three clear groups instead of one.
  3. Card arrow continuity: inline the arrow with the title so the whole title reads as the clickable target, or break it out as a distinct foot element.
  4. Writing card differentiation: widen violet rail to 4px, add a typographically distinct kicker prefix ("ESSAY · " instead of just a colour change).
  5. Meta row signal: drop tags (redundant with kicker/tags), or dim everything except published date.
  6. Ticker alignment: align title and live dot to the 96px date-column edge.
  7. Kicker-to-title rhythm rule: define gap as kicker line-height × 1.5; apply everywhere.
  8. Tag breathing: bump vertical padding 2px → 3px.
  9. Drop .btn-thinking: three button variants, not four.

Nothing in the critique undermines the palette decisions or the overall structure. These are component-level fit-and-finish items that become much cheaper to fix now (before globals.css gets written) than after.