One typeface, one accent, and the discipline to stop there.
Internal reference for the site buildout. Every page is assembled from the parts on this page — if a surface disagrees with this page, the surface is the defect.
Every specimen below is the production component, imported from src/components/v2/. Nothing here is a copy. Change a component and this page changes with it. Open usage under any specimen for the exact JSX. Written index: site/DESIGN-SYSTEM.md. Canon: brand/design.md v2.1, regenerated from this surface.
Sixteen sections. Section 16 is the complete token table, every name verbatim, and it is the one to bookmark. Section 13 is the component roster with props. Section 09 is the image base prompt every generated image on the site comes from. If you are about to build a page rather than look something up, start at how to build from this instead.
Monochrome, plus one live thing.
ground·dark
card·dark
ink·hi
ink·mid
ink·dim
eyebrow
muted ✕
ground
raised
Schibsted Grotesk carries everything.
Ledes run 17px on a 520px measure. Side notes run 15px on 340px. Long-form article bodies cap at 65ch.
Body 14.5px — captions, card copy, table cells.
The grid, the rhythm, the radii.
Buttons and the grader field.
usage
<Button href="/resources/website-grader/">Get website grade</Button>
<Button variant="ghost" href="/book/">Book a call</Button>usage
<Button ground="dark" href="/resources/website-grader/">Get website grade</Button>
<Button variant="ghost" ground="dark" href="/book/">Book a call</Button>The site-wide conversion device. 56px light shell, 8px inner dark button — the only nested radius. On dark grounds it lifts with the field shadow. Every page ends with this ask.
The mono language.
usage
<Eyebrow number="05">services</Eyebrow>usage
<CheckList mark="cross" items={['Sold, then ghosted']} />
<CheckList items={['Same-day reply, personally']} />Card families. Hover them.
The whole demand engine. Featured tier: dark card, −8px lift, the system’s second shadow.
usage
<CaseCard tag="jhai · ai system" title="…" stat="↑ 12× content output" statSpark
href="/work/case-study-01/" />
<ToolCard icon={<Link size={22} strokeWidth={1.5} color="var(--accent)" />} title="Website grader"
description="…" cta="try it" href="/resources/website-grader/" />
<BlogCard date="jul 17, 2026" title="…" href="/blog/post-01/" />
<PricingCard featured tag="best value" name={tiers[1].name} price={tiers[1].price} per="/mo"
line="…" items={['Everything in Presence']} note="*ad spend billed separately" />Bands, tables, rows.
| what you get | The others | Justin |
|---|---|---|
| Same-day reply, personally | ||
| Who does the work | A rotating account team | Justin, every time |
usage
<ComparisonTable
rowHeading="what you get"
caption="How working with Justin compares with a typical agency."
columns={[{ label: 'The others' }, { label: 'Justin', highlight: true }]}
rows={[
{ label: 'Same-day reply, personally', values: [false, true] },
{ label: 'Who does the work', values: ['A rotating account team', 'Justin, every time'] },
]}
/>FAQ rows use native details, hairline-ruled
A second row, closed
usage
<StatTile value="300+" label="businesses served" divider={false} />
<CTABand title="Your website is losing customers you never meet." action="/resources/website-grader/" />
<FAQItem question="How much does it cost?">Retainers start at {tiers[0].price} a month, scoped in writing.</FAQItem>Black and white, hard light, one subject.

the cutoutusage
<ImageSlot ratio="16/10" placeholder="Drop: agent console frame" />
<ImageSlot ratio="16/10" src="/v2/photos/plaza-plate.jpg" alt="Plaza" grayscale contrast />One base prompt. The subject is the work.
Higgsfield MCP, nano-banana model. Every featured image on the site is generated from the block below, and nothing else is added to it.
Black and white photograph. A bit of grain. Architectural framing. Editorial.
Photographic realism, as though we hired our own photographer to take high-concept pictures.
Subject: <the subject of the section this image sits in>That is the entire prompt. The only thing you write is the subject line, and you take it from the section the image sits in. Everything a person would normally reach for next is deliberately absent, and section 09 exists partly to stop it being added back.
- Black and white.
- A bit of grain.
- Architectural framing.
- Editorial register.
- Photographic realism.
- Subject drawn from the section it sits in.
These six are the system. They are what makes 63 separately generated images read as one set.
- Scene.
- Composition.
- Camera position.
- Light direction.
- Lens.
- Depth.
- Texture.
These seven are the variance. Naming one of them narrows the output for no gain, and naming several collapses it. If you can infer it from the subject and the reference, do not write it.
A dentist page shows a dental practice, or a dentist working, shot artistically. A speed-to-lead guide shows a phone being answered. The image is briefed from the section it sits in, one image at a time, and it is the only thing in the prompt that changes.
Decorative is the failure. A moody abstract that could sit on any page sits correctly on none of them, and 63 of those read as stock even when nothing was bought. If you cannot name what the image is of in the words of the section, the subject line is not finished.
Justin’s own photography, at tasks/2026-08-10-wireframe-v2/claude-design-drop/extracted/uploads/claude-design-kit/photos/. Six frames. Name the look and the subject when you point at them, and nothing else. Never name an era, a genre, a magazine or a decade: those are abstractions, and a model given an abstraction returns the average of it. The six frames themselves stay reserved for person pages, About and author bios (D-13). They are the calibration, not the supply.
One curve. Nothing bounces.
data-rvFade up 28px over 0.7s on the one curve. The hide is written in JavaScript and only for elements already below 90 percent of the viewport, so a crawler, a reader with JS off, and the very first paint all get the fully visible page. No reveal can cost an indexed word.
data-rv="0" … data-rv="3"The index is the delay multiplier, not an order. Four siblings numbered 0 to 3 land 90ms apart within their band. Numbering restarts per band, never across the page.
data-count="300" data-count-suffix="+"900ms, ease-out cubic, 30ms tick. The server renders the final figure, so the number is in the HTML whether or not the script runs. Use data-count-in="<selector>" when the figure is inside a shared component you do not own, and the annotation stays on the band.
data-plxThe plate is sized past its frame and the frame clips it: top -12 percent, height 124 percent, inside an overflow: hidden parent. Deeper than 12 percent reads as a trick, so 12 percent is the ceiling and not a default.
The rules about what never happens.
Eight services are eight hairline rows. Repetition is the tell: the moment a pattern repeats, the box comes off. A container appears only when one thing stands alone (a featured case, the terms card, the best-value tier).
Identical items in identical boxes is agency-template grammar. Also never: grey-filled cards, colored left-borders, icon-in-circle chips, emoji.
Sections meet as a change of ground (#F5F5F5 ↔ #FFFFFF ↔ dark) or 150px of air. No full-width rules at section edges, no tinted divider bands. Hairlines live INSIDE sections: on rows, tables, terms strips.
No image or video touches the browser edge; a wide image is a wide container at the brand radius. Exception, stated once: the homepage hero video is the single full-bleed surface on the site. Video adds four rules: muted, looping, no controls, poster + painted fallback.
Header and footer, fixed for every page.
Capacity strip above the bar (dark, always). Bar: transparent-dark over the hero → frosted light after scroll. Megamenu: near-solid light panel, categorized columns, “All →” hairline row. Footer: giant wordmark (rises on arrival), 6 link columns, legal strip, signature + badges + skyline, “made with love in las vegas”.
The bar above is a facsimile on purpose: Header, Megamenu and MobileNav are sticky, client-side and scroll-reactive, and a sticky header rendered inside a specimen plate would misrepresent its own behaviour. The live header is the one at the top of this page. Source: src/components/v2/chrome/.
usage
<Footer />
// columns, phone, email, legalLinks, badges and copyright all defaultEvery part, and what it takes.
63 entries under src/components/v2/. 19 of them render live somewhere on this page; every other row names why it cannot.
Reach for a part in this order: a core primitive, then a card, then a page part, then a band. If none of them fits, the answer is almost never a new component. It is a row.
D-24, binding. Every stateful or interactive primitive comes from shadcn, themed with the v2 tokens. Navigation menu, sheet, dialog, tabs, form, input, select, tooltip, popover, carousel, separator, badge, skeleton. If shadcn has it, we use it, and no new hand-rolled interactive component is authored, ever. Tailwind utilities are the styling layer and inline style objects come out rather than go in. The token names stay verbatim: shadcn is themed with our tokens, our tokens are never bent to shadcn defaults. lucide-react is the icon set, and no icon SVG is hand-drawn inside a component file.
Zero files in this build import @/components/ui today. shadcn v4, Tailwind v4, @base-ui/react, lucide-react, class-variance-authority and tailwind-merge are all installed and components.json is configured, and none of it is used yet. That is the true state of the build, and it is printed here rather than described as finished, because a style guide that claims a roster it does not have is the defect this whole surface exists to end. Lane W1-0 does the conversion and hands its roster back as data; the table appears here when it lands, with no edit to this page.
usage
import { Icon } from '@/components/v2/sections/icons';
<Icon name="search" size={22} />
<Icon name="wrench" size={22} color="var(--accent)" />FAQItem uses a native <details> element and keeps it. It opens with JavaScript disabled, it is keyboard operable for free, and its content is findable by in-page search, all of which a shadcn accordion has to re-earn. This is the one case where the platform primitive beats the library one, and it is written down here so nobody “fixes” it later. D-24, stated once, on purpose.
“Justin's expertise, responsiveness, and genuine investment in our success have been evident throughout this process. We are truly grateful.”
wall={false} here. The trusted-by wall it normally carries is LogoWall, a marquee, and a marquee inside a specimen plate reads as a bug rather than a behaviour. That is the same reason the roster below lists LogoWall as documented rather than live, and rendering it here anyway would have contradicted the row.usage
<ProofDeck row={row} content={content} />
// wall={false} when the template already renders its own trusted-by wall| component | props | on this page |
|---|---|---|
Buttoncore/Button.tsxEvery call to action. Renders an anchor when given href, a button otherwise. | variant?: 'primary' | 'ghost' · ground?: 'light' | 'dark' · size?: 'md' | 'nav' | 'lg' · href?: string · children: ReactNode | rendered livesee it → |
CheckListcore/CheckList.tsxThe mark system. Grey cross names a pain, Bjarmi check answers it. | items: ReactNode[] · mark?: 'check' | 'cross' · ground?: 'light' | 'dark' · size?: 'md' | 'lg' · style?: CSSProperties | rendered livesee it → |
Eyebrowcore/Eyebrow.tsxThe mono label above a heading. One recipe, no variations. | number?: string · ground?: 'light' | 'dark' · accent?: boolean · children: ReactNode · style?: CSSProperties | rendered livesee it → |
GraderFieldcore/GraderField.tsxThe site-wide conversion device. Every page ends with this ask. | ground?: 'light' | 'dark' · cta?: string · action?: string · subline?: ReactNode · placeholder?: string · inputLabel?: string · style?: CSSProperties | rendered livesee it → |
ImageSlotcore/ImageSlot.tsxThe grey plate that stands in for photography, and the frame real photography lands in. | placeholder?: string · src?: string · alt?: string · ratio?: string · shape?: 'rounded' | 'rect' | 'circle' · grayscale?: boolean · contrast?: boolean · width?: string · style?: CSSProperties | rendered livesee it → |
SectionHeadercore/SectionHeader.tsxEyebrow, heading and side note, at the one gap. Every section on the site opens with this. | number?: string · label: string · title: ReactNode · side?: ReactNode · scale?: 'section' | 'feature' | 'display' · ground?: 'light' | 'dark' · style?: CSSProperties | rendered livesee it → |
StatTilecore/StatTile.tsxOne figure and its mono caption, with an optional left hairline. | value: string · label: string · divider?: boolean · ground?: 'light' | 'dark' · style?: CSSProperties | rendered livesee it → |
Icon · Sparkline · SocialIconsections/icons.tsxThe icon layer. Lucide strokes at 1.5, plus the sparkline that rides a case-card stat. | Icon: name: string · size?: number · color?: string | Sparkline: variant?: number | SocialIcon: id: string | rendered livesee it → |
| component | props | on this page |
|---|---|---|
CaseCardcards/CaseCard.tsxA case study, carrying exactly one stat. The case page carries the rest. | href? · tag: string · title: ReactNode · stat? · statSpark?: boolean · cta? · image? · imagePlaceholder? · width? · style? | rendered livesee it → |
ToolCardcards/ToolCard.tsxA tool or resource. White ghost card, 22px Lucide icon, one-line description, mono CTA. | href? · icon?: ReactNode · title: string · description: string · cta? · style? | rendered livesee it → |
BlogCardcards/BlogCard.tsxA post. Hairline top rule, mono date, 21px title. No box. | href? · date: string · title: string · image? · imagePlaceholder? · style? | rendered livesee it → |
PricingCardcards/PricingCard.tsxA tier. Two ghost cards flank one dark featured card, and the featured card carries one of the system two shadows. | name: string · price: string · per? · line: string · items: ReactNode[] · featured?: boolean · tag? · note? · ctaHref? · ctaLabel? · style? | rendered livesee it → |
| component | props | on this page |
|---|---|---|
CTABandpage/CTABand.tsxStatement left, grader right. One dark CTA moment per page, after a proof moment. | title?: ReactNode · subline?: ReactNode · action? · cta? · style? | rendered livesee it → |
ComparisonTablepage/ComparisonTable.tsxThe cross and check table. The centrepiece of a comparison page and nowhere else. | rowHeading? · columns: ComparisonColumn[] · rows: ComparisonRow[] · caption? · yesLabel? · noLabel? · style? | rendered livesee it → |
FAQItempage/FAQItem.tsxOne question row on a hairline. Native details, and that is deliberate. See the note in section 13. | question: string · children: ReactNode · open?: boolean · style? | rendered livesee it → |
DemoFramepage/DemoFrame.tsxA working demo inside contained browser chrome, with mono spec rows beneath. | url: string · live?: boolean · liveLabel? · ratio? · placeholder? · meta?: ReactNode[] · maxWidth? · children? · style? | rendered livesee it → |
ProofDeckpage/ProofDeck.tsxThe proof wall. Identical on all 190 pages: 14 years, 300+ businesses, 40+ industries, plus the rating and the BBB mark. | row: PageRecord · content: ContentFile | null · wall?: boolean. The figures are locked at D-10 and are not props, so no page can vary them. | rendered livesee it → |
| component | props | on this page |
|---|---|---|
Footerchrome/Footer.tsxGiant wordmark, six link columns, legal strip, signature, badges, skyline. | columns?: FooterColumn[] · phone? · email? · legalLinks?: FooterLink[] · badges?: FooterBadge[] · copyright? | rendered livesee it → |
Headerchrome/Header.tsxCapacity strip, bar, phone, CTA pair. Transparent over a hero, frosted after scroll. | nav?: NavItem[] · phone? · phoneHref? · ctaPrimary?: HeaderCta · ctaSecondary?: HeaderCta · announcement?: HeaderAnnouncement | null · transparentOverHero?: boolean · homeHref? | documentedSticky, client-side and scroll-reactive. A sticky header rendered inside a specimen plate misrepresents its own behaviour. The live one is at the top of this page. |
Megamenuchrome/Megamenu.tsxThe near-solid light panel under a nav item: categorised columns and an "All →" hairline row. | panel: MegamenuPanel · onPointerEnter? · onPointerLeave? · id? · labelledBy? | documentedOnly exists as a child of Header, positioned against the bar. Detached from the bar it has no geometry to render into. |
MobileNavchrome/MobileNav.tsxThe full-screen sheet under 900px, opened from the bar. | open: boolean · onClose: () => void · nav: NavItem[] · phone · phoneHref · ctaPrimary · ctaSecondary | documentedRenders full-viewport by definition. Open it in a specimen and it eats the page. Resize this window under 900px to see the real one. |
| component | props | on this page |
|---|---|---|
MotionRuntimemotion/MotionRuntime.tsxThe scroll-driven half of the motion layer. Mounted once in the root layout, double-mount guarded. | none. Sections opt in with data-rv, data-plx, data-count and data-feed. | rendered livesee it → |
| component | props | on this page |
|---|---|---|
Herosections/Hero.tsxThe one full-bleed surface on the site. | content: HeroContent | documentedTakes a full content slice and renders at viewport scale. Its parts are documented individually above. |
Problemsections/Problem.tsxThe diagnosis band. Capped at one per page (I-4). | content: ProblemContent | documentedContent-bound band. See the service-page recipe in section 14. |
Answersections/Answer.tsxThe operator answer, with the one bespoke drift on #answer-figure. | content: AnswerContent | documentedContent-bound band. |
Servicessections/Services.tsxEight services as eight hairline rows. The doctrine example in section 11. | content: ServicesContent | documentedContent-bound band. Its row grammar is rendered live in section 11. |
Worksections/Work.tsxThe case rail. | content: WorkContent | documentedContent-bound band. Built from CaseCard. |
Engagesections/Engage.tsxThe pricing band. | content: EngageContent | documentedContent-bound band. Built from PricingCard, which is live in section 06. |
Processsections/Process.tsxThe numbered-row ladder. | content: ProcessContent | documentedContent-bound band. Its numbered rows are rendered live in section 05. |
Operatorsections/Operator.tsxThe person band: portrait plate and fact rows. | content: OperatorContent · phone: string · phoneHref: string | documentedContent-bound band. |
Aesirsections/Aesir.tsxThe system band. | content: AesirContent | documentedContent-bound band. |
Comparesections/Compare.tsxThe comparison band. Wraps ComparisonTable. | content: CompareContent | documentedContent-bound band. ComparisonTable is live in section 07. |
Questionssections/Questions.tsxThe FAQ band. Wraps FAQItem. | content: QuestionsContent | documentedContent-bound band. FAQItem is live in section 07. |
Closesections/Close.tsxThe closing display statement and the last grader ask. | content: CloseContent | documentedContent-bound band. |
CtaBandSectionsections/CtaBandSection.tsxThe band wrapper around CTABand. | content: CtaBandContent | documentedContent-bound band. CTABand is live in section 07. |
AnchorQuotesections/AnchorQuote.tsxOne quote at feature scale. | content: QuoteContent | documentedContent-bound band. |
InterstitialPlatesections/InterstitialPlate.tsxA contained full-width plate between bands. | content: InterstitialContent | documentedContent-bound band. Built from the plate treatment in section 08. |
LogoWallsections/LogoWall.tsxThe client marquee, drifting linearly at 70s. | logos: LogoEntry[] · label: string | documentedA marquee inside a narrow specimen plate reads as a bug rather than a behaviour. |
ReviewRailsections/ReviewRail.tsxThe review rail and its rating mark. | content: ProofContent | documentedContent-bound band. |
RecordBandsections/RecordBand.tsxThe four-figure stat strip. Built from StatTile with data-count. | stats: StatEntry[] | documentedIts parts are live in section 07 and its count-up is demonstrated in section 10. |
Tickersections/Ticker.tsxThe single-line drift strip. | text: string | documentedSame marquee reason as LogoWall. |
Toolssections/Tools.tsxThe tool-card grid. | content: ToolsContent | documentedContent-bound band. ToolCard is live in section 06. |
Writingsections/Writing.tsxThe three-post rail. | content: WritingContent | documentedContent-bound band. BlogCard is live in section 06. |
TechStacksections/TechStack.tsxThe stack strip. | content: TechContent | documentedContent-bound band. |
IndexRailsections/IndexRail.tsxThe fixed section rail, coloured by scroll position and never moved. | entries: RailEntry[] | documentedPosition-fixed against the viewport. Inside a plate it has nothing to track. |
Container · Section · Grain · HomeStylessections/shell.tsxThe 1280 container, the ground grammar, the one texture, and the stylesheet the folds read from. | Section: id? · label? · ground?: 'paper' | 'white' | 'ink' · children · style? · className? | documentedThis documentation surface renders its own container and grain, because a doc page is not a marketing band and nesting a Section inside one would double the rhythm. The three grounds these produce are rendered in section 11 and the grain is on the dark plate in section 04. |
| component | props | on this page |
|---|---|---|
ServiceDetailTemplatetemplates/service/ServiceDetailTemplate.tsx33 service pages. | row: PageRecord · content: ContentFile | null | documentedRoute-level template. Needs a real pages.json row. |
ServiceCategoryTemplatetemplates/service/ServiceCategoryTemplate.tsxThe six service category landers. | row: PageRecord · content: ContentFile | null | documentedRoute-level template. |
IndustryTemplatetemplates/industry/IndustryTemplate.tsx24 industry pages. | row: PageRecord · content: ContentFile | null | documentedRoute-level template. |
IndustryCategoryTemplatetemplates/industry/IndustryCategoryTemplate.tsxThe industry category landers. | row: PageRecord · content: ContentFile | null | documentedRoute-level template. |
ArticleTemplatetemplates/longform/ArticleTemplate.tsxBlog posts and guides. | row: PageRecord · content: ContentFile | null | documentedRoute-level template. |
ComparisonTemplatetemplates/longform/ComparisonTemplate.tsxComparison pages, where the cross and check table is the centrepiece. | row: PageRecord · content: ContentFile | null | documentedRoute-level template. |
CaseStudyTemplatetemplates/work/CaseStudyTemplate.tsx38 case studies. | row: PageRecord · content: ContentFile | null | documentedRoute-level template. |
WorkIndexTemplatetemplates/work/WorkIndexTemplate.tsxThe editorial work index: hairline rows and a fixed preview panel. | row: PageRecord · content: ContentFile | null | documentedRoute-level template. Its row and preview grammar is rendered in section 14. |
ChildLaneDirectorytemplates/service/ChildLaneDirectory.tsxThe hairline directory under a category page. | consumed by the service templates | documentedTemplate part. |
ServiceHerotemplates/service/ServiceHero.tsxThe short service hero with the grader beside it. | consumed by the service templates | documentedTemplate part. |
service/parts.tsxtemplates/service/parts.tsx17 bands: ZoneHeader, Prose, ContentPlate, ItemGrid, StepLadder, RowList, ProofBand, RecordStrip, PriceAnchor, ReviewBand, FaqStack, MidCta, CloseBand, LogoWallBand, BandMedia, ContentBand, ServiceStyles. | each takes its own slice of the service content file | documentedTemplate parts. Every one is a composition of the primitives above. |
industry/bands.tsxtemplates/industry/bands.tsx16 bands: IndustryHero, WhyBand, LanesBand, ChecklistBand, InclusionsBand, ProofBand, ProcessBand, TimelineBand, PricingBand, QuestionsBand, ProseBand, ChildDirectory, AboutBand, Prose, QuietLink, ProofRows. | each takes its own slice of the industry content file | documentedTemplate parts. |
industry/compose.tsxtemplates/industry/compose.tsxFold, MidCta, Reviews and PageClose: the shared industry page spine. | consumed by the industry templates | documentedTemplate parts. |
longform/parts.tsxtemplates/longform/parts.tsx10 parts: StatementLine, Plate, LongFormHero, ContentsRail, ComparisonBlock, Movement, FigureRows, RelatedRail, OperatorNote, CloseBand. | LongFormHero: row · kind · eyebrow · statement · subline · primary? · secondary? · scale?: 'display' | 'feature' | documentedTemplate parts. |
work/parts.tsxtemplates/work/parts.tsx10 parts plus the GRADER constant: MonoTag, Crosshead, Prose, Plate, GalleryPlate, StatBand, FactRows, LinkRows, WorkClose, WorkStyles. | consumed by the work templates | documentedTemplate parts. |
work/narrative.tsxtemplates/work/narrative.tsxNarrativeSection: the 65ch case-study body with mono crossheads. | consumed by CaseStudyTemplate | documentedTemplate part. |
styles modulestemplates/*/styles.tsxIndustryStyles, LongFormStyles and ServiceStyles: the per-family fold rules, kept out of the band files so a band stays declarative. | none | documentedStyle-only modules. They render a stylesheet, not a visual. |
src/app/design-system/_doc/roster.ts, and its specimen by importing it into this page. Both, or it is undocumentedThirteen archetypes cover all 190 pages.
Every route in site-data/pages.json resolves to one of these. Compose from the parts above; never restyle one.
usage
<DemoFrame
url="demo.justinharris.ai/grader"
meta={['stack · claude + n8n', 'build · 3 days', 'how it was built →']}
/>A section built from the parts above and nothing else.
Own the map pack before they scroll.
46% of searches are local. The three names in the map pack take the call.
Eyebrow recipe, section scale, italic cap, side-note measure, ✓ list, button pair, contained plate, hairline discipline — every value on this page, nothing invented. This is the bar for all 190 pages.
Every name, verbatim.
69 distinct names at :root, plus 6 re-declared under 760px. 75 declarations, one file: src/styles/v2-theme.css.
Every swatch, rule and specimen on this page is painted from one of these. If a surface disagrees with this table, the surface is the defect.
The chips are previews, painted with var(--token) at render time. The value beside each one is authored text, transcribed from the stylesheet. They are deliberately two separate facts, so a divergence between them shows up on this page as a visible mismatch rather than sitting quietly in a document nobody re-reads. There is no Tailwind @theme block and no --color-* alias set: if a utility layer is ever wanted it belongs in globals.css as aliases pointing at these names, never as a second vocabulary.
| preview | token | value | role |
|---|---|---|---|
--ink-950 | #0a0b0b | near-black: dark grounds, dark cards | |
--ink-900 | #151717 | elevated dark card on a dark ground | |
--ink-800 | #222222 | primary text on light | |
--ink-600 | #4a4a48 | body text on light | |
--ink-500 | #6b6b68 | secondary text on light | |
--ink-400 | #8a8a86 | eyebrows, muted labels | |
--ink-300 | #b0b0ad | disabled and muted marks on light | |
--paper-0 | #ffffff | white section ground | |
--paper-50 | #f5f5f5 | default page ground |
| preview | token | value | role |
|---|---|---|---|
--dark-text-1 | #e8e8e8 | headings and primary copy on dark | |
--dark-text-2 | #b8b8b6 | body copy on dark | |
--dark-text-3 | #a0a0a0 | side notes and captions on dark |
| preview | token | value | role |
|---|---|---|---|
--bjarmi-ink | #547070 | the accent on light grounds. The only form that carries words on paper, at 4.90:1 | |
--bjarmi-glow | #87a6a6 | the accent on dark grounds. The live thing | |
--bjarmi-tint | rgba(84, 112, 112, 0.06) | the one tint in the system: comparison-table headers, nothing else |
| preview | token | value | role |
|---|---|---|---|
--line-light | rgba(0, 0, 0, 0.12) | the standard hairline on a light ground | |
--line-light-soft | rgba(0, 0, 0, 0.06) | ghost card edges on light | |
--line-dark | rgba(255, 255, 255, 0.12) | the standard hairline on a dark ground | |
--line-dark-soft | rgba(255, 255, 255, 0.08) | ghost card edges on dark |
| preview | token | value | role |
|---|---|---|---|
--text-heading | var(--ink-800) | every heading on a light ground | |
--text-body | var(--ink-600) | every paragraph on a light ground | |
--text-secondary | var(--ink-500) | side notes, captions, table cells | |
--text-eyebrow | var(--ink-400) | the mono eyebrow on light | |
--surface-page | var(--paper-50) | the default page ground | |
--surface-raised | var(--paper-0) | the alternating white section | |
--surface-dark | var(--ink-950) | the dark moment, reserved | |
--accent | var(--bjarmi-ink) | Bjarmi on light. What a component asks for by default | |
--accent-on-dark | var(--bjarmi-glow) | Bjarmi on dark |
| preview | token | value | role |
|---|---|---|---|
--shadow-field | 0 10px 40px rgba(10, 11, 11, 0.35) | the grader field, lifted off a dark ground | |
--shadow-lift | 0 24px 48px rgba(10, 11, 11, 0.22) | the featured price card, and nothing else |
| preview | token | value | role |
|---|---|---|---|
--font-sans | 'Schibsted Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif | everything a human reads as a sentence | |
--font-mono | 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace | labels only. Never a paragraph |
| preview | token | value | role |
|---|---|---|---|
--type-hero | clamp(56px, 6.8vw, 96px) | hero h1. lh 1.02, ls -0.045em, w 500 | |
--type-display | clamp(56px, 6.6vw, 96px) | page-scale statements: the problem band, the close. lh 0.98 to 1 | |
--type-feature | clamp(44px, 4.4vw, 60px) | feature sections: the answer, the operator. lh 1.02, ls -0.04em | |
--type-section | clamp(40px, 4vw, 52px) | the standard section h2. lh 1.06, ls -0.035em | |
--type-card-title | 19px | card h3. w 500, ls -0.015em | |
--type-card-title-lg | 21px | case and blog card h3. lh 1.25 to 1.3 |
| preview | token | value | role |
|---|---|---|---|
--type-lede | 17px | the lede under a heading. lh 1.65 | |
--type-body | 14.5px | captions, card copy, table cells. lh 1.6 to 1.65 | |
--type-small | 13.5px | the smallest running copy. lh 1.55 to 1.6 | |
--type-caption | 13px | plate captions and figure notes | |
--type-eyebrow | 9.5px | the mono eyebrow. ls 0.24em, uppercase, always this recipe | |
--type-micro | 10px | micro labels and dates. ls 0.2 to 0.24em, uppercase | |
--measure-lede | 520px | the lede measure | |
--measure-side | 340px | the side-note measure | |
--weight-heading | 500 | every heading, at every scale. There is no 600 and no 700 display | |
--eyebrow-gap | 20px | eyebrow to heading, everywhere, no exceptions |
| preview | token | value | role |
|---|---|---|---|
--container | 1280px | the one content width | |
--gutter | 48px | the container inset. Steps to 24px under 760px | |
--section-y | 150px | standard section padding. Steps to 90px under 760px | |
--section-y-display | 160px | display moments: the problem band, the close | |
--header-offset | 120px | scroll-margin-top, so an anchored section clears the sticky bar | |
--gap-heading-block | 56px | section header to its content | |
--gap-grid | 48px | column gaps inside a section grid | |
--gap-card | 20px | card grid gaps. Pricing runs 24px | |
--gap-inline | 12px | button rows and chip rows |
| preview | token | value | role |
|---|---|---|---|
--radius-card | 12px | cards, buttons, photo plates, panels | |
--radius-inner | 8px | a button nested inside a 12px field. The only nested radius | |
--radius-pill | 999px | the capacity pill and tags | |
--button-h | 50px | standalone page CTAs | |
--button-h-nav | 42px | the header CTA | |
--field-h | 56px | the grader field shell |
| preview | token | value | role |
|---|---|---|---|
--ease | cubic-bezier(0.2, 0, 0, 1) | every transition on every surface | |
--dur-fast | 200ms | colour and border hovers | |
--dur-med | 400ms | arrow slides and small moves | |
--dur-slow | 700ms | reveals and image scale hovers | |
--hover-img-scale | 1.02 | an image inside a clipped card | |
--hover-arrow-shift | 4px | how far the arrow slides right |
| token | value | was |
|---|---|---|
--gutter | 24px | was 48px |
--section-y | 90px | was 150px |
--section-y-display | 96px | was 160px |
--gap-heading-block | 36px | was 56px |
--gap-grid | 32px | was 48px |
--header-offset | 96px | was 120px |
| specimen | file | family | style | weight | subset |
|---|---|---|---|---|---|
schibsted-grotesk-var-latin.woff2 | Schibsted Grotesk | normal | 400 600 | latin | |
schibsted-grotesk-var-latin-ext.woff2 | Schibsted Grotesk | normal | 400 600 | latin-ext | |
schibsted-grotesk-var-italic-latin.woff2 | Schibsted Grotesk | italic | 400 600 | latin | |
schibsted-grotesk-var-italic-latin-ext.woff2 | Schibsted Grotesk | italic | 400 600 | latin-ext | |
ibm-plex-mono-400-latin.woff2 | IBM Plex Mono | normal | 400 | latin | |
ibm-plex-mono-400-latin-ext.woff2 | IBM Plex Mono | normal | 400 | latin-ext | |
ibm-plex-mono-500-latin.woff2 | IBM Plex Mono | normal | 500 | latin | |
ibm-plex-mono-500-latin-ext.woff2 | IBM Plex Mono | normal | 500 | latin-ext |
src/styles/v2-theme.css. A hex, size or radius written anywhere else is a defectOne motion invariant, carried with the tokens: prefers-reduced-motion stops every animation and transition on every surface, so no component has to remember to honour it