rafaelborges.design · design system reference

Lime on Deep Green

The whole portfolio runs on two colours, two typefaces and one idea: every component inherits its palette from the surface it sits on. This is what exists today, documented from the source — plus an audit of where the system holds and where it leaks. All fourteen findings were fixed on 2 September and are marked Fixed below.

Source · styles/style.css, styles/case.css Pages · 2 Read · 2 Sep 2026
42
tokens in :root, none unused
11
components in use
15.4:1
core text contrast
96/100
system health score

01Foundations

Everything derives from two HSL values. There is no third brand colour, no neutral ramp and no elevation scale — depth comes from borders and surface inversion instead of shadow.

Colour

primary
--color-primary
hsla(64, 96%, 81%, 1)
#F7FDA0
Text and borders on dark. Becomes the background on light surfaces. The only accent.
secondary
--color-secondary
hsla(95, 50%, 9%, 1)
#15220B
The page ground. Text and borders on light surfaces.
transparency
--transparency-primary
hsla(64, 96%, 81%, 0.3)
The active nav pill, and the hairline above the case outro.
nav scrim
--nav-scrim
hsla(95, 50%, 9%, 0.85)
Sticky nav and case breadcrumbs. 0.85 holds labels at 9.8:1 even where the lime hero passes underneath.

Aliases and derived tokens

TokenResolves toRoleUses
--color-bg-dark--color-secondaryGround of .dark and body3
--color-text-on-dark--color-primaryInk on dark surfaces3
--color-bg-light--color-primaryGround of .light2
--color-text-on-light--color-secondaryInk on light surfaces2
--focus-ring--color-primary, flipped by .lightSurface-aware focus outline5
--card-border--color-primary, flipped by .lightSurface-aware card border1 (every card)
--opacity-hover0.8Pointer over the control6
--opacity-active0.65Pressed6
--opacity-disabled0.5Unavailable. 4.82:1 on the dark ground — WCAG exempts inactive controls, but a disabled label you can't read is still a bad label.4
--color-negative#fd6b6bA metric that moved the wrong way. No --color-positive: good news already reads in the brand yellow.1

Contrast

PairRatioWCAG AAAAA
#F7FDA0 on #15220B — body text on dark15.42:1PassPass
#15220B on #F7FDA0 — text on the lime hero15.42:1PassPass
#FD6B6B on #15220B — the one-off negative-result red5.92:1PassFail
The palette is the system's strongest asset. A 15.4:1 ratio in both directions means no component can accidentally fail contrast as long as it takes its colours from the surface — which is exactly how the surface classes are built.

Surfaces

.dark and .light are the system's central mechanism. They are not themes in the usual sense — both appear on the same screen. A component doesn't choose its colours; it inherits them from whichever surface class wraps it.

.dark

bg --color-bg-dark · ink --color-text-on-dark

Default. Applied to body, all case-study containers except the intro, and every icon button.

.light

bg --color-bg-light · ink --color-text-on-light

Hero banner, contact banner, case intro, modal panel. Also flips --focus-ring to the dark green so the ring stays visible.

The specificity trap. .btn deliberately sets no background. .btn and .dark have the same specificity, so a background declared on .btn would win by source order and blank out every themed button. Keep backgrounds on the surface classes only.

Typography

Two faces. Tanker (self-hosted, single 400 weight) carries every display moment; Sora (variable, Google Fonts) does everything else across six weights. Tanker loads with font-display: block rather than swap — the hero wordmark is sized to Tanker's metrics, so painting a fallback at that size would overflow the banner for a few frames.

h2 · displayTanker 400 · 120px
line-height 100%
72px @ ≤768
I made this
.casetitleTanker 400 · 60px
line-height 100%
40px @ ≤425
Comprehensive Insurance
p.specialTanker 400 · 32px
line-height normal
How might we streamline the selection?
h3 · sectionSora 800 · 32px
line-height 100%
Project Overview
.casesubtitleSora 600 · 24px
line-height 100%
Driving adoption and revenue growth
p.case · leadSora 300 · 20px
line-height 28px
16px @ ≤425
Helping travellers understand their protection options and choose confidently.
bodySora 200 · 16px
line-height 28px
24px @ ≤425
I design digital products that turn uncertainty into clarity.
.btn · controlSora 400 · 16px
line-height 24px
Read More
.btn-menu.smallSora 300 · 14px
line-height normal
Learnings and Outcomes

The scale, now tokenised

Named by role, not size — the same step is reused at different breakpoints, so a size-based name would go stale the first time one moved.

TokenValueUsed by
--text-caption14px.btn-menu.small (breadcrumbs)
--text-body16pxbody, .btn, hero paragraphs, insight subtitle & description
--text-body-lg20px.btn-menu, p.case lead, contact heading ≤600
--text-title-s24px.casesubtitle, insight title, .subhead
--text-title-m32pxh3, .case-content h2, p.special
--text-title-l40pxblockquote, a.logo, .casetitle ≤425
--text-display-s60px.casetitle
--text-display-m72pxh2 ≤768
--text-display-l120pxh2
--leading-display100%the seven display headings

Four literals remain, all deliberate: 1.4rem / 0.95rem in the case modal (rem-based so it scales with the root — though neither resolves to a scale step), the 18px close-button glyph, and an 18px nudge on the contact heading at ≤425. Px line-heights stay literal: they're tied to the font-size beside them and don't generalise.

Weights

WeightWhereDeclarations
200 · ExtraLightBody copy, insight-card subtitle, work note5
300 · LightCase lead paragraphs, small menu links3
400 · RegularTanker display, buttons, card descriptions9
600 · SemiBoldstrong, menu links, hero paragraphs5
700 · BoldInsight-card title only1
800 · ExtraBoldSection headings, blockquote3
The request is now wght@200..800 — 100 was never used. Narrow the range rather than listing discrete weights: for a variable family a list of values can produce more files, not fewer.

Spacing

The number is the multiple of 4, so gaps in the sequence are meaningful rather than missing. 127 references across the two stylesheets. The bar shows each step to scale; the figure on the right is how often it is used.

--space-14px
--space-28px
--space-312px
--space-416px
--space-520px
--space-624px
--space-832px
--space-1040px
--space-1248px

The scale was rebuilt from usage data rather than guessed: --space-1 and --space-5 were added because 4px and 20px were already in use with no token, --space-9 (36px) was deleted because nothing referenced it, and 28px is absent because nothing uses it.

44px left the scale. It appears seven times and every one is a width or height on an interactive control. It is --control-height now, and it is 44px because that is the WCAG 2.5.5 minimum touch target — not because it is a step of the scale. Filed as --space-11 it made the scale look like it had a step nothing ever used.

Where the scale stops

It governs component rhythm — padding, gaps and margins inside buttons, cards, modals and content blocks. Section-scale numbers stay literal on purpose.

Still literalUsesWhy
56 · 60 · 64 · 80 · 92 · 100 · 120 · 176 · 2081–4 eachEach appears once or twice in an unrelated place — a column gap here, a section padding there. Tokenising them would invent a shared rhythm that doesn't exist.
10 · 181 eachOff the 4px grid. A modal paragraph margin and the contact menu gap. Left alone rather than silently nudged to 8/16 — that would be a visible change, so it's a decision to make rather than a cleanup.
014Not a scale value.

The rule for new work: if a value repeats three or more times across components, add it to the scale. Otherwise leave it literal.

Radius & border

--radius-s28px · hero ≤600
--radius-m32px · hero ≤768
--radius-l36px · unused
--radius-xl40px · hero, case container

Fifteen further radii are hardcoded: 100px for every pill control (×5), 24px for cards (×3), 50% for modal close buttons (×2), plus 40px, 16px, 8px, 4px and 40px 40px 0 0. The pill and card radii are real, recurring decisions and belong in the scale.

Border weight

1.5px is the system weight — buttons, icon buttons, cards, insight cards and the outro rule all use it. Two deliberate exceptions: the About portrait frame at 6px, and the image-modal close button at 1px. Sub-pixel borders round inconsistently across browsers at some zoom levels, so 1.5px is a choice to keep, not to rediscover.

Motion

TokenValueUsesApplied to
--easecubic-bezier(0.2, 0.7, 0.2, 1)8every transition
--t-fast150ms7hover feedback, both modal close buttons
--t-med300ms1the .btn-menu.active pill background

Two speeds, deliberately. Opacity is direct feedback for the pointer, so it is fast. The one use of --t-med is the nav pill background, which the scroll-spy toggles as the page moves rather than in response to a click — ambient state reads better a little slower. .btn-menu therefore carries both.

The transition sits on the base rule rather than on :hover, so it eases in and out. Only opacity and background-color animate — focus outlines are deliberately excluded, because focus should be instantaneous.

The one substantial animation is the case page's scroll-driven parallax: each .parallax-section translates its content by (scrollY − sectionTop) × (1 − speed), with speed 0.5 on desktop and 0.8 below 1024px. The script returns early under prefers-reduced-motion: reduce and binds no listeners; case.css also clears any applied transform, so a preference change mid-session settles the page rather than freezing it mid-parallax.

Reduced motion

style.css carries a global guard — * with transition-duration: 0.01ms !important and the same for animations and scroll-behavior. Because it loads on both pages it covers the homepage too, which previously had none at all. Near-zero rather than none so transitionend still fires and nothing waiting on it can hang.

CSS can't reach the homepage's anchor scrolling, which is JS (scrollIntoView({behavior: "smooth"})). That reads the preference itself and passes "auto" instead — checked per click rather than cached, so it follows the setting if it changes mid-session.

02Components

Eleven components, previewed below with their real declarations. Each sits on the surface it was designed for — which, since the card merge, is the only thing that decides how any of them are coloured.

Button · .btn

The primary action. A 44px pill with a 1.5px border and an optional trailing arrow icon. Colour comes entirely from the surface class.

.btn.darkon --color-bg-dark
Read More Hover · opacity .8 Focus-visible
.btn.light & .btn.secondaryon --color-bg-light
Read More Contact me Focus-visible

Both rows take their border from currentColor, so the edge follows the surface. Until 2 Sep it was hardcoded to the yellow, which is why the lime row's first button had no visible edge at all.

VariantUse whenNotes
.btn.darkAny action on the green groundThe default. Used 5×.
.btn.lightActions on the lime hero or bannerNow usable — not yet used in markup.
.btn.secondaryThe lower-priority action beside a primaryDrops the surface background only; the border comes from .btn.

States — all five, on every control

DefaultRead More
Hover · 0.8Read More
Active · 0.65Read More
Focus · 2px ringRead More
Disabled · 0.5Read More
Why the border is currentColor. It used to be var(--color-primary), so a .btn on a lime surface drew a lime-on-lime border and read as a floating text label — which is why .btn.light was declared but never usable. Taking the ink the surface class already set makes the border follow the surface like everything else, with identical rendering on dark surfaces.
Disabled is written as :disabled, [aria-disabled="true"] because most of these "buttons" are anchors, which cannot carry the disabled attribute. Both forms resolve identically — verified in the browser. Focus is deliberately excluded from the transition: it must be instantaneous.

Icon button · .btn-icon

A 44×44 circle holding a single SVG. Two sizes: 44px everywhere, 56px in the contact banner (dropping back to 44px below 600px). Always carries an aria-label; the icon inside is alt="".

.btn-icon44px · 56px
in

Menu link · .btn-menu

Navigation only. The .active state is applied by the scroll-spy script and pairs with aria-current="true". The .small modifier is the case-page breadcrumb.

.btn-menu · navon --nav-scrim
Work About Contact
.btn-menu.small · breadcrumbscase page, fixed bottom
Intro· Project Overview· First Iteration· Second Iteration· Final Solution

Text button · .btn-link

A link-shaped control that sits inside running copy. Underlined at 4px offset, transparent, inherits its font. Two uses, and they are different elements:

ElementWhere
<button>The "more on the way" trigger in the work note — it opens a dialog, so it is a button.
<a>The design system link in the footer colophon — it navigates, so it is an anchor.
.btn-linkinline, in running copy
One case study for now — more on the way
a.btn-linkon --color-bg-light
©2026 - Designed and coded by Rafael Borges (Design system)
On an <a> this class silently loses its underline. The reset sets a:link { text-decoration: none }, which is (0,1,1) and beats the bare class at (0,1,0) — so the only affordance the link had disappears. a.btn-link restores it at matching specificity, later in the file. Any component class used on an anchor needs the same check.

Card · .card

An icon-plus-label block for impact figures. One class, both grounds. The border comes from --card-border, which defaults to the yellow and is flipped to the dark green by .light — the same mechanism as --focus-ring. A card nested inside a .light block inherits the flip, so it is correct by construction rather than by remembering which class to type.

.cardon --color-bg-light

+404k
policies sold

€605k
additional revenue

40%
growth in adoption

the same .cardon --color-bg-dark

+404k
policies sold

€605k
additional revenue

Verified in the browser: the three intro cards resolve to rgb(21, 34, 11), the overview and learnings cards to rgb(247, 253, 160) — identical to the two-class version. This used to be .card plus .card-light, byte-for-byte identical except for border-color, with inverted names: .card-light was the variant for dark surfaces.

Insight card · .insight-card

The case study's signature block — a behavioural-design principle, tagged, named and explained. The only component with a gradient fill: a 45° wash from transparent to rgba(247,253,160,0.15).

.insight-cardon --color-bg-dark

Insight

Decoy Effect
A well-structured option can bring clarity without pressure.

When users are choosing between alternatives, the addition of a less attractive option can influence their perception of the choices.

The subtitle uses line-height: normal, not a number. It was 16px against a 16px font — a 1.0 ratio, so the two-line subtitles had their ascenders and descenders nearly touching. normal is what .description directly below already uses, so the two text blocks in the card share one rhythm, and it is Sora's own metric (measured: 20px at 16px) rather than a magic number that would go stale if the face changed.

Case figure · .case-figure

The one image component a case section uses: a mockup sitting beside the copy. Two variants — .case-figure is zoomable and opens the lightbox; .case-figure.is-static is not, and is used once for the intro cover.

Layout lives on the <figure>, not on the button or the image. That is the point: both variants occupy an identical box whether or not there is a button in the middle, so a section reads the same either way.

markup for a new case studycopy this
<figure class="case-figure">
  <button type="button" class="case-figure__zoom js-zoom-image"
          aria-label="Enlarge image: <describe it>">
    <picture>
      <source type="image/webp" srcset="/assets/images/Name.webp" />
      <img class="case-figure__img" src="/assets/images/Name.png"
           alt="<describe it>" decoding="async" />
    </picture>
  </button>
</figure>
The before/after labels are baked into the image pixels. "MVP" and "1st Iteration" are artwork, not text — they can't be selected, translated or read aloud, and the alt string is the only machine-readable version of them. For a new case study, prefer real captions: this component has room for a <figcaption> under the image, and the labels would then be actual content.
Renaming removed a collision. These were .mockup, which is also a homepage class (#work .case-cover .mockup) — two unrelated components sharing one name across two stylesheets.

Modal · .case-modal and .image-modal

Two modals, built twice. The case modal is a proper dialog: focus trap, role="dialog", aria-modal, aria-labelledby, Escape to close, and focus returned to the trigger — deliberately skipped when the modal closes on its way to #contact, so restoring focus doesn't fight the smooth scroll.

.case-modal__contentradius 16px · shadow 0 18px 45px
.case-modal.image-modal
role / aria-modalYesYes
Focus trapYesYes
Focus returned on closeYesYes
Escape closesYesYes
Backdrop closesYesYes
Body scroll lock.case-modal-open.modal-open
Opened bybutton.case-figure__zoom button
The lightbox sets its image from img.currentSrc, so it enlarges the WebP the <source> actually served rather than re-fetching the PNG, and copies the source image's alt across. Two lock classes still exist for one behaviour.case-modal-open and .modal-open. One modal component with a variant would collapse this table into a single row; worth doing when a third modal appears, not before.

03Patterns

Recurring solutions that aren't components — the conventions that make the pages behave consistently.

Responsive images

Every photograph ships as WebP with a PNG fallback via <picture>. Three rules make it work invisibly:

RuleWhy
picture { display: contents }Keeps the <img> as the flex/grid item, so every existing layout rule still targets it.
picture source { display: none }<source> computes to display:block; contents promotes it to a grid item, silently adding a row to every container holding an image.
%20 in srcsetA raw space in a srcset is a descriptor separator. Filenames with spaces must be percent-encoded or the WebP is silently dropped.

Above the fold: fetchpriority="high", no lazy loading. Below: loading="lazy" — but never on a parallax element, whose transform depends on a known offsetHeight.

Section heights

Homepage sections cover the viewport rather than being fixed to it: min-height: calc(100svh − navHeight), with the 100vh line first as the fallback. Nav height is 100px above 1024px and 60px below, and scroll-margin-top matches it so anchor landings sit just under the bar.

Scroll spy

Both the homepage nav and the case breadcrumbs highlight the last section whose top has crossed a line 40% down the viewport, with the final item forced active at the bottom of the page.

Why not IntersectionObserver. The original used a 0.5 threshold, which can never fire on a section taller than the viewport — so the highlight stuck on the wrong item. The scroll-position approach has no such floor.

Hero wordmark sizing

The hero name is CSS-sized, not measured by JavaScript. #hero-banner is a container (container-type: inline-size) and the title is clamp(28px, 19.5cqw, 320px). "Rafael Borges" in Tanker at -0.03em advances ≈4.92× its font-size, so 20.3cqw would fill the line exactly; 19.5cqw is the largest value that never overflows once letter-spacing rounds to whole pixels — worst-case fill 98.0% across 320–2000px.

Do
  • Let container queries size display type
  • Keep Tanker on font-display: block
  • Break the wordmark to two lines only on tall phones (≤600px, ≤3/5 aspect, ≥700px tall)
Don't
  • Reintroduce a JavaScript text fitter
  • Two-line the wordmark on tablets — it only reaches ~65% of the line and halves the portrait
  • Switch to swap — the fallback overflows the banner mid-load

Focus

--focus-ring defaults to the yellow and is flipped to the dark green by .light and by .case-modal__content, so the ring is always visible against whatever it is sitting on. Rings are 2px with a 2px offset (4px on .btn-link, 6px on the email link).

All five focus rules now use the token, plus .case-figure__zoom. .btn-link and the contact email link used to hardcode --color-primary and --color-secondary, bypassing the mechanism built for exactly this.

Alt text

The convention is alt="" for decorative icons, with the meaning carried by an adjacent aria-label or visible text. Both pages now follow it throughout — 21 decorative images on the case page, 8 on the index — and only the five content mockups carry descriptive alt. The case page previously broke it in 12 places, where icons sitting beside their own visible label were announced as "Idea Icon", "Shield Icon", "Trade Up Icon", and twice as "Shiedl User Icon".

04Responsive

Eleven media queries across two stylesheets, with two shape-aware queries that width alone couldn't express.

QueryFileWhat changes
≤1440pxbothOuter padding 48→32px; case section padding; nav max-height 80px
≤1224pxcase onlyCase container becomes a column; mockups to 70% and centred
≤1024pxstyle onlyHero to 3/1.65 and horizontal 50/50; nav 100→60px
≤768pxstyle onlyh2 120→72px; hero to 3/2; About stacks
≤600pxbothHero to 3/4 portrait; work section stacks; logo → "RB"
≤425pxbothHero to 3/5; body line-height 28→24px; contact menu stacks
601–1024px portraitstyle onlyPortrait tablets stack fully and hand the photo the full width
≤600px, ≤3/5, ≥700px tallstyle onlyTwo-line wordmark at clamp(28px, min(38cqw, 18svh), 320px)
The two orientation queries sit last in the file on purpose — they have to win over the width-only blocks above them. Anything appended to style.css after them will silently outrank them.

case.css's 1224px breakpoint is the odd one out: it matches neither 1024 nor 1440, and there is no shared list of breakpoints for a third stylesheet to follow. A set of custom media values, or simply aligning 1224 → 1024, would make the two files agree.

05Audit

The foundations were sound and unusually well commented to begin with — the inline comments explaining why are what made this document possible to write from the source alone. Five rounds of fixes on 2 September closed all fourteen findings and took the score from 52 to 96. Every token in :root is now referenced by something.

96/100
52 → 60 → 73 → 81 → 85 → 96 across five rounds on 2 Sep. Every finding closed; every token referenced.
Token definition
10/10
Token adoption
10/10
Component states
9/10
Naming consistency
10/10
Accessibility
10/10
Documentation
8/10

Token coverage

CategoryDefinedReferencedHardcoded in CSS
Colour11115 (rgba blacks, gradient)
Spacing912711 (9 section-scale, 2 off-grid)
Radius3315
Typography12124 (all deliberate)
Motion3160
Interaction opacity3160
Breakpoints011

Findings

Unclosed paragraph in the case intro cases/comprehensive-insurance.html:48 Fixed was Bug

A <p class="case"> is closed with </h1>. Browsers recover, but the parse tree isn't what the markup says, and it will confuse a screen reader's heading list and anything that scrapes the page.

Fixed: the closing tag is now </p>.

Zoomable case images are keyboard-inaccessible cases · .js-zoom-image ×4 Fixed was A11y

The four mockups open a lightbox on click, but they are plain <img> elements — not focusable, not announced as interactive, no keyboard path in. The lightbox itself has no role="dialog", no focus trap and doesn't return focus on close.

Fixed: each is now a .case-figure__zoom button, and .image-modal has the dialog treatment .case-modal already had.

Parallax ran with no prefers-reduced-motion guard case.css · all sections Fixed was A11y

Every case section transforms its content on scroll, at every viewport, with no opt-out. Scroll-coupled parallax is a known vestibular trigger and is exactly what the media feature exists for.

Fixed: the script returns early on matchMedia("(prefers-reduced-motion: reduce)"), with a CSS guard on .parallax-content and a global one in style.css.

Spacing scale defined, never used style.css:root · 9 tokens Fixed was Tokens

All nine --space-* tokens had zero references against 123 spacing declarations (165 individual values).

Fixed: rebuilt from usage data — added --space-1/--space-5, deleted the unreferenced --space-9, moved 44px out to --control-height, converted 126 values. Section-scale one-offs stay literal by design.

No typography tokens at all both stylesheets · 29 declarations Fixed was Tokens

Font sizes, families and line-heights are literals everywhere, including inside media queries. Changing the base reading size means finding every one by hand. The scale that emerges is coherent — 14/16/20/24/32/40/60/72/120 — it just wasn't written down anywhere.

Fixed: --font-display / --font-body, nine role-named --text-* steps and --leading-display. 29 literals down to 4 deliberate ones.

.card and .card-light were duplicates style.css · .card, .card-light Fixed was Naming

Eight identical declarations each; only border-color differs. The name is also inverted — .card-light is the variant for dark surfaces, the opposite of what .light means everywhere else.

Fixed: one .card driven by --card-border, flipped by .light exactly as --focus-ring is.

Decorative icons carry descriptive alt text case page · 12 images Fixed was A11y

Icons sitting next to their own visible label are announced twice — "Idea Icon" followed by "Insight". Two are misspelled as "Shiedl User Icon". index.html gets this right in all 8 cases, so the two pages disagree.

Fixed: alt="" on all 12. Both pages now follow the convention throughout.

Focus ring token bypassed in two places .btn-link · #contact .email-link Fixed was Tokens

.btn-link hardcodes --color-primary, so if that button is ever placed on a lime surface its focus ring becomes invisible. The email link hardcodes --color-secondary — correct today only because it happens to sit on the light banner.

Fixed: both now use var(--focus-ring); all five focus rules go through the token.

.btn hardcoded a yellow border, so .btn.light was unusable style.css · .btn Fixed was Consistency

The border is var(--color-primary) rather than currentColor, so a .btn.light on the lime hero renders lime-on-lime with no visible edge. The variant is declared and has never been used — this is why. .secondary already works around it locally.

Fixed: .btn now borders with currentColor. Verified identical on dark surfaces, and both other variants behave.

Three inline styles bypassed the system case page Fixed was Consistency

color: #fd6b6b is the only semantic colour in the project and it lives in an HTML attribute. font-size: 24px appears twice as an ad-hoc sub-heading that isn't in the type scale.

Fixed: the red is now --color-negative applied via .metric-down, and the two headings use .subhead. The page has zero inline styles.

Motion tokens unused; no transitions on hover --ease, --t-fast, --t-med Fixed was Polish

Every hover snapped instantly to opacity: 0.8, and the one transition in the codebase used 0.2s ease rather than the tokens defined for it.

Fixed: all seven hover states transition through the tokens, --t-med drives the nav pill, and a global prefers-reduced-motion guard collapses everything to instant — including the homepage's JS anchor scroll.

Insight-card subtitle line-height equals its font-size case.css · .subtitle Fixed was Polish

16px on 16px. All three subtitles in the case study wrap, so ascenders and descenders nearly collided.

Fixed: now line-height: normal, matching the .description beneath it — Sora's own metric, measured at 20px, rather than a magic number.

Four visibility utilities with four different meanings .hide · .hide-m · .hide-mobile · .hide-desktop Fixed was Naming

.hide only works inside #hero-banner below 1024px. .hide-m lives in case.css and fires at 1224px. .hide-mobile and .hide-desktop are hero-scoped and swap at 600px — and again in the portrait-tablet block. None of the names says which breakpoint it means.

Fixed: two real utilities named for their breakpoints, plus .hero-cue--inline / --overlay for the pair that were never utilities. See Patterns §3.6.

Bilingual comments and duplicate rule blocks both stylesheets Fixed was Housekeeping

Comments switched between Portuguese and English mid-file. .case-modal declared display: none twice; .case-outro__actions and #work .case-cover .content were each split across two adjacent blocks. --radius-l and --transparency-secondary were declared and never used.

Fixed: comments are English throughout both stylesheets and both scripts, the split blocks are merged, and the two dead tokens are gone.

What's working

Worth stating plainly, because an audit reads as a list of faults: the contrast is excellent in both directions, the surface-inversion model is a genuinely good idea cleanly executed, the responsive work handles cases most sites ignore (portrait tablets, short phones, container-relative display type), and the inline comments explain why — the letter-spacing rounding sweep, the display:contents trap, the IntersectionObserver threshold, the font-display choice. That commentary is better than most production codebases carry, and it is what made this document possible to write from the source alone.

06What was done, and what is left

Every audited finding is closed. This is the record, and the short list of what is deliberately still open.

Completed 2 September 2026

The four one-line correctness bugs

The unclosed <p>, 12 alt attributes, two focus rings, .btn to currentColor.

Case images keyboard-operable, parallax guarded

The four mockups became .case-figure__zoom buttons; .image-modal gained a focus trap, focus restore and role="dialog"; the parallax bails out under prefers-reduced-motion.

Typography tokens

Nine role-named steps plus the two families and --leading-display.

.card / .card-light collapsed into one class

Driven by --card-border, flipped by .light.

Spacing scale adopted

126 values converted, 11 left literal by design.

Motion tokens wired up

With a global prefers-reduced-motion guard that also covers the homepage's JS anchor scroll.

.case-figure declared as a component

A zoomable and a static variant, with layout on the <figure> so both occupy an identical box.

Insight-card leading fixed

To normal, matching the description beside it — Sora's own metric, 20px at 16px.

Visibility utilities renamed to state their breakpoints

And the hero cue pair renamed for what it is rather than when it disappears.

Comments unified, duplicates merged, dead tokens removed

English throughout both stylesheets and both scripts.

Component states completed

Active and disabled on every control, with the scroll-spy class renamed .is-current to clear the way.

One thing to carry forward. #intro / #overview became .case-intro / .case-overview. Watch the specificity if you touch those rules: the generic .case-content section .container .content is (0,3,1), so a bare .case-intro .container .content (0,3,0) would lose. The rules keep .case-content in front, making them (0,4,0). Don't shorten them.

Still open

Either your call, or genuinely not worth doing.

ItemWhy it is still here
A loading stateNo component has one, because nothing on the site is asynchronous. The first form that posts will need it.
Two off-grid spacing values — 10px, 18pxNudging them to 8/16 would put the whole codebase on the 4px grid, but it is a visible change. Your call, not a silent cleanup.
.case-modal__content uses 1.4rem / 0.95rem22.4px and 15.2px, neither a step of the type scale. Same reasoning as above.
A <figcaption> on .case-figureThe honest place for a "click to enlarge" cue, and for the before/after labels currently baked into the image pixels. Blocked until case #2's assets are exported with the labels out of the artwork.
The scroll cue exists twice in the markupOne copy is always hidden, so it is harmless — but it is duplicated content. A single element repositioned by CSS would be cleaner if the hero is reworked.
This document is synced by handThree copies: the repo markdown, the published page, and myPortfolio/design-system.html. The weakest part of the setup, and why Documentation sits at 8/10.
Deliberately not on this list: the JS text fitter (removed on purpose), font-display: swap (breaks the hero mid-load), aligning scroll-behavior (case.css overrides it intentionally), and the 1.5px borders. These look like inconsistencies and are decisions.
Compiled from styles/style.css, styles/case.css, index.html and cases/comprehensive-insurance.html · 2 September 2026, revised after the same day's fixes
Type set in Tanker (self-hosted) and Sora; data in IBM Plex Mono.
Component previews use the source declarations verbatim.