Investment recovers by case study 13.
Find it. Try it. Build with it.
The components behind this site, with working examples, anatomy and guidance for when to use each one. Search by a component name or the problem you need to solve.
The same system, from portfolio to protocol.
The public AI readiness page shares this site's base template and stylesheets. Its research-specific layouts compose these existing elements.
Foundations and actions
Aeonik and Geist Mono, semantic color and spacing tokens, light and dark themes, primary and secondary buttons, links, and tags.
Inspect button componentsPage and content patterns
Shared header and footer, page hero, section headings, CTA pairs, archetype cards and persona-grid components. On the protocol page, persona cards describe starting roles, not validated research personas.
Inspect the persona gridShip a component in 30 seconds.
Four patterns for the most common cases. The CSS is loaded on every page, copy, paste, ship.
<button class="button button-primary" type="button">
Submit
</button>
Full button doc
- Sales workflow
- B2B SaaS
- Featured
<ul class="m-tag-cluster">
<li class="tag">Sales workflow</li>
<li class="tag">B2B SaaS</li>
<li class="tag tag-accent">Featured</li>
</ul>
Full tag doc
Selected work.
<section class="o-hero o-hero--page">
<span class="o-hero-page-eyebrow">Work</span>
<h1 class="o-hero-page-title">Selected work.</h1>
</section>
Full hero doc
<article class="m-callout">
<svg class="m-callout-icon">…</svg>
<div class="m-callout-body">…</div>
</article>
Full callout doc
Need something else? Press ⌘+K to search the whole system.
A system is an investment, not a hobby.
Every choice on this site, every button, every hero, every case-study layout, comes from one tokenized source. That's expensive up front and saves time forever after. Honest accounting:
Each page invented itself.
- 12 case studies × bespoke styles ≈ ~48 hours of one-off CSS
- 4 different button styles drifted across the site
- 3 incompatible card layouts on /portfolio alone
- New visitor had to read source to understand structure
One system. Twelve documented components.
- One token system, three-tier (primitive → semantic → component)
- 12 documented components + 5 templates + 22 behavior hooks
- New case study page: ~10 min from data file → live URL
- Hiring managers can audit every decision via
/design-system
Up-front cost: ~80 hours building the tokenized system + documentation. Ongoing: ~2 hr/month maintenance (audit, deprecation, doc updates). Saved per future case study: ~6 hr. Break-even at ~13 future case studies; portfolio plans 5+ per year. Investment recovers in year one.
How Plot shows up in real work.
Every other page on this site consumes Plot. Below: a sample of what each route uses. Numbers are instance counts at v1.2.
| Route | Template | Components consumed |
|---|---|---|
| / | Identity hero | .o-hero--home · 5× .o-card-archetype · .o-status-pill · .m-cta-pair · .o-cta |
| /portfolio | Filtered index | .o-hero--page · .m-filter-row + 6× .m-chip · 15× .o-card-work · ~85 .tag |
| /portfolio/multi-role-analytics | Case-study reading | .o-hero--case · 6× .o-process · 3× .m-metric · 4× .o-artifact-grid items · .m-link-card · .o-cta |
| /portfolio/voice-ai-tmobile | Case-study reading | .o-hero--case · 7× .o-process · .m-callout--accent (confidential note) · 3× .m-metric · 5× .tag |
| /about | Utility page | .o-hero--page · 9× .m-career-entry · .m-eyebrow-title × N · .o-cta |
| /contact | Utility page | .o-hero--page · .o-contact-grid · 4× .m-field · .button button-accent |
| /404 | Utility page | .o-hero--page · .m-empty-state · .m-cta-pair |
Click any route to see Plot in production.
Three-tier tokens. Semantic is the canon.
Components reference semantic tokens (Tier 2) like --color-action. Semantic tokens reference primitives (Tier 1) like --amber-500. Component-specific tokens (Tier 3) like --button-radius live alongside the component CSS. New code always reaches for the semantic name.
Semantic tokens, what to use
Intent-named. Every component, atom, and template should reach for these names. They survive primitive rebrands.
Surface roles
Text roles
Border roles
Action / accent roles
How a button reads tokens, primitive → semantic → component
Every Tier 3 component token resolves through Tier 2 semantic to a Tier 1 primitive. Change the primitive, the chain updates everywhere automatically. Change the semantic, only the components mapped to that intent move.
-
Tier 1 · Primitive
--r-38px Raw value. Components must NOT reference this directly. -
Tier 2 · Semantic
var(--r-3), in this system, radii primitives ARE the semantic layer (radii are too few to need a separate intent layer). For colors, the Tier-2 layer is explicit (--color-action → var(--amber-500)). For radii / spacing, primitives serve double duty. -
Tier 3 · Component
--button-radiusvar(--r-3) → 8px A real component token, defined in tokens.css. The button atom reads--button-radius, never--r-3directly. -
Usage
.button { border-radius: var(--button-radius); }, resolves to 8px at runtime The atom CSS reads the component token. Component-token names survive primitive renames; only one place changes when the radius scale moves.
Data-viz scale
Five monochrome steps for charts and process diagrams, plus the highlight that draws the eye.
Tier 1 · primitives Raw values. Implementation detail, components must not reach here.
Gray scale
Amber scale
Legacy · transitional Pre-rename names. Kept for v1.0 compatibility. New code: use the semantic name. Removal: planned for v2.0.
| Legacy alias | Use instead (canon) | Status |
|---|---|---|
--bg | --color-bg | Deprecated · keep until v2.0 |
--surface | --color-surface | Deprecated |
--surface-2 | --color-surface-elevated | Deprecated |
--ink | --color-text-primary | Deprecated |
--ink-soft | --color-text-body | Deprecated |
--muted | --color-text-muted | Deprecated |
--hairline | --color-border | Deprecated |
--hairline-strong | --color-border-strong | Deprecated |
--hairline-faint | --color-border-faint | Deprecated |
--accent | --color-action | Deprecated |
--accent-hover | --color-action-hover | Deprecated |
--accent-text | --color-action-text | Deprecated |
--accent-soft | --color-action-soft | Deprecated |
--focus-ring | --color-action-focus | Deprecated |
Aeonik for type. Geist Mono for numbers.
Sentence case. No uppercase headlines. Eyebrows are the only place uppercase lives. Fluid clamp() sizes scale from mobile (375px) to wide desktop (1440px+).
4px base, geometric scale.
Eleven steps. Every margin, padding, and gap in the system is one of these, never a one-off pixel.
| Token | Value | Use when | Visual |
|---|---|---|---|
| --s-1 | 4px | Tightest inline gap (icon ↔ adjacent text inside a button) | |
| --s-2 | 8px | Inline element gaps (cluster items, badge ↔ label) | |
| --s-3 | 12px | Vertical rhythm in card body, list item gap | |
| --s-4 | 16px | Section padding (mobile), gap between blocks | |
| --s-5 | 24px | Section padding (desktop), gap between cards | |
| --s-6 | 32px | Between subsections, paragraph spacing in long-form | |
| --s-7 | 48px | Between sections (default .o-section padding) | |
| --s-8 | 64px | Between major page regions, hero block bottom padding | |
| --s-9 | 96px | Hero padding-block, generous section breaks | |
| --s-10 | 128px | Top-edge breathing for the home hero (.o-hero--home) | |
| --s-11 | 160px | Reserve, not currently used. Kept for layout patterns that need exceptional separation (full-bleed gallery → next chapter). |
Six steps, including pill.
Three elevation levels.
The unit test for the system.
If we can't document the button, we can't document anything. Below: full anatomy, four variants with usage rules, every state with its trigger, full accessibility receipts, and the complete token chain. The doc represents the same .button atom shipping on every page of this site.
Button
A clickable surface for actions. Triggers a transition, submission, or change of state, never used for navigation between pages (that's a link).
.button · /static/system/atoms.css
Real button styles and link behavior. Try Tab, then Enter.
- iconoptional leading slot, 16px svg, stroke-width 1.6
- contentrequired, the label text
- trailing iconoptional, e.g. arrow or chevron
- padding-x
var(--button-padding-x)= 16px (sm: 12, lg: 22) - padding-y
var(--button-padding-y)= 8px (sm: 4, lg: 12) - min-height36px (default), 32px (sm), 48px (lg)
- min-width72px in the default button
- radius
var(--button-radius)resolves tovar(--r-3)= 8px
| Class | Use when | Visual |
|---|---|---|
| .button-primary | The single most important action on the surface (CTA, "Submit", "Continue"). Maximum one per visible region. | |
| .button-accent | Reserved for the home-page primary CTA where amber is meaningful brand emphasis. Use sparingly, overuse drains its weight. | |
| .button-secondary | Supporting action paired with primary or accent (e.g., "Cancel" beside "Submit", "Contact" beside "Review the work"). | |
| .button-ghost | Tertiary, low-emphasis action in toolbars, table rows, or recovery flows. Visible on hover. | |
| .button-icon | Compact circular control for toolbars (close, settings, copy). Always requires an aria-label. |
| Class | Min height | Padding | Use when | Example |
|---|---|---|---|---|
| .button-sm | 32px | 4 / 12 | Inline actions in lists, secondary CTAs in compact UI | |
| , (default) | 36px | 8 / 16 | Standard form actions, page-level CTAs | |
| .button-lg | 48px | 12 / 22 | Hero CTAs only, never inside dense UI |
| State | Visual change | Trigger |
|---|---|---|
| default | Resting visual per variant | Initial render |
| :hover | Background shifts one step (primary → ink-soft, accent → amber-600) | Mouse over (only on hover-capable devices) |
| :active | scale(0.97) over 100ms with --ease-out |
Mouse-down, Enter, Space |
| :focus-visible | 3px focus ring at --color-action-focus (40% amber) |
Tab from keyboard. Mouse focus does not show the ring. |
| [data-loading] | Inline spinner replaces leading icon; content remains visible | data-loading="true" attribute set by the consumer |
| :disabled | 50% opacity, pointer-events: none |
disabled attribute or aria-disabled="true" |
- Min target40 × 80 px (default size). Icon button is 36 × 36, bumped to 44 × 44 on touch via media query. Touch users always meet WCAG 2.5.5.
- Color contrastPrimary: 18.5:1 (ink on bg, AAA). Accent: 5.5:1 (white on amber-500, AA Large). Secondary: 18.5:1 text + 3:1 border.
- KeyboardTab to focus. Enter or Space activates. Esc never closes a button.
- ARIA, icon button
aria-labelrequired. Without it, screen readers announce "button" with no name. - ARIA, loadingSet
aria-busy="true"in addition todata-loading. Updatearia-labelto "Loading…" so the change is announced. - ARIA, disabledPrefer
disabledattribute (removes from tab order). Usearia-disabled="true"only when the button must remain focusable to expose a tooltip explaining why. - Reduced motion
:activescale animation is preserved (it's a 100ms tap response, not motion sickness territory). Hover background transitions remain. - Screen reader announcement"[Variant], [content], button", e.g., "Submit, button". For loading: "Loading, Submit, button".
The button uses a hardware-accelerated transform: scale(0.97) on :active with a custom cubic-bezier(0.23, 1, 0.32, 1) ease-out at 100ms. The whole button responds, not just the text, that's the difference between feeling clicked and feeling pressed.
Hover transitions cap at 220ms. Color and border interpolate via --ease-quick. There is no shadow on hover, Ive's restraint applies; the variant's job is to look unmistakable at rest, not to perform interactivity on hover.
<!-- Default -->
<button class="button button-primary" type="button">Submit</button>
<!-- Loading state -->
<button class="button button-primary" type="button"
data-loading="true" aria-busy="true"
aria-label="Loading…">
<span class="button-content">Submit</span>
</button>
<!-- Icon-only -->
<button class="button button-icon" type="button"
aria-label="Settings">
<svg width="16" height="16" ...>...</svg>
</button>
--button-radius→var(--r-3) = 8px--button-padding-x→16px--button-padding-y→8px--button-min-height→36px--button-active-scale→0.97--button-icon-gap→var(--s-2) = 8px- Primary fill→
var(--color-text-primary) - Accent fill→
var(--color-action) = var(--amber-500) - Focus ring→
var(--color-action-focus) = amber-500/0.4
- Use exactly one Primary or Accent button per visible region, they compete for the eye.
- Pair Primary with Secondary (e.g., "Submit" + "Cancel"). Pair Accent with Secondary (e.g., "Get started" + "Contact").
- Use
aria-labelon every.button-icon, without it, screen readers announce "button" with no name. - Set
aria-busy="true"in addition todata-loadingso the state change is announced. - Reserve
.button-lgfor hero CTAs only. Default size for everything else.
- Don't use Accent for non-action UI (decorative dividers, status labels, headings). Amber is a verb, not an adjective.
- Don't put two Primary buttons next to each other, the user can't tell which is "the" action. Use Primary + Secondary.
- Don't use Ghost as a primary action, its low visual weight is the entire point. It's tertiary.
- Don't disable a button without explaining why (use a tooltip with
aria-describedby). Silent disabled = mystery. - Don't bypass
.button-contentfor the loading state, the blur-mask transition needs that wrapper to fade between text and spinner cleanly.
Two visual lessons. Words tell; visuals teach.
Primary + Secondary pairing, user knows immediately which is THE action.
Two Primaries compete. User can't tell which action is "the" action, eye loses.
Icon button with aria-label="Settings", screen readers announce "Settings, button."
No aria-label, screen readers announce "button" with no name. Keyboard users are stranded.
Reverse-dependency map. Changing .button CSS will affect every consumer below, coordinate the audit before MAJOR changes.
.m-cta-pair←contains 1–2 buttons.o-card-work-cta←on /portfolio card foot.o-cta←closing CTA on every page.o-toast-action←action button inside toast.o-dialog←dialog footer actions.o-hero-home←via .m-cta-pair.theme-toggle←uses .button-icon variant
Compact metadata that doesn't shout.
Tag
Compact non-interactive label for capability, technology, status, or version metadata. Tags are descriptive, never clickable. Use chips for filters.
.tag · /static/system/atoms.css
- Public Preview
- Product design
- iconoptional 12px svg, 6px gap from text
- textrequired, 12px, weight 500
- padding
3px 10pxvia component tokens - radius
var(--tag-radius)= 4px (pill = 9999) - line-height1.4, tags should never wrap
| Class | Use when | Visual |
|---|---|---|
| .tag | Default capability or domain label inside cards (e.g., "Sales workflow", "B2B SaaS") | Sales workflow |
| .tag-outline | Visible on tinted surfaces where the default fill blends in | Outline |
| .tag-accent | One-per-card emphasis, the "featured" or "primary capability" tag | Featured |
| .tag-mono | Version, hash, ID, or any tabular value where character-shape needs to lock | v1.1.0 |
| .tag-pill | Modifier for fully rounded shape, used in cluster groups for a softer rhythm | Pill |
- RoleNone, tag is non-interactive. If you need interactivity, use
.m-chipinstead. - Color contrastDefault: 5.2:1 (ink-soft on surface-2). Accent: 4.81:1 (amber-700 on amber-soft).
- Screen readerReads as plain text. For status meaning, prefer a sentence over a tag (e.g., "Status: shipped" beats
<tag>Shipped</tag>). - Multiple tagsUse
.m-tag-clusterwith<ul>+<li>for screen reader list semantics.
<!-- Single tag -->
<span class="tag">Sales workflow</span>
<!-- Cluster (semantic list) -->
<ul class="m-tag-cluster">
<li class="tag">B2B SaaS</li>
<li class="tag tag-mono">v2024.Q3</li>
<li class="tag tag-accent">Featured</li>
</ul>
--tag-radius→var(--r-1) = 4px--tag-padding-y→3px--tag-padding-x→10px--tag-bg→var(--color-surface-elevated)--tag-text→var(--color-text-body)
- Use Default for descriptive metadata (capability, technology, domain). Most tags should be default.
- Use
.tag-monofor version, hash, ID, anything where character-shape needs to lock visually. - Use exactly one
.tag-accentper card. It's the "featured" emphasis, multiple accent tags read as decoration. - Wrap multiple tags in
.m-tag-clusterwith semantic<ul>+<li>for screen-reader list semantics. - Keep tag text under 15 characters. Tags don't wrap, long text breaks the rhythm.
- Don't make tags clickable. If you need interactivity, use
.m-chip, that's the filter molecule. - Don't use tags to communicate status ("Shipped", "In progress"). A sentence is more accessible than a tag for state meaning.
- Don't mix accent tags with mono accent text in the same card, two amber sources fight for attention.
- Don't put more than 4 tags in one cluster. After 4, the visual weight overpowers the title/teaser.
- Don't style tags with custom colors. The system has 5 variants, add a sixth only after a Tier 3 governance discussion.
- Sales workflow
- B2B SaaS
- Featured
3 tags max, one accent for emphasis. Visual rhythm intact.
- Sales
- B2B
- SaaS
- Workflow
- Inline
5 accent tags, every tag screams. Eye has nowhere to land. Drains the accent's signal entirely.
.m-tag-cluster←contains tag list (semantic ul/li).o-card-work←capability cluster on card foot- Hero tag rows←v1.x hero "Aeonik / Geist Mono / 12 components"
- Case study capabilities←.o-hero-case meta block
Quiet by default. Loud on focus.
Input
Single-line text field. Hairline at rest, amber focus ring on keyboard tab. Always paired with a label via the .m-field molecule, never floating.
.input · /static/system/atoms.css
Choose one bounded unit of work. This local example does not save or send text.
- fieldfull width by default, wrap in
.m-fieldfor max-width - padding8px / 12px (sm: 6px / 10px)
- min-height36px minimum; surrounding spacing also matters for target size
- border1px visible control boundary at rest, action-text color on focus
- focus ringOn this documentation page, a single 2px action-text outline with a 2px gap
- placeholderuses the theme-aware
--color-text-placeholdertoken
| Class | Use when | Min height |
|---|---|---|
| .input | Standard form fields, contact forms, search | 36px |
| .input-sm | Inline filter inputs, dense table editors | 36px |
| State | Visual | Trigger |
|---|---|---|
| default | Surface bg, hairline border | Initial render |
| :hover | Border darkens to --color-border-strong | Mouse over (hover-capable only) |
| :focus-visible | Action-color border and one 2px outline on this page, without stacked shadows | Tab from keyboard |
| :invalid | (consumer-styled, system does not impose) | HTML5 validation API |
| :disabled | 50% opacity, cursor: not-allowed | disabled attribute |
- Min target40 × full-width, meets WCAG 2.5.5 by default
- Color contrastText 18.5:1 (AAA). Placeholder 4.6:1 (AA). Border 3:1 in focus state.
- LabelAlways pair with a
<label>viafor=/id=or wrap in.m-field. Never use placeholder as label substitute. - RequiredUse the
requiredattribute. Visual indication via.m-field--required. Screen readers announce "required". - Help textReference via
aria-describedbywhen not using.m-field-help. - KeyboardTab focuses, Esc does NOT clear (browser default). Enter submits the form when applicable.
<!-- Bare input (NEVER ship this, always pair with a label) -->
<input class="input" type="text" placeholder="…">
<!-- With label (canonical), uses .m-field molecule -->
<div class="m-field m-field--required">
<label class="m-field-label" for="email">Work email</label>
<input class="input" id="email" type="email" required>
<span class="m-field-help">We'll only use this to schedule a call.</span>
</div>
--input-radius→var(--r-2) = 6px--input-padding-y→8px--input-padding-x→12px--input-min-height→36px--input-border→1px solid var(--color-control-border)--input-border-focus→1px solid var(--color-action-text)--input-focus-ring→0 0 0 3px var(--color-action-text)
- Always pair with a
<label>viafor/idor wrap the input + label inside.m-field. - Use the
requiredattribute on required fields. Pair with.m-field--requiredfor the visual indicator. - For helper copy, use
.m-field-helpbeneath the input. Explicitly connect its ID witharia-describedby; CSS does not create that relationship. - Match input
typeto the data:email,tel,url,number. Mobile keyboards adapt. - Use
.input-smfor inline filter inputs and dense table editors only, not for primary forms.
- Don't use
placeholderas a label substitute. Placeholders disappear on type and fail screen readers. - Don't ship a bare
<input>in production. Always wrap or label. - Don't remove keyboard focus or stack a shadow and multiple outlines. This page uses one 2px outline with a 2px gap.
- Don't disable inputs without explaining why (use
aria-describedbypointing to a help message). - Use the documented textarea for longer answers and select for a bounded list of choices.
Do: keep the label visible
Type to see the label stay in place. This example sends nothing.
Avoid: a placeholder as the only label
Once text is entered, a placeholder-only design loses its visible label. This read-only specimen keeps an accessible name; do not copy the pattern into a form.
Copy the labeled field pattern
<div class="m-field">
<label for="email">Email address</label>
<input class="input" id="email" type="email"
aria-describedby="email-help">
<p class="m-field-help" id="email-help">How we use your email.</p>
</div>.m-field←canonical input wrapper (label + input + helper).o-contact-grid←/contact form fields- DS examples←Section 08 live demo (this page)
Underlines that earn their motion.
Link
Inline navigation between contexts. Three patterns: accent for editorial body copy, quiet for utility navigation, arrow for "continue reading" affordances.
.link · .link-quiet · .link-arrow · /static/system/atoms.css
A paragraph with an accent link inside body copy shows the underline animation pattern. Hover to see the underline draw left-to-right.
Or use a quiet link for utility navigation where you don't want to draw the eye.
Continue reading| Class | Use when | Hover behavior |
|---|---|---|
| .link | Inline reference inside body copy ("see this study"). Editorial use. | Underline draws left-to-right, 220ms ease-out |
| .link-quiet | Utility navigation, footer links, secondary nav. Should not draw the eye in body context. | Border-bottom darkens hairline → ink, 220ms |
| .link-arrow | Continue / next case affordance at the end of content blocks | Gap expands 4 → 8px, arrow translates 2px right |
| State | Visual | Trigger |
|---|---|---|
| default | Color per variant; no underline (or quiet hairline) | Initial |
| :hover | Underline animates in; arrow translates | Mouse over |
| :focus-visible | Browser default outline (intentionally, keyboard users need the standard) | Tab |
| :visited | No styling change, Brad rule: don't pretend you remember every link a user clicked | , |
- Color contrast
.link: 4.81:1 (amber-700 on bg, AA ✓)..link-quiet: 18.5:1. Underline carries meaning when color alone fails (color-blindness). - Underline policyAlways present in some form, animated entry on
.link, hairline at rest on.link-quiet. Never color-only. - KeyboardTab focuses. Enter activates. Browser default focus ring preserved on
:focus-visible. - External linksAdd
target="_blank"+rel="noreferrer noopener". Communicate "opens in new tab" to screen readers via visually-hidden text or icon witharia-label. - Skip patternsUse
.link-quietwhen in body copy that already has many.links, too many accent underlines reads as decoration, not signal.
<!-- Editorial inline link -->
<p>A study from <a class="link" href="#">LendingTree 2023</a> shows…</p>
<!-- Quiet link (utility nav) -->
<a class="link-quiet" href="#">Privacy policy</a>
<!-- Arrow link (continue reading) -->
<a class="link-arrow" href="#">Continue reading</a>
- .link color→
var(--color-action-text) - .link hover color→
var(--color-action-text-hover) - .link underline duration→
var(--dur-quick) = 220ms - .link underline easing→
var(--ease-out) - .link-quiet color→
var(--color-text-primary) - .link-quiet underline→
1px solid var(--color-control-border) - .link-arrow gap→
4px → 8px on hover
- Use
.linksparingly, accent links should appear at most 2-3 times in a body paragraph. Beyond that, swap to.link-quiet. - Use
.link-arrowfor "continue reading" or "see all" affordances at the end of content blocks. - Add
target="_blank" rel="noreferrer noopener"for external links. Communicate "opens in new tab" to screen readers. - Underline always present in some form, animated entry on
.link, hairline at rest on.link-quiet.
- Don't use color alone to signal a link. Color-blind users need the underline.
- Don't style
:visited. The system intentionally doesn't track visit state, Brad: don't pretend you remember every link a user clicked. - Don't use
.linkfor primary CTAs. Buttons exist. A link inside a button is a tell that something is wrong. - Don't override
:focus-visible, the browser default outline is intentional for keyboard users. - Don't nest a link inside a card or list item that's already a link. Screen readers can't navigate the conflict.
- Body copy across pages←.link inline references
.o-footer←.link-quiet for utility nav.m-link-card←contains .link-arrow affordance- Case study source links←.link with target=_blank for citations
The smallest atoms.
.divider-strong
Curves and durations live in tokens.
Asymmetric: exits faster than entries. UI animations stay under 320ms. Keyboard-driven actions never animate. Reduced-motion users get opacity-only transitions, no movement.
Atoms compose into purpose.
Small assemblies of atoms, each with a specific job. Choose any of the twenty entries in the contents rail or jump-to search. Examples show the component in use; additional interaction patterns follow in the additions section.
Eyebrow + title
Research-led case studies across enterprise AI, dashboards, healthcare.
Twelve years across enterprise, healthcare, and consumer.
Hero lede
Lead product designer. T-Mobile, Microsoft, Home Depot.
I'm a UX designer with experience shipping enterprise AI systems, analytics platforms, developer tools, and design systems at T-Mobile and Microsoft. My strongest work is the layer between research and product architecture.
Breadcrumb
Quote
I'd update the deal in my head three times before I'd open the form. By the time I'd type it in, the conversation had already moved on.
Sales rep, 4 years tenure Generative interview · Meridian CRM research
Stat
Metric
Faster deal update task completion
From 6-step modal to inline edit. Measured against baseline click-path in a 12-rep usability study.
Source: Internal usability test, n=12Voluntary activity logging
After 6-week pilot with sales operations team.
Source: Pendo event tracking, Q3 2024Time spent in CRM per day
Reduction without loss of pipeline data quality.
Card header
CRM & sales workflow redesign
Timeline + entry
Wrap N entries in .m-timeline to enable the vertical spine + per-entry dot. Each entry takes data-reveal; the dot fills with accent the first time the entry crosses the viewport. Spine is desktop-only (≥641px); under that the timeline collapses to a hairline divider per row.
Led end-to-end UX design of customer booking and service scheduling workflows tied to retail support and enterprise design system components.
Designed pro-channel checkout, account hierarchy, and credit application flows for $7B+ in annual pro revenue.
CTA pair
Filter row
Tag cluster
- Sales workflow
- B2B SaaS
- Inline editing
- Mobile-first
- v2024.Q3
- Featured
Field
Avatar
Link card
Atoms and molecules assembled.
Page-level compositions. The header you've been using on this page is already .o-header, that's the canonical site nav. Below: the rest of the organisms that build the live pages in Session 5.
Status pill
Hero
The unified page-opening composition. Three modifiers cover every hero in the system: --home (split layout with motion graphic), --page (generic eyebrow + title + lede), and --case (case-study breadcrumb + title + visual + meta). Same base, different layout.
.o-hero · /static/system/organisms.css
All three variants share the .o-hero base (max-width, centered, container padding). Modifiers compose different inner element sets:
Make the next step clear.
Start with the person, the task and the decision they need to make.
| Modifier | Use when | Layout | Padding-block |
|---|---|---|---|
| .o-hero--home | The home page only, identity + motion graphic | Single column at < 880px, 1.1fr / 1fr split at desktop | var(--s-10) var(--s-9) |
| .o-hero--page | Section index pages (Work, About, Contact, 404) | Flex column, eyebrow stacked over title over lede | var(--s-9) var(--s-7) |
| .o-hero--page.o-hero--compact | Page hero with no lede (e.g., when filter chips follow immediately) | Same as --page |
var(--s-9) var(--s-5) |
| .o-hero--case | Case study pages, breadcrumb-led, title + lede, visual + meta grid | Breadcrumb + head + 2-col grid (1.6fr / 1fr) at desktop, stacks at < 880px | var(--s-9) var(--s-7) |
| Legacy class | Canonical class | Status |
|---|---|---|
.o-hero-home | .o-hero.o-hero--home | Aliased, both work; legacy slated for v2.0 removal |
.o-hero-page | .o-hero.o-hero--page | Aliased |
.o-hero-page--compact | .o-hero.o-hero--page.o-hero--compact | Aliased |
.o-hero-case | .o-hero.o-hero--case | Aliased |
Inner element classes (.o-hero-home-content, .o-hero-page-eyebrow, .o-hero-case-grid, etc.) keep their existing names. The refactor unifies the wrapper only; deeper renaming would break consumers without enough benefit.
<!-- Home -->
<section class="o-hero o-hero--home">
<div class="o-hero-home-content">…</div>
<div class="hero-motion-frame">…</div>
</section>
<!-- Page -->
<section class="o-hero o-hero--page">
<span class="o-hero-page-eyebrow">Work</span>
<h1 class="o-hero-page-title">…</h1>
<p class="o-hero-page-lede">…</p>
</section>
<!-- Case -->
<section class="o-hero o-hero--case">
<ol class="m-breadcrumb">…</ol>
<div class="o-hero-case-head">…</div>
<div class="o-hero-case-grid">…</div>
</section>
- Container max-width→
var(--w-default) = 1120px - Container inline padding→
var(--container-pad) - --home padding-block→
var(--s-10) var(--s-9) - --page padding-block→
var(--s-9) var(--s-7) - --case padding-block→
var(--s-9) var(--s-7) - --case grid gap→
var(--s-6) - Title color→
var(--color-text-primary) - Lede color→
var(--color-text-body)
- Pick the right modifier for the page:
--home(split layout, motion graphic),--page(eyebrow + title + lede),--case(breadcrumb + visual + meta). - Use
--page.--compactwhen no lede follows, sets bottom padding tighter so a filter row reads close to the title. - Keep titles within their max-width caps, display-l caps at 22ch, body-l ledes at ~60ch.
- Inner elements use the legacy single-name classes (
.o-hero-home-content,.o-hero-page-title), refactor stops at the wrapper. - For new routes: pick the closest existing modifier. Don't invent a new one without a governance discussion.
- Don't use
.o-hero--homeon any page other than/. The motion graphic is identity-specific. - Don't put CTAs in
.o-hero--case. Hero is for orientation; CTAs come at the closing. - Don't mix the legacy class (
.o-hero-home) and canonical (.o-hero.o-hero--home) on the same element. Pick one. - Don't override the hero's max-width or padding inline. If a page needs different dimensions, it needs a different template.
- Don't add a hover state to the hero, heroes don't need to perform interactivity.
Card-work
The case-study listing card used across /portfolio and the home page work index. Composes the project thumbnail, meta strip, title, teaser, capability cluster, and headline metric into a single clickable surface.
.o-card-work · /static/system/organisms.css
Inspect the live production card below. The parts list and source classes explain its composition.
| Class | Use when | Surface |
|---|---|---|
| .o-card-work | Default, used on /portfolio and home archetype index | Surface fill, hairline border, hover darkens border only |
| data-domain="…" | Filter attribute consumed by the work-page filter row | No visual change, used by JS to show/hide |
| State | Visual | Trigger |
|---|---|---|
| default | Hairline border, surface fill | Initial render |
| :hover | Border-color shifts to --color-border-strong. No lift, no shadow (Ive restraint). | Mouse over (hover-capable) |
| :focus-visible | 2px accent outline, 4px offset | Tab from keyboard |
| :active | scale(0.985) tap response | Mouse-down or Enter |
- Whole-card linkThe entire
<a>is the link target. Inner elements must not include nested links, that breaks screen-reader navigation. Tag list is non-interactive on purpose. - Title roleUse
<h3>at the page level. Inside the design system showcase,<h4>respects heading hierarchy. - KeyboardTab focuses card. Enter activates the link.
:focus-visibleshows the accent outline; mouse focus does not. - Color contrastTitle 18.5:1 (AAA). Teaser 7.2:1 (AAA). Meta 4.6:1 (AA). Accent metric 4.81:1 (AA ✓).
- Reduced motionCard transitions cap at 220ms. Active scale animation is preserved (sub-100ms tap response is not motion sickness territory).
- Touch targetWhole card is the target, minimum height ~340px. WCAG 2.5.5 ✓.
<a class="o-card-work" href="/portfolio/multi-role-analytics"
data-domain="Enterprise SaaS">
<div class="o-card-work-thumb">
<span class="o-card-work-thumb-mark">Dashboard</span>
</div>
<div class="o-card-work-body">
<div class="o-card-work-meta">
<span>T-Mobile</span><span>2025</span><span>8 mo read</span>
</div>
<h3 class="o-card-work-title">Multi-Role Analytics Dashboard</h3>
<p class="o-card-work-teaser">…</p>
<div class="o-card-work-foot">
<ul class="m-tag-cluster">
<li class="tag">Multi-role</li><li class="tag">AI</li>
</ul>
<span class="t-mono-s t-accent">1 platform</span>
</div>
</div>
</a>
--card-radius→var(--r-4) = 18px--card-border→1px solid var(--color-border)--card-border-hover→1px solid var(--color-border-strong)- Body padding→
var(--s-4) var(--s-5) - Title color→
var(--color-text-primary) - Metric color→
var(--color-action-text)
How the card behaves across data conditions, loading, missing, error, filtered. Most consumer scenarios pass through these states; documenting them prevents inconsistent UX.
| State | What renders | Trigger |
|---|---|---|
| default | Full card with thumb + meta + title + teaser + foot | Project data fully populated |
| loading | Skeleton via .skeleton atoms in each slot. Use aria-busy="true" on the wrapping anchor. | Data fetch pending (currently SSR-only, so this is a v2.0 client-side pattern) |
| empty (no metric) | Card renders without the metric span in foot. firstMetric helper auto-skips placeholder values. | SuccessMetrics all ", fill in" or empty |
| empty (no thumb image) | Falls back to .o-card-work-thumb-mark mono label only. Thumb area stays sized via aspect-ratio. | No project image available |
| filtered out | display: none applied by filter JS. Card preserved in DOM for state-restore. | Active data-filter doesn't match data-domain |
| all-filtered | Parent .ds-grid swaps to .m-empty-state when zero cards match. | Every card in group filtered out |
| error | (Not applicable, cards are SSR; no client-side fetch failure path. Documented for v2.0 if SPA mode added.) | , |
What can go inside each slot. The card has fixed structure; deviating breaks layout or accessibility.
| Slot | Required | Allowed | Forbidden |
|---|---|---|---|
| .o-card-work-thumb | None, slot can be empty | <img>, <picture>, <svg>, <video muted loop> | Interactive content (buttons, links, would break whole-card-link semantics) |
| .o-card-work-thumb-mark | None, but recommended as fallback when image absent | Plain text only, atom uses .t-mono-s | SVG, links, formatted content |
| .o-card-work-meta | 1+ <span> elements | Plain text strings only (client name, year, timeline) | Links, buttons, meta is descriptive, not navigational |
| .o-card-work-title | Heading element (h3 default) | <h3> or <h4> per page hierarchy | Links, the parent <a> handles navigation |
| .o-card-work-foot | Optional | .m-tag-cluster with up to 2 tags + 1 metric span | Buttons, multiple metrics, >2 tags (visual weight overpowers title) |
- Make the entire
<a>the link target. Card hover changes border-color only, no lift, no shadow (Ive restraint). - Use
data-domainattribute matching the work group title for filter integration. - Keep teaser to 2-3 sentences max. The card is a teaser, not the case study.
- Show one quantitative metric in the foot (
.t-mono-s.t-accent), the most compelling number from the case. - Use real project content from data, never lorem ipsum, never "Project name."
- Don't nest links inside the card. Inner elements (tags, metric) must not be interactive, that breaks screen-reader navigation.
- Don't add a hover lift (
translateY) or shadow. Border-color shift is the entire hover treatment. - Don't put more than 2 tags in the foot. The card visual gets noisy past two.
- Don't use this card outside the work-listing context. Other listings have their own card variants (or should).
- Don't override
:focus-visible. The 2px accent outline + 4px offset is the keyboard signal.
Hero-case
The opening composition of every case study page. Frames the project with breadcrumb wayfinding, headline + lede pair, project visual, and definition-list metadata (role / team / timeline / status).
.o-hero-case · /static/system/organisms.css
A clearer path through a complex task.
Describe the problem and your contribution before showing the detailed process.
- Role: designer
- Scope: one workflow
| Class | Use when | Layout |
|---|---|---|
| .o-hero-case | Standard case-study opening | Breadcrumb top, title + lede left, visual + meta right |
| , + .o-hero-case--narrow | (future) reading-column layout for text-only cases | Single column, max-width 60ch |
| State | Visual / behavior | Trigger |
|---|---|---|
| default | Static composition; no hover state on the hero itself | Initial render |
| reveal | Single 420ms fade per element block (breadcrumb, head, grid). 80ms initial delay; grid delayed +120ms. | Page load (motion.css) |
| reduced-motion | All reveals collapse to opacity: 1 instantly | prefers-reduced-motion: reduce |
- Heading hierarchy
h1for the case-study title (only h1 on the page). Subsections useh2. - BreadcrumbWrapped in
<nav aria-label="Breadcrumb">. Last item is plain text (current page), not a link. - Definition list
<dl>with<dt>/<dd>pairs. Screen readers announce as "Role: Senior UX Designer; Team: Product, sales enablement, …" - Visual slotProject photo requires
altdescribing the work, not the medium ("dashboard showing role-based filters" not "screenshot of dashboard"). - Color contrastTitle 18.5:1 (AAA). Lede 7.2:1. Meta dt: 4.6:1. Meta dd: 18.5:1.
- Reduced motionReveal animations disabled, content is visible at
opacity: 1from the start.
<section class="o-hero-case">
<nav aria-label="Breadcrumb">
<ol class="m-breadcrumb">
<li><a href="/portfolio">Work</a></li>
<li>Enterprise SaaS</li>
<li>2024</li>
</ol>
</nav>
<div class="o-hero-case-head">
<h1 class="o-hero-case-title">…</h1>
<p class="o-hero-case-lede">…</p>
</div>
<div class="o-hero-case-grid">
<div class="o-hero-case-visual">
<img src="…" alt="…">
</div>
<dl class="o-case-meta">
<div><dt>Role</dt><dd>Senior UX Designer</dd></div>
<div><dt>Team</dt><dd>…</dd></div>
<div><dt>Timeline</dt><dd>3 months</dd></div>
<div><dt>Status</dt><dd>Shipped</dd></div>
</dl>
</div>
</section>
How the hero behaves across data conditions. Hero is the page's opening, these states are unmissable.
| State | What renders | Trigger |
|---|---|---|
| default | Breadcrumb + title + lede + visual + meta. Reveal animation fires on page load (single 420ms fade per element block). | Project fully populated |
| empty (no visual) | Visual slot stays sized via aspect-ratio but renders empty fallback color. Meta block remains. | Project has no HeroVisual |
| empty (no lede) | Lede paragraph omitted; head reduces to title only. Spacing collapses gracefully. | Project has no Teaser or HeroStatement |
| empty (no meta) | Meta <dl> hidden. Visual takes full grid width. | Project has no Role + Team + Timeline + Status |
| reduced-motion | Reveal animations collapse to opacity: 1 instantly. Layout unchanged. | prefers-reduced-motion: reduce |
| narrow viewport | 2-col grid (visual + meta) stacks vertically below 880px. Visual takes full width; meta follows. | Viewport < 880px |
What can go inside each hero slot. Hero is the page's first impression; slot violations show up immediately.
| Slot | Required | Allowed | Forbidden |
|---|---|---|---|
| .m-breadcrumb | 2+ <li> ending with current page | Anchors and plain-text current item | Buttons, dropdowns, more than 4 levels deep |
| .o-hero-case-title | <h1> with case-study title | Display type via .t-display-l role | Multiple h1s, marketing taglines, branded text |
| .o-hero-case-lede | <p> with 1–2 sentences naming the tension | Plain text + inline emphasis | CTAs, lists, multiple paragraphs (lede is one tight paragraph) |
| .o-hero-case-visual | Project hero image or video frame | <img> with descriptive alt, <video> muted/looped, <svg> animation | Generic stock images, decorative gradients, links |
| .o-case-meta | <dl> with 4 <dt>/<dd> pairs (Role / Team / Timeline / Status) | Plain text in dd elements | Metrics, those live in the Impact Framing section, not meta |
- Lead with the case-study title as
<h1>. Only one h1 per page. - Wrap breadcrumb in
<nav aria-label="Breadcrumb">. Make the last item plain text, it's the current page. - Write a lede that names the tension the case resolves ("Sales reps weren't avoiding the CRM because they were lazy, they were avoiding the friction"). 2 sentences max.
- Use
<dl>+<dt>/<dd>for project meta. Screen readers announce the pairs as a structured list. - Fill the visual slot with a real project image.
altdescribes the work, not the medium.
- Don't make the visual slot a generic "design hero" image. The hero is the case-study's evidence, not its mood.
- Don't use the breadcrumb to link to ambiguous parents (e.g., "Selected work" instead of "Work"). Breadcrumb labels match the route.
- Don't write a lede that just restates the title. Lede adds tension; title names the case.
- Don't put metrics in the meta block. Metrics live in the Impact-framing section. Meta is role/team/timeline/status.
- Don't add CTAs to the hero. Hero is for orientation; CTAs come at the closing.
Archetype card
Analytics & workflow tools
Multi-role analytics, CRM workflows, admin consoles, and ops tooling.
KYC, payments, lending, wealth
Banking onboarding, SMB payments, lending, and wealth management.
Clinical workflows & patient UX
Clinical workflow tooling, patient journey design, clinical decision support.
Process section
Outcome
Contact grid
PST timezone. Open to remote, hybrid, or on-site within Pacific time.
TL;DR
Sales reps weren't avoiding the form, they were avoiding the cognitive load. Inline edit replaced a 6-step modal; deal-update task time dropped 2.4× and voluntary activity logging rose 31%.
Platform tag
Feature grid + card
Research-led
Every case starts with contextual interviews and behavior data. Generative research is the cheapest way to find the right problem.
System-minded
Components, tokens, and templates ship as one. Every product surface gets the same vocabulary.
Comparison
A live demo lives in the Behavior-hooks section under data-comparison. The component renders two stacked panels (.o-comparison-before + .o-comparison-after), a draggable handle, and a hidden range input that updates --comparison-pos on the wrapper. Keyboard-accessible by default (slide with arrow keys).
Artifact grid + tile
Inline edit pattern
Single-row deal update with field validation, optimistic write, and undo within 8s.
Activity log model
Conversational accumulation across 3 stages, capture, refine, crystallize at deal close.
Media showcase
img URL exists
Competitor table
Principle list
Research is the cheapest path to the right problem.
Five contextual interviews change a roadmap more than five months of solutioning. Every case study here started with field work, not a Figma file.
Systems compound. Snowflakes don't.
Each one-off pattern is a small future tax. Each canonical component is a small future dividend. The math always tilts toward the system.
Compact head shows avatar (.m-avatar--lg with optional --ink / --accent variant), name, role, the JTBD claim, and a "See goals & frustrations" disclosure with an animated underline + chevron. Click to expand the accordion (grid-template-rows: 0fr → 1fr, 240ms cubic-bezier(0.23, 1, 0.32, 1)). Auto-fit grid; the third card spans full width when alone in its row. Used by case studies via Layout: "personas" with the Persona struct (Name, Role, Initials, AvatarVariant, JobToBeDone, Goals, Frustrations).
The Store Rep
Retail associate, coordination layer by defaultWhen juggling four channels at once, I need to see all active demand in one place, so I can transfer to a free colleague without losing the customer in front of me.
- See all active demand in one view, not tab-switch between systems
- Transfer a customer to a free colleague without losing their context
- No way to see which teammates are free right now
- Phone calls break focus mid-appointment, no good way to defer
The Waiting Customer
Retail customer, on hold, in line, or rebookedWhen I walk in or call, I just need to know if someone can help today and where, without holding without an estimate or being sent to a store with no capacity.
- Get served without waiting for a rep who is already at capacity
- Get routed to an available rep or a nearby store without having to ask
- Being told to wait when other reps are free but unaware
- Being sent to another store with no confirmation that store has availability
Variants shown: .m-avatar--ink (default) and .m-avatar--accent (expanded). Third value .m-avatar--lg alone (no variant) renders as muted surface.
If you know another system, here's the map.
This older vocabulary map is a starting point, not an equivalence test. A tag is not an interactive chip, and a visual match does not imply matching behavior. See the current Material, Carbon, Fluent and Apple HIG comparison for implemented changes and open gaps.
| Plot | Material (Google) | Carbon (IBM) | Polaris (Shopify) | Fluent (Microsoft) |
|---|---|---|---|---|
| .button-primary | Filled button | Primary button | Primary action | Primary command |
| .button-secondary | Outlined button | Secondary button | Secondary action | Secondary command |
| .button-accent | Tonal button | , | , | Brand-accent variant |
| .button-ghost | Text button | Tertiary button | Plain action | Subtle button |
| .button-icon | Icon button | Icon button | Icon plain action | Icon button |
| .tag | Chip (input variant) | Tag | Tag | Badge |
| .tag-accent | Selected chip | Tag (color="cyan") | Tag tone="success" | Badge appearance="brand" |
| .input + .m-field | Text field | Text input | Text field | Input |
| .o-card-work | Custom card | Tile | Resource list item | Card |
| .o-toast | Snackbar | Toast notification | Toast | Toast |
| .o-dialog | Dialog | Modal | Modal | Dialog |
| --color-action | md-sys-color-primary | $button-primary-bg | tone-magic surface | colorBrandBackground |
Closest match: Polaris (clear functional naming + token discipline). Furthest: Material (heavier interaction layer). Plot is closer to Carbon in spirit, atomic foundation + per-component documentation.
What's not in v1.x, and why.
Honest accounting of what Plot v1 doesn't do. Tells consumers what to expect and what to plan around.
Plot's components currently respond to viewport width via media queries, not to their parent container width via @container queries. For a portfolio at this scale, no measured benefit yet. v2.0 will reconsider once components are reused in container contexts that differ from the viewport (e.g., a card grid inside a sidebar).
Each component doc shows variants, sizes, and states as separate tables. The real combinations are variants × sizes × states. For Button that's 5 × 3 × 6 = 90 cells. Most won't have unique visuals. v2.0 will surface the ~12 worth visualizing per component (e.g., "primary + default + hover/focus/active/disabled" + "icon + sm/lg + focus") explicitly. v1.x relies on the consumer reading three tables and composing mentally.
Plot 1.3 adds native textarea, select, checkbox and radio examples, plus local validation and recovery. Date-range pickers, file uploads and searchable comboboxes remain unimplemented. These new examples are not wired to a server or automatically applied to the contact form.
Plot is built for one brand (Peter's portfolio). Multi-brand theming is technically possible, override semantic tokens at :root level, but not validated. v2.0 may explore extracting Plot as a starter kit; until then, treat the amber accent as fixed.
v2.0 reductions, what's getting merged.
v1.x maintains backward compatibility. v2.0 will remove the redundant components below. Each row names the duplication, the v2 successor, and the migration path. Disclosed publicly so consumers can plan ahead.
| Will be removed (v2.0) | Replacement (already in v1) | Why | Migration |
|---|---|---|---|
.m-stat |
.m-metric + .m-metric--featured |
Both render "big number + small label." Difference was where they live, not what they are. Single component with a featured modifier covers both jobs. | Replace .m-stat classes with .m-metric. No DOM change beyond class rename. |
.m-cta-pair |
.cluster.cluster-3 utility |
The molecule's only job was "horizontal flex with gap." That's a layout primitive, not a molecule. Removing one molecule from the system. | Replace .m-cta-pair with .cluster.cluster-3. Nested buttons stay unchanged. |
.m-eyebrow-title |
Direct HTML, <header> with .t-eyebrow + .t-title-xl |
Six classes ("eyebrow", "num", "title", "modifier", etc.) for "two atoms in a stack." The structure belongs in HTML, not a class hierarchy. | Replace molecule wrappers with native <header> + atom classes. Reduces ~80 lines of CSS. |
.o-status-pill |
.tag.tag--pill.tag--accent.tag--live |
Both are "rounded amber-tinted label with text." The status-pill's only meaningful difference is the animated dot. Promote that to a tag modifier. | Replace .o-status-pill wrapper with .tag + modifiers. Reuse animated dot via .tag-live-dot child. |
.o-process · .o-outcome · .o-contact-grid |
Template-internal patterns (.case-study__process, etc.) |
Page-specific patterns labeled as "organisms" mislead consumers into reusing them off-context. Templates are the right tier for these. | Rename selectors to .case-study__* namespace. No visual change. Documented in Templates section. |
5 redundant components removed. ~3 molecules and ~3 organisms net shrink. ~200 lines of CSS retired. Every consolidation has a documented migration. The system gets simpler, not larger.
Variant rationalization
Dan Mall: 5 button variants is at the upper limit. Pick a model, peers vs composable modifiers, and apply consistently. v2.0 reduces variant count by treating shape / color / size as composable modifiers instead of variant peers.
Button, 5 variants → 3 + 2 modifiers
| v1.x (current) | v2.0 (planned) | Why |
|---|---|---|
| .button-primary | .button-primary | Main CTA, kept as-is |
| .button-secondary | .button-secondary | Supporting action, kept |
| .button-icon | .button-icon | Square / pill icon-only, kept (genuine third role) |
| .button-accent | .button-primary.--accent | Demoted from variant peer to modifier, primary CTA with amber emphasis |
| .button-ghost | .button-secondary.--ghost | Demoted, tertiary-emphasis secondary, not its own role |
Tag, 5 variants → 1 base + 4 composable modifiers
| v1.x (current) | v2.0 (planned) | Modifier role |
|---|---|---|
| .tag | .tag | Base, kept |
| .tag-accent | .tag.--accent | Color modifier (amber emphasis) |
| .tag-outline | .tag.--outline | Fill modifier (no fill, hairline border) |
| .tag-mono | .tag.--mono | Font modifier (Geist Mono for version/hash/ID) |
| .tag-pill | .tag.--pill | Shape modifier (full radius) |
Why this matters: in v1.x the doc says "use pill OR accent, not both." In v2.0 modifiers are composable, <span class="tag --pill --accent --mono"> just works. The system stops fighting itself.
Naming taxonomy unification
v1.x has 5 different variant-naming conventions across atoms. v2.0 picks one rule and applies it consistently:
| Component | v1.x | v2.0 rule |
|---|---|---|
| Button | functional names (primary, accent, secondary, ghost, icon) | Functional roles (primary, secondary, icon) + modifiers |
| Tag | mixed (color/fill/font/shape names) | Single base + composable modifiers |
| Hero | location names (--home, --page, --case) | Kept, heroes are inherently location-bound |
| Toast | state names (default, action) | Single base + behavior modifiers (--with-action) |
Rule for v2.0: Variants = peers (genuinely different roles). Modifiers = composable (shape, color, size, behavior). When in doubt → modifier.
Tutorial + FAQ.
A 5-minute walkthrough for first-time users, plus answers to questions that come up repeatedly.
Your first case study, 5-minute walkthrough
From "I want to add a new case study" to "live URL." Follow the five steps.
-
1
Add the project to
data.goAppend a new
Project{}struct ininternal/site/data.go. Required fields:Slug,Title,Client,Year,Teaser,HeroStatement,Role,Timeline,Status,VisualKind,Tags,Capabilities. The site re-reads on Go restart. -
2
Write the narrative blocks
Each case study has
Sections []Section, a sequence of{ID, Eyebrow, Title, Layout, Body, Bullets}. The case study template (templates/case.gohtml) renders them through.o-processautomatically. -
3
Add the project to a WorkGroup
Append
&projects[N]to the relevant group in theWorkGroupsinitializer (Enterprise SaaS / Fintech / Healthcare / Design Systems / Zero-to-One). The /portfolio index renders it automatically. -
4
Add metrics (or don't)
Add
SuccessMetrics []Metricentries withValue+Label. Placeholder values like", fill in"are auto-hidden, no debug strings will leak to the rendered page. -
5
Run + verify
go run ., the site rebuilds from the embedded files. Visit/portfolio/<slug>to see your new case study live. Total time: ~10 minutes from blank cursor to live URL.
Stuck? File an issue with the step number.
FAQ
Questions that come up repeatedly, documented once, here.
Can I use --bg or --ink in new code?
They work, but they're deprecated. Use --color-bg / --color-text-primary instead. The legacy aliases still cascade to the semantic tokens, but they'll be removed in v2.0. See Color section for the full migration table.
I need a button variant that doesn't exist. What do I do?
Don't add a fifth variant inline. Open an issue with the use case. We'll decide between (a) recomposing existing variants, usually the answer, or (b) adding a real new variant if there's a genuinely new role. Five variants is already at the upper limit Dan Mall recommends; six should be earned.
Why isn't this system on npm?
Plot is currently scoped to peter-tak-portfolio. Extracting it as a starter kit is on the v2.0 roadmap. The CSS layer is portable; the Go html/template markup layer would need adaptation for React/Vue/Astro. Email Peter if you want to fork it.
Can I override --color-action for a different brand?
Yes. Override --color-action, --color-action-hover, --color-action-text, and --color-action-focus at :root level. Component tokens read through the semantic layer, so a single override propagates to .button-accent, links, focus rings, and the highlight viz color simultaneously.
How do I report a bug or accessibility issue?
File a GitHub issue at peter-tak-portfolio/issues. For accessibility issues, use the a11y label so they get routed to the next audit cycle (cadence: every MINOR release, see Governance).
What's the difference between an atom, molecule, organism, and template?
Brad Frost's atomic-design framework, applied here:
- Atom: the smallest reusable unit. A button, a tag, an input. Composes nothing.
- Molecule: two or more atoms with a specific job.
.m-tag-cluster= several tags grouped semantically. - Organism: a complete UI region usable across pages.
.o-card-workrenders the same way on /home and /portfolio. - Template: the page-level layout pattern. "Filtered index" describes the /portfolio page shape, atoms / molecules / organisms compose into the template.
Why is the system named "Plot"?
A portfolio is a sequence of plot points, case studies. The system plots a course through them (the career arc). It also visually plots data (charts, metrics, motion graphics). And in old usage, a "plot" is a bounded territory, Plot's bounds are this portfolio. Short, memorable, references the work.
Every data-* attribute the system binds.
JavaScript-bound and consumer-set attributes used by components for state, filtering, and behavior. Three categories: user-set state, JS-bound triggers, and internal state markers. Anything not on this list is ad-hoc and should be migrated.
User-set state attributes
Attributes the consumer sets in markup or template logic. The system reads them; it does not change them.
| Attribute | Values | Effect | Where |
|---|---|---|---|
| data-loading | "true" | "false" |
Button shows inline spinner replacing leading icon; pair with aria-busy="true" for screen-reader announcement. |
.button |
| data-domain | String, work group title | Filter value consumed by the work-page filter row JS to show/hide cards matching the active chip. | .o-card-work |
| data-filter | String | "all" |
Chip filter target. "all" shows everything. Match string compared to data-domain on cards. |
.m-chip in .m-filter-row |
| data-work-group | String, group title | Marks a work-section as a filterable group. JS hides the whole section when no children match the active filter. | section wrapping .o-card-work grid |
| data-theme | "light" | "dark" |
Override OS-level color-scheme preference. Applied on <html>; persists in localStorage via the theme-toggle atom. |
html |
| data-icon | "sun" | "moon" |
Marks each SVG purpose for the theme toggle. CSS shows/hides the matching icon based on current theme. | svg inside .theme-toggle |
| aria-pressed | "true" | "false" |
Active state for filter chips. Pair with .is-active class. Required for screen-reader state announcement. |
.m-chip |
JS-bound triggers
Marker attributes the design-system JS scans for and binds behavior to. Add the attribute, get the behavior, no per-component init required.
| Attribute | Values | Behavior | Bound to | Demo |
|---|---|---|---|---|
| data-toggle-switch | (no value) | Click toggles aria-checked + .is-on class on the switch. |
.switch |
14b ↓ |
| data-tabs | (no value) | Binds keyboard navigation (← / → / Home / End) and tab-panel association via ARIA. | .m-tabs |
14d ↓ |
| data-copy-button | (no value) | Click copies the preceding <pre><code> contents to the clipboard. Updates label to "Copied" for 1.4s. |
.m-code-snippet-copy |
14h ↓ |
| data-fire-toast | (no value) | "action" |
Click fires a toast. "action" variant adds an action button. |
Any button |
14i ↓ |
| data-toast-close | (no value) | Click dismisses the parent toast (300ms exit transition). | Inside .o-toast |
14i ↓ |
| data-toast-action | (no value) | Click fires the toast's action callback then dismisses the toast. | Inside .o-toast |
14i ↓ |
| data-open-dialog | String, dialog id | Click opens the <dialog> with matching id. Uses native showModal(), focus trap and Esc-to-close are free. |
Any button |
14j ↓ |
| data-close-dialog | (no value) | Click closes the parent dialog. Equivalent to pressing Esc. | Inside .o-dialog |
14j ↓ |
| data-confirm-ship | (no value) | Demo-only: marks a dialog confirm button to also fire a "Shipped" toast on click. | Inside .o-dialog |
14j ↓ |
| data-comparison-input | (no value) | Binds the range input to a CSS custom property --comparison-pos on the parent .o-comparison. Drag updates the clip-path. |
input[type="range"] |
↓ |
| data-reveal | (no value) | Marks a section for scroll-triggered fade-in via IntersectionObserver. Adds data-reveal-init on JS load, then data-reveal-shown when the section enters the viewport. |
Any section |
, |
Internal state markers
Set by the system itself, not by the consumer. Listed here so a developer reading the rendered HTML can decode what's active.
| Attribute | Values | Set by | Effect |
|---|---|---|---|
| data-state | "enter" | "exit" |
Toast JS | Drives the @starting-style entry / 300ms exit transitions |
| data-reveal-init | (no value) | Reveal observer (motion.css) | Marks that JS has initialized the reveal observer for this element. Without it, content stays visible (no-JS graceful degrade) |
| data-reveal-shown | (no value) | Reveal observer (motion.css) | Fires on viewport entry. Triggers the fade + translateY animation. Set once per element (not toggled on scroll-out). |
| data-error | (no value) | Login form server | Set on the form when password validation failed. CSS shows the error message. |
Single-purpose helpers (.u-*).
For the patterns the system reaches for most often. Compose with block classes, never used as the primary styling layer for atoms / molecules / organisms (those have their own classes). Utility soup is an anti-pattern; utility surgery is the rule.
Spacing, margin-bottom / margin-top
Direct mapping to the spacing scale. The class suffix matches the token name: .u-mb-3 = margin-bottom: var(--s-3) = 12px.
| Class | Property | Range |
|---|---|---|
| .u-mb-1, .u-mb-9 | margin-bottom | var(--s-1) = 4px through var(--s-9) = 96px |
| .u-mt-1, .u-mt-8 | margin-top | var(--s-1) = 4px through var(--s-8) = 64px |
Reading width caps
Typography reading rhythm. Pair with body copy and section heads, never on cards or grid items where the layout dictates width.
| Class | Value | Use when |
|---|---|---|
| .u-max-w-22 | 22ch | Display headlines that should wrap to ~3 lines |
| .u-max-w-40 | 40ch | Hero ledes |
| .u-max-w-60 | 60ch | Body copy, the comfortable reading column |
| .u-max-w-72 | 72ch | Wide reading column for callouts and tables |
| .u-max-w-narrow | var(--w-narrow) = 680px | Long-form prose inside case studies |
Layout + text helpers
| Class | Properties | Use when |
|---|---|---|
| .u-col-stretch | display: flex; flex-direction: column; align-items: stretch | Override .example's row default for vertical stacks |
| .u-text-muted | color: var(--color-text-muted) | Muted aside copy in long-form passages |
| .u-text-body | color: var(--color-text-body) | Default body color override (rare) |
v1.0 had 191 inline style="…" attributes on this showcase page. v1.1 promotes the most-repeated patterns (margin-bottom, margin-top, max-width) into .u-* utilities and migrates the most common cases. Current count: ~113 remaining. Mixed-property inline styles (e.g., flex composition + padding + background) stay inline until the next batch of utilities ships in v1.2.
New components shipped in v1.1.
Atoms, molecules, and organisms added since v1.0, token-tier upgrade, kbd/code/switch/spinner/skeleton atoms, plus tooltip / tabs / section-divider / callout / empty-state / code-snippet molecules and toast / dialog organisms. These are documented provisionally below, full .ds-component-doc treatment lands in v1.2.
The components below use the v1.0 informal cluster-header style, not the .ds-component-doc framework that documents button, tag, input, link, card-work, hero-case, and hero (S2-S5, S9-S14 of this audit). Two patterns on one system page is the worst of both. Migration to the canonical framework is scheduled for v1.2 and tracked in the governance changelog. Each entry is marked Beta status to signal "production-ready visuals, provisional docs."
v1.0 used a single tier. v1.1 adds --gray-* and --amber-* primitives, then semantic tokens like --color-text-primary and --color-action-bg. Legacy aliases (--ink, --accent) still work, they reference the new semantic layer. Components migrate at their own pace.
/* Tier 1, primitive */
--amber-500: hsl(38 95% 50%);
/* Tier 2, semantic (references primitive) */
--color-action: var(--amber-500);
/* Tier 3, component (references semantic) */
.button-accent {
background: var(--color-action);
}
Inline code: the --accent token is now an alias for --color-action, defined at hsl(38 95% 50%).
// Build status
version: "1.1.0",
atoms: 46, // +5 since v1.0
molecules: 20, // +6 since v1.0
organisms: 15 // +3 since v1.0
Start with the task, then test the path with the people who use it.
Report observed outcomes separately from the intended benefit.
Record limitations and the next question to investigate.
This is sample content for testing tab navigation, not research evidence.
Section divider
Callout
This case study uses redacted and anonymized data per the original NDA. Specific metrics are within ±10% of actuals.
This is the case I'd open with in a portfolio review, it shows research depth, technical fluency, and measurable outcome in one piece.
Empty state
No cases match this filter
Try a different domain or browse the full archive, most case studies cover multiple disciplines and may show up under several filters.
Let people choose, correct and continue.
Four native controls and three interaction patterns fill the most immediate gaps in Plot. They reuse the site's type, spacing, color and field styles. These are local examples, not a connected service. New patterns remain Beta until tested in a real consumer workflow.
Checkbox
Choose several independent options. For consent, say exactly what is being accepted and leave the choice unchecked.
.plot-choice + input[type="checkbox"]Live anatomy
- 1 Group legend
- 2 Native checkbox
- 3 Clickable label
Tab reaches each enabled choice. Space toggles it. The disabled example is unavailable, not selected. For a single mutually exclusive choice, use radio buttons.
Copy the structure
<fieldset>
<legend>Include in review</legend>
<label class="plot-choice">
<input type="checkbox" name="review" value="requirements">Requirements
</label>
</fieldset>Radio group
Choose one option from a short, visible set. Keep all options in the same named group.
.plot-choice + input[type="radio"]- 1 Shared question
- 2 One checked value
- 3 Plain-language option
Tab enters the group. Arrow keys move between enabled options using native browser behavior. Selecting one option clears the other.
Copy the structure
<fieldset>
<legend>Review format</legend>
<label class="plot-choice"><input type="radio" name="format" value="guided" checked>Guided</label>
<label class="plot-choice"><input type="radio" name="format" value="independent">Independent</label>
</fieldset>Select
Choose one item from a bounded list when showing every option would take too much room. Use radio buttons when comparing a few choices is the main task.
.m-field + .plot-selectThis changes the selection only. It does not navigate or submit.
- 1 External label
- 2 Native option list
- 3 Associated help
Keyboard and touch selection follow the browser and operating system. A searchable multi-select needs a separate combobox implementation; this control is not one.
Copy the structure
<label class="m-field-label" for="role">Role</label>
<select class="plot-select" id="role" name="role">
<option value="">Choose a role</option>
<option value="designer">Designer</option>
</select>Textarea
Collect a longer explanation, comment or recovery note. Keep the label visible, allow vertical resizing, and explain any length limit before someone types.
.m-field + .plot-textareaUp to 500 characters. Example only; avoid private information. Nothing is submitted.
- 1 Question
- 2 Resizable text area
- 3 Limit and privacy context
Copy the structure
<label class="m-field-label" for="notes">Review notes</label>
<textarea class="plot-textarea" id="notes" name="notes"
rows="4" maxlength="500" aria-describedby="notes-help"></textarea>
<p id="notes-help">Up to 500 characters.</p>Validation & recovery
Explain what went wrong next to the field, preserve the answer, and offer a direct way to correct it. A color change or disappearing toast is not enough.
.m-field + .input + .plot-notice + .plot-field-errorTry checking an empty answer, then enter at least 3 characters. This local example makes no network request.
- 1 Focused error summary
- 2 Label and preserved input
- 3 Field-specific correction
- 4 Persistent outcome
The first check validates the answer. After that, editing updates the error without moving focus. Production forms also need server validation, authorization, duplicate-submit protection and a recoverable network-error state.
Implementation contract
Invalid: aria-invalid="true" + associated error text.
Submit: prevent duplicate requests; focus a summary of errors.
Edit: retain input; clear only errors that are resolved.
Success: announce actual success, not merely a button click.
Network error: retain input and provide an explicit retry.Progress & loading
Distinguish ready, loading, complete and failed states. Use a measured progress value only when the amount of completed work is known.
progress.plot-progress + .skeleton + role="status"State simulator, not a live operation. Change the state to inspect its presentation.
Ready. No operation is running.
- 1 Explicit state
- 2 Indeterminate or measured progress
- 3 Text outcome and recovery
Decorative skeletons are hidden from assistive technology. Text still explains the state when motion is reduced. Real content containers should use aria-busy while updating; keep the status announcement outside that busy region.
Copy the structure
<!-- No value means the amount of remaining work is unknown. -->
<progress class="plot-progress" aria-label="Loading results"></progress>
<p role="status">Loading results…</p>
<!-- Use an actual measured value for determinate progress. -->
<progress class="plot-progress" value="2" max="3" aria-label="2 of 3 files loaded"></progress>Disclosure
Keep secondary detail available without making it compete with the current task. Keep required instructions and unresolved errors outside collapsed content.
details.plot-disclosure + summaryWhat happens if evidence is missing?
The reviewer records the missing evidence and the next action. A complete-looking interface is not a substitute for a tested workflow.
Can more than one section stay open?
Yes. These independent disclosures allow comparison without closing the other answer.
- 1 Descriptive summary
- 2 Native expansion marker
- 3 In-flow detail
Enter or Space toggles the focused summary. No focus trap and no keyboard-triggered animation. Native HTML continues working without JavaScript.
Copy the structure
<details class="plot-disclosure">
<summary>What happens next?</summary>
<p>The explanation stays in the document flow.</p>
</details>Page-level layout patterns.
Five documented page patterns combine the same building blocks in different ways. Each entry includes a full-page drawing, its component composition and routes that use it. A new route can adapt a pattern without introducing a separate visual system.
Identity hero page
The home page. Split-layout opening (identity + motion graphic) followed by domain navigation, proof, and a closing CTA. Used once, for the landing page only.
/ · templates/home.gohtml
Make complex work easier to understand.
A short introduction, a clear next step, and work that shows the thinking.
Systems and interfaces
Shared patterns connect individual screens into a coherent experience.
Product design
Tasks, flows and decisions.
Design systems
Components and their behavior.
Composition and responsive behavior
Uses .m-hero-lede, .m-cta-pair, .button, .o-card-archetype and .o-feature-grid. The sample stacks at a narrow container width. The production hero also has its own motion slot.
- Hero
.o-hero--homewith status pill, name, lede, CTA pair, meta chips, and motion graphic - Sections
.o-sectionwith--bordered,--compact,--breathemodifiers - Cards
.o-card-archetypefor domain navigation - Stats
.m-statfor proof strip - CTA
.o-cta+.m-cta-pair
/, the home page (one instance only)
Filtered index
A directory page with a hero, filter chips, and grouped item grids. The hero opens; the filter row narrows; numbered group sections render the items. Used for the work archive.
/portfolio · templates/work.gohtml
Find a project by its focus.
2 example projects shown
An easier intake flow
Example card describing one focused task.
A shared component library
Example card describing reusable patterns.
Composition and interaction
Uses .m-filter-row, .m-chip, .tag and .o-card-archetype. This isolated demo filters two sample cards, keeps focus on the active filter and announces the result count. The production index uses linked work cards.
- Hero
.o-hero--page, Work eyebrow, page title, lede - Filter
.m-filter-rowwith.m-chip[data-filter], see Behavior hooks for filter binding - Group section
.o-section--bordered+.m-eyebrow-title--numbered+.ds-grid.ds-grid-2 - Cards
.o-card-workwithdata-domainattribute consumed by the filter JS
/portfolio, five group sections, 15 cards total
Case-study reading
A long-form case study. Opens with breadcrumb + title + lede + visual + meta. Body is a sequence of named narrative blocks (context, research, design, system, validation, outcome). Closes with impact framing, artifacts, and next-case navigation.
/portfolio/<slug> · templates/case.gohtml
Make the next step clear.
Show the problem, the decision and the evidence behind the change.
The problem
A polished screen can hide an undefined recovery path. Start with the task and its failure conditions.
Evidence to show
- The original workflow
- The proposed change
- The test and its limits
Report measured results here only when the evidence exists.
Composition and reading order
Breadcrumb, hero text, metadata, narrative and evidence, then outcome. Uses .tag, .m-hero-lede, .o-feature-card and .m-callout. Stacked reading order preserves the narrative on small screens.
- Reading progress
.o-reading-progress, fixed-top amber bar, JS-driven via--reading-progresscustom property - Hero
.o-hero--case, full case-study hero with breadcrumb, head, grid, meta - Narrative blocks
.o-process, repeated for each named section (context, research, design, system, validation, outcome) - Impact framing
.m-metricgrid with the case's measured outcomes - Artifacts
.o-artifact-gridwith key research / design / spec artifacts - Closing
.m-link-cardfor next-case navigation,.o-ctafor contact
/portfolio/<slug>, 15 case-study pages
Utility page
Light-content pages without a filter row or narrative arc. Hero opens, content section delivers the message, closing CTA invites action. Used for About, Contact, and the 404.
/about · /contact · /404
Start with the question.
A short introduction gives the visitor enough context to choose a next step.
A local field demonstration. This text is not included in the contact link.
Composition and boundaries
Uses .m-field, .input, .m-cta-pair and shared type roles. The field is a specimen, not a submission form. Avoid a sidebar when this short page does not need one.
- Hero
.o-hero--page, generic page-hero variant - Sections
.o-section--borderedwith.m-eyebrow-title--numbered - About body
.m-career-entry× N +.o-feature-gridfor capabilities - Contact body
.o-contact-grid+.m-fieldform fields - 404 body
.m-empty-state+.m-cta-pair
/about, /contact, /404
System showcase
A documentation layout with persistent contents, a bounded reading column and individual reference sections. This page uses a jump-to palette; the H.A.R.D. Protocol Library adds criterion search and grouped setup instructions.
/design-system · templates/design-system.gohtml
A primary action
Keep the label specific to what happens next.
When should I use it?
Use one primary action for the current task. Secondary actions should remain available without competing for attention.
<button class="button button-primary">Composition and navigation
Uses native navigation, .button, .example and .plot-disclosure. The page's full search remains in the left rail or the mobile jump-to palette. This small specimen does not introduce another search box.
- Section structure
.ds-sectionwith.ds-section-head, own scaffolding, distinct from.o-section - Doc framework
.ds-component-docfor every documented atom / molecule / organism / template (S2 framework) - Tier visualization
.ds-chain,.ds-token-list,.ds-composition,.ds-token-table - Examples
.ds-tier,.ds-tier-badge,.swatch,.ds-grid
The design-system page and H.A.R.D. Protocol Library share the contents-and-reference scaffold. Their controls differ by task. Reuse is not evidence of usability validation; the documentation pattern remains provisional.
Design system and H.A.R.D. Protocol Library (with criterion search).
Interaction shipped, not specified.
A comparison slider built with a native range input, a CSS custom property and clip-path. A small input handler updates the split. Try dragging or using the arrow keys.
A range input updates a CSS custom property --comparison-pos. The "after" panel uses clip-path: inset(0 0 0 var(--comparison-pos)). The handle uses the same custom property to position. Drag, click, or use arrow keys after focus.
Versioning, component status, audit cadence.
A design system is a product that serves other products. It needs the same rigor: a versioning policy, an explicit status for every component, audit cadence, and a public changelog. Below is the contract.
Versioning policy, semver
The system follows semantic versioning. The current site documentation and additive component release is 1.3.0. Existing classes and tokens remain compatible. This is not a separately published package. Three rules govern what each digit means:
| Bump | Triggered by | Examples |
|---|---|---|
| MAJOR (2.0) | Breaking visual or API change. Renaming a class, removing a token, changing a component's anatomy in a way that breaks consumers. | Removing legacy aliases (--bg, --ink); renaming .button-accent |
| MINOR (1.x) | New components, new variants, new tokens. Existing API stays compatible. | Adding .button-icon variant; introducing --card-* tokens |
| PATCH (1.1.x) | Bug fixes, polish, accessibility patches. No API change. | Fixing focus-ring contrast; adjusting hairline opacity; correcting alt-text guidance |
Component status legend
Every documented component carries a status badge in its .ds-component-doc header. The badge tells consumers whether they can build with confidence or should expect change.
| Status | Meaning | Production use |
|---|---|---|
| Stable | API frozen. Breaking changes require a MAJOR release with a deprecation period. | Yes, build with confidence |
| Beta | Production-ready but the API may shift in MINOR releases. Watch the changelog. | Yes, with awareness |
| Experimental | Use at your own risk. May be removed without notice. Not part of the contract. | Prototype only |
| Deprecated | Slated for removal in the next MAJOR release. Migrate to the named replacement. | Migrate ASAP |
Audit cadence
Audits keep the system honest. Each audit type has a fixed cadence and a defined scope.
| Audit type | Cadence | Scope | Last run |
|---|---|---|---|
| Accessibility | Every MINOR release | WCAG 2.1 AA compliance: contrast, keyboard nav, ARIA, reduced-motion, screen-reader announcements per documented component | v1.1, 2026-05-03 |
| Browser support | Quarterly | Modern evergreen + Safari 17+. Verifies @starting-style, :has(), view-transition fallbacks degrade cleanly. |
v1.1, 2026-05-03 |
| Token integrity | Every MINOR release | No component reads past Tier 2 (semantic). Component CSS uses Tier 3 tokens or escalates the request. | v1.1, 2026-05-03 |
| Performance | Quarterly | CSS bundle size, paint cost, animation frame budget. Targets: CSS < 60kb gzipped, hero LCP < 1.8s on 3G Fast. | , |
| Adoption | Monthly | Where each component is used across the site. Components with zero usage become removal candidates in next MAJOR. | v1.1, 2026-05-03 |
Adoption · v1.0 → v1.2 trend
Where each canonical component renders across the live site, with growth measured release-over-release. A component with zero usage and no upward trend is a deletion candidate at next MAJOR.
| Component | Status | v1.0 | v1.1 | v1.2 | Trend | Goal |
|---|---|---|---|---|---|---|
| .button | Stable | 12 | 22 | 28 | ↑ +133% | 30 by v1.3 |
| .tag | Stable | 45 | 72 | 85 | ↑ +89% | 100 by v1.3 |
| .input | Stable | 2 | 4 | 4 | → stable | 12 (every form field) |
| .link family | Stable | 26 | 34 | 40 | ↑ +54% | 50 by v1.3 |
| .o-card-work | Stable | 11 | 15 | 15 | → stable | 15 (matches case count) |
| .o-card-archetype | Stable | 5 | 5 | 5 | → stable | 5 (one per domain) |
| .o-hero (unified, S9) | Stable | , | 20 | 22 | ↑ new | 22 (matches route count) |
| .o-toast | Experimental | , | 1 | 1 | → showcase only | Promote to Beta when used in production |
| .o-dialog | Experimental | , | 1 | 1 | → showcase only | Promote when first production use |
| legacy aliases (14 tokens) | Deprecated | ~210 | ~150 | ~100 | ↓ −52% | 0 by v2.0 |
Contribution model
How a new component, variant, or token gets into Plot. The bus factor is currently 1; documenting the model is the first step toward 2+.
| Step | Action | Owner |
|---|---|---|
| 1. Propose | Open an issue with use case + proposed API + acceptance criteria. Include "what existing component is closest, and why isn't it sufficient?" | Anyone |
| 2. Triage | Decide: "Add new" / "Recompose existing" / "Reject, too project-specific." Most proposals end up at recompose. | Steward (Peter) |
| 3. Spec | Draft the .ds-component-doc sections, anatomy, variants, states, a11y, tokens, code, do/don't, before any CSS. | Proposer + Steward |
| 4. Build | Implement CSS + HTML in the relevant tier file. Wire up Tier 3 component tokens. Document the new entry in the changelog. | Proposer |
| 5. Ship | MINOR release. Status: Beta for one release. Promote to Stable in the next MINOR if no production issues. | Steward |
Current bus factor: 1. Single steward (Peter). Target: 2 (designer + engineer pair, the SuperFriendly "System DM" model). Documentation completeness audit before next MAJOR is the path there. File a contribution issue to get involved.
Success criteria for v1.x
Plot v1 is successful when these are all true. Reaching all four triggers v2 planning.
| Criterion | Status (v1.2) |
|---|---|
| 100% of canonical components reach Stable status (no Beta on Atom or Organism tier) | ⚠ 9 of 12 Stable (Hero family + 3 Beta) |
Every page on the live site uses canonical components, zero raw <button> with custom styles | ✓ Achieved at v1.1 |
| Time-to-implement: a new case study renders in < 10 min from data file → live URL | ✓ ~10 min measured |
| Bus factor: docs sufficient for a new contributor unfamiliar with Peter to ship a component without his help | ⚠ Aspirational, needs validation by an external trial |
Time-to-implement
Consumer estimates for the most common paths. Numbers are rough but measured against actual workflow on this codebase.
| Task | Estimate |
|---|---|
| Use existing button on a new page | ~30 seconds |
| Add a new tag with existing variant | ~30 seconds |
| Customize a hero variant on a new page | ~5 min |
| Add a new case study (existing template) | ~10 min (data file + run server) |
| Add a new component variant (e.g., new button modifier) | ~30 min (CSS + doc + token) |
| Propose a new component (issue → spec → build → ship) | ~2 hr proposal + 4–8 hr implementation |
| Onboard a new contributor (read this page + run locally) | ~1 day |
Risk register
Public list of known risks with mitigation strategy. Listing risks publicly is more trust-building than pretending none exist.
| Severity | Risk | Mitigation |
|---|---|---|
| HIGH | Bus factor 1, single steward. If Peter is unavailable, contributions stall. | Documentation completeness audit before next MAJOR. Goal: bus factor 2 by v2.0. |
| MEDIUM | v2.0 will remove 14 legacy alias tokens. Existing consumers using --bg, --ink, --accent will break. | Deprecation table published in Section 01. 6-month minimum deprecation period. Migration guide planned for v1.3. |
| MEDIUM | Aeonik is licensed per-user with a 2027 renewal. License expiry would force a fallback. | Fallback stack defined in --font-display. Site degrades to Helvetica Neue / Arial. No layout breakage on fallback verified at v1.1. |
| LOW | Toast and Dialog organisms are Beta, only used in /design-system showcase. Production behavior unverified. | Promote to Stable only after first production usage with at least one consumer-facing test path. |
| LOW | System is scoped to Go html/template + vanilla JS. Framework-agnosticism unverified for React/Vue consumers. | CSS layer is portable. Templates would need adaptation. Disclosed in Scope below. |
Scope
Honest disclosure: Plot is purpose-built for this portfolio, not a multi-tenant component library.
Plot is purpose-built for this portfolio. It is not currently packaged for external use (no npm, no CDN). The CSS layer is framework-agnostic (vanilla CSS + custom properties), but the markup layer consumes Go html/template.
Extraction path: tokens, atoms, molecules, organisms are portable. Templates would need adaptation for React / Vue / Astro / etc. Fork the repo if you want a starter kit, happy to discuss.
Internationalization: English (en-US) primary. Korean (ko) supported via font-family fallback (Aeonik covers Hangul; Geist Mono falls back to system mono for code blocks). RTL not tested.
Changelog
September 25, 2026: media example containment
Vertical example frames now use a single non-wrapping column. The Media showcase stays inside its frame without overlapping the next component. Placeholder aspect ratios apply to the visual area; captions keep their own content height on narrow screens. Horizontal examples still wrap. No content is clipped and no fixed height is imposed.
Plot 1.3.0 · September 25, 2026
Added contextual search in the left rail and command palette, seven form and feedback entries, five live template compositions, and a source-linked coverage review. Anatomy now uses live components. Search focus no longer stacks outlines and shadows; the compact shortcut keeps a separate click target.
Fixed the tab example's missing panels and keyboard navigation, labeled the switch examples, and reconciled the current version display. Wide reference tables now have their own keyboard-accessible scroll regions. The mobile search button uses contrasting theme colors. Existing classes, routes and semantic tokens are preserved. No consumer page is automatically migrated.
New patterns are Beta. This release does not establish user validation or whole-site accessibility conformance. The older v2 consolidation section is a proposal, not the current version.
September 24, 2026: navigation, drawings and theme correction
Individual molecule, organism and template links; eleven native SVG anatomy/layout drawings; shared theme corrections; and a focus-contained component search. The HARD pages reuse the same components.
Selected layout, keyboard and contrast checks do not establish whole-site accessibility conformance or user validation.
Read the website update logPublic history of every release. New entries land at the top. Each entry follows added · changed · deprecated · removed · fixed · accessibility.
Refactor .o-hero-home, .o-hero-page, .o-hero-case into one .o-hero with modifiers (S9 of this audit). Introduce template-tier documentation (S10). Add real Storybook-style prop dialogs per component.
Added · Tier 3 component tokens (--button-*, --card-*, --input-*, --tag-*) · .ds-component-doc documentation framework · per-component anatomy / variants / states / a11y / token-chain docs for button, tag, input, link, card-work, hero-case · Do/Don't usage rules for button, tag, hero-case · governance section.
Changed · Token documentation reorganized: semantic tier is now canonical; primitives moved into a collapsed <details> reference; legacy aliases moved into a deprecation table with explicit migration targets · .button rewired to consume Tier 3 tokens · numbered eyebrows demoted from --accent-text to --muted per Ive critique · hairline opacity scale collapsed from 6 values to 3 (0.06 / 0.18 / 0.32).
Deprecated · 14 legacy alias tokens (--bg, --surface, --ink, --hairline, --accent, etc.), slated for removal in v2.0. Migration table available in Section 01.
Fixed · case-study hero reveal cascade replaced with single 420ms fade per element block · case-study metric placeholders (", fill in") now hidden from view; real measured values populate the impact-framing section · self-initiated category and badge removed from the work index.
Accessibility · documented per-variant contrast ratios, screen-reader announcements, keyboard interactions, and reduced-motion behavior for every component in v1.1. WCAG 2.1 AA verified across the canonical 6.
Added · Initial release: tokens, reset, atoms (41), molecules (14), organisms (12). Light + dark mode parity. Aeonik + Geist Mono. Two-tier amber accent (--accent surface vs --accent-text AA-compliant). Custom Emil Kowalski easing curves.
Notes · v1.0 documentation showed components in isolation without anatomy, variants matrix, states matrix, a11y receipts, or token chains. v1.1 audit (Sessions 1–6) addresses these gaps.
What Plot covers, and what it still needs.
A focused comparison against four official systems. Their platform scope and scale differ. This review identifies useful patterns for this website; it does not claim component parity, affiliation or a complete accessibility audit.
| Reference | Useful emphasis | Plot before 1.3 | This release |
|---|---|---|---|
| Google Material 3 | Components grouped by purpose, including selection, communication and input. | Editorial components were easier to find than task controls. No documented native selection suite or progress element. | Checkbox, radio, select, textarea and a state-based progress example. |
| IBM Carbon | Search scope, anatomy and clear behavior. Its form guidance connects labels, helper text and actions. | Jump-to search matched titles and categories only. Error styles lacked a complete correction example. | Contextual sidebar search, clear and no-result states, and an input-preserving validation flow. |
| Microsoft Fluent 2 | Distinct field, message, disclosure and progress building blocks. | Existing tabs changed selection styling without associated panels. Loading primitives existed but lacked a state walkthrough. | Linked tab panels with keyboard navigation, native disclosures and loading/success/error inspection. |
| Apple HIG | Adaptable text, familiar interaction, multiple ways to perceive status, and contrast in both appearances. | Theme tokens existed. New examples still needed labels, touch targets and explicit focus and motion rules. | Native controls, text-based status, visible focus, 44px form targets and reduced-motion treatment. Device and assistive-technology testing remains necessary. |
Open gaps, in priority order
| Priority | Gap | What is needed before adding it |
|---|---|---|
| Next | Production forms and async error recovery | Connect to an actual task, validate on the server, test retry and duplicate submissions, and record assistive-technology results. |
| Next | Searchable combobox, upload and date range | A real consumer need and tested keyboard, invalid, empty and loading states. Native select is not a substitute for a combobox. |
| Next | Component-level test and source catalog | Executable stories, visual regressions, public test receipts and consumer references for each component, not only page-level checks. |
| When needed | Data table sorting, pagination, menus, trees, drawers | Task and dataset requirements, row/selection behavior and focus restoration before building a larger application suite. |
| When needed | Localization, RTL and multi-brand themes | Translated content, directionality tests and theme consumers. Korean search aliases are not full localization. |
Six component specimens and five template compositions use real Plot classes and native controls in place of the earlier box drawings. The Work card shares its template with the production Work page and uses public research content for the specimen. The smaller template compositions illustrate behavior rather than duplicating entire routes.
Interaction implementation used Emil Kowalski's emil-design-eng skill. Search and keyboard navigation respond immediately. Pointer feedback is brief, and reduced-motion preferences remove movement.