{"apiVersion":1,"name":"alanlong.design system","description":"The executable component, token, layout and authoring contract behind /system.","publicUrl":"https://alanlong.design/system","capabilities":[{"name":"manifest","arguments":[],"description":"Describe this CLI and return the complete design-system contract.","options":["--json","--dense"],"json":true},{"name":"search","arguments":["query"],"description":"Search components, foundations, patterns and views.","options":["--json","--dense"],"json":true},{"name":"component","arguments":["name"],"description":"Return the complete contract for one named system entry.","options":["--json","--dense"],"json":true},{"name":"context","arguments":["name","name?"],"description":"Bundle selected entries, their referenced tokens and the authoring vocabulary.","options":["--json","--dense"],"json":true},{"name":"tokens","arguments":[],"description":"Return design tokens and the generated authoring vocabulary.","options":["--json","--dense"],"json":true},{"name":"rules","arguments":["name?"],"description":"Return constraints for one entry or the whole system.","options":["--json","--dense"],"json":true},{"name":"doctor","arguments":[],"description":"Run read-only source, token and generated-contract health checks.","options":["--json","--dense"],"json":true}],"entries":[{"kind":"component","name":"AtmosphereSpecimen","slug":"atmosphere-specimen","href":"/system/internal#atmosphere-specimen","sourceFile":"src/components/system/AtmosphereSpecimen.astro","propsApi":null,"icon":"cloud","tier":"component","internal":true,"why":"Makes the solar palette visible across a whole day and shows a sparse Bayer boundary for comparison with Divider.","foundations":{"type":[".t-body-mono"],"colour":["--ground","--ink"],"spacing":["--space-blocks","--space-related","--cell"]},"composition":{"containedBy":["Colour"]},"constraints":[{"rule":"The spectrum uses the active solar bounds and the same palette as the page","reason":"A second palette would document a picture rather than the working system."},{"rule":"The sparse boundary remains a specimen; Divider stays a hairline","reason":"Atmospheric colour marks a substantial change of subject, not every boundary."}],"a11y":{"role":"figure","keyboard":"Native range input supports arrow keys, Home and End.","notes":"Visible selected time and solar bounds accompany the canvases. No automatic motion."},"agentContext":{"when":"In the Colour foundation beside ColourClock.","notWhen":"Not a generic page decoration or a replacement for Divider."},"spacing":{"internal":"Blocks separated by --space-blocks; labels attach with --space-related. Canvases are three cells high.","external":"Owned by the Colour foundation."}},{"kind":"component","name":"BaseLayout","slug":"base-layout","href":"/system/layout#base-layout","sourceFile":"src/components/layout/BaseLayout.astro","propsApi":{"signature":"interface Props { title?: string; description?: string; variant?: Pick<CollectionEntry<'variants'>['data'], 'noindex'>; }","binding":"const { title = 'Alan Long — Senior product designer, London', description = 'Portfolio of Alan Long, senior product designer in London. Design systems, complex tools, and AI-native interfaces.', variant, } = Astro.props;"},"icon":"stack-simple","tier":"component","why":"Root HTML shell for every non-system page. Sets up the document head (meta, font preloads, title), mounts the construction grid, Nav, page content, and Footer, then boots the client-side systems (colour, reveal, nav, clock) via a main.ts script import.","internal":false,"group":{"name":"Layout","order":3,"description":"Structural components that define page regions and navigation."},"foundations":{"colour":[],"spacing":[]},"composition":{"contains":["SkyBand","GridOverlay","Nav","Footer"],"containedBy":["Home","Variant","Project","NotFound"],"slots":["default"]},"constraints":[{"rule":"One per page, wraps all page content","reason":"Owns the single <html>/<body> document shell — nesting layouts would duplicate the head and chrome"},{"rule":"variant.noindex sets a noindex meta tag","reason":"Personalised /for/[slug] pages can be excluded from search indexing per-variant"}],"a11y":{"role":"document","keyboard":"No interactive elements of its own — delegates to Nav and Footer"},"agentContext":{"when":"Every page outside /system — Home, Variant, and Project views.","notWhen":"The /system page, which uses SystemLayout instead for its sidebar/topbar chrome.","prefer":"SystemLayout for /system routes"}},{"kind":"component","name":"Behaviour Block","slug":"behaviour-block","href":"/system/internal#behaviour-block","sourceFile":"src/components/system/BehaviourBlock.astro","propsApi":{"signature":"interface Props { contains?: string[]; usedBy?: string; role?: string; keyboard?: string; when?: string; notWhen?: string; prefer?: string; constraints?: { rule: string; reason: string }[]; }","binding":"const { contains, usedBy, role, keyboard, when, notWhen, prefer, constraints = [] } = Astro.props;"},"icon":"lightning","tier":"component","internal":true,"why":"Displays composition, accessibility, agent context, and constraints for a system entry.","foundations":{"type":[".t-body-mono"],"colour":["--ink","--ink-secondary","--surface","--border"],"spacing":["--space-within","--space-related","--space-blocks","--space-150"]},"composition":{"contains":["Label"],"containedBy":["EntryCard","System Section"]},"a11y":{"role":"generic"},"agentContext":{"when":"Inside EntryCard only.","notWhen":"Never used standalone."},"constraints":[],"spacing":{"internal":"Rows use label-value pairs, constraints below dashed divider"}},{"kind":"component","name":"Button","slug":"button","href":"/system/primitives#button","sourceFile":"src/components/shared/Button.astro","propsApi":{"signature":"interface Props { variant?: 'cta' | 'disc' | 'toggle' | 'ghost'; href?: string; icon?: IconName; label?: string; [key: string]: any; }","binding":"const { variant = 'ghost', href, icon, label, ...attrs } = Astro.props;"},"icon":"cursor-click","tier":"component","why":"The primary interactive affordance. Drives navigation and actions across the site. Four variants — ghost (default), cta (circular accent disc), disc (the same circle in surface, for a secondary route standing next to a cta), toggle (ghost with the box removed, for a state control that must not read as a third button).","internal":false,"group":{"name":"Primitives","order":1,"description":"Atomic building blocks composed into larger components."},"foundations":{"colour":["--accent","--ink","--ground","--surface","--ink-secondary","--border"],"spacing":["--space-within","--space-related","--cell"],"type":[".t-body-mono"]},"composition":{"contains":["Icon"],"containedBy":["Nav","ClockGroup","System Section"],"slots":["default"]},"constraints":[{"rule":"One CTA variant per viewport","reason":"Multiple accent discs compete for attention and break visual hierarchy"},{"rule":"A disc variant needs an aria-label","reason":"Its label is one or two characters chosen to fit the circle — \"DS\" names nothing read aloud"},{"rule":"Always provide href or button type","reason":"Renders as <a> or <button> — never an unsemantic element"},{"rule":"Put visible text in the default slot; label is a cursor hint and becomes the accessible name only when the slot is empty","reason":"The prop is interaction metadata, not a second way to render button copy"}],"a11y":{"role":"button | link","keyboard":"Enter activates; Space too only when it renders as a real <button> — an anchor render responds to Enter alone. focus-visible ring uses --accent","wcag":"AA contrast maintained across full colour clock range — accent on ground tested at all times of day"},"agentContext":{"when":"Any clickable action — navigation links, form submits, CTAs. Choose variant by prominence: cta for page-level primary, disc for a secondary route that has to sit beside a cta, ghost for inline/back nav, toggle for a two-state control whose state is the whole message.","notWhen":"Card-level click targets where the entire card is interactive — use the card's own link wrapper instead.","prefer":"SummaryCard or SpotlightCard for case study navigation"},"spacing":{"internal":"gap: var(--space-within) between icon and label; padding: var(--space-within) (v) / var(--space-related) (h)","external":"gap only, no margin — parent controls placement"},"usage":[{"kind":"requires","when":{"prop":"variant","equals":"disc"},"props":["aria-label"]},{"kind":"limit","when":{"prop":"variant","equals":"cta"},"max":1,"scope":"file"}],"variants":[{"prop":"variant","options":["cta","disc","toggle","ghost"],"default":"ghost"}],"examples":{"url":"/","selector":".btn"}},{"kind":"component","name":"Byline","slug":"byline","href":"/system/content#byline","sourceFile":"src/components/shared/Byline.astro","propsApi":{"signature":"interface Props { variant?: Pick< CollectionEntry<'variants'>['data'], 'company_name' | 'logo_url' | 'logo_url_dark' | 'mark_fill' >; }","binding":"const { variant } = Astro.props;"},"icon":"signature","tier":"component","why":"The line at the top of the page saying who made it: the headshot, \"Alan Long\", and the role. On a /for/ page the headshot gains \"/for/\" and the addressed company's mark beside it, so the page states in one line who is writing and who is being addressed. Named for the editorial term, because that is exactly what it is; it is not a hero, a header, or a masthead, none of which survive being moved to a different page.","internal":false,"group":{"name":"Content","order":2,"description":"People, quotes, and metrics — the substance that fills layouts."},"foundations":{"type":[".t-body-lg"],"colour":["--ink-secondary"],"spacing":["--space-related"]},"composition":{"contains":["Identity","Mark"],"containedBy":["PageFlow","System Section"],"slots":[]},"constraints":[{"rule":"It is a flush block — it takes .col-flush and never the label/prose split","reason":"The byline and the headline are the two blocks that start at the left edge of the reading area with nothing beside them. That is what makes them read as the head of the page rather than the first entry in the run of passages below (DESIGN.md → Layout composition)"},{"rule":"The /for/ clause renders only when a variant is supplied","reason":"On / there is no company being addressed, so there is nothing for the clause to say. An empty or placeholder company is worse than its absence — the line simply becomes the headshot, the name and the role"},{"rule":"The title is Alan's, it is the same on every page, and it always renders","reason":"The byline is headshot, name, title — and on a /for/ page, \"/for/\" and the company mark beside the headshot. That is the whole component. It is not parameterised by the variant: a variant is an application, and the role being applied for is not his title. It also renders at every width; it used to be hidden above 1320px, which meant the block that states who is writing said two different things depending on the size of the window"},{"rule":"The /for/ clause is lifted onto the headshot's line by CSS order, never by source order","reason":"The frame reads [headshot] /for/ [logo], then the name, then the role. Source order stays name-first so a screen reader hears who is speaking before who is addressed — a deliberate a11y decision, and the reason this is an order/display:contents reorder rather than a second markup branch"},{"rule":"Byline has no composition breakpoint of its own","reason":"1320px is where the page's label/content columns stack and the nav cluster starts to overlay. The byline has no columns to stack — it is one flush block that wraps onto two lines by the same rule at any width — so it has nothing to answer that breakpoint with, and forking on it was what let the two widths disagree about whether the role exists"},{"rule":"Byline restyles Identity's own classes through :global — the sub-line's visibility and the mark's position are the byline's composition, not Identity's","reason":"Same parent-restyles-child move PriorWorkGrid makes on .summary-card and ClockGroup on .btn. Identity has no business knowing about a clause that sits between its mark and its text. Note display:contents on the LinkedIn anchor: it stays keyboard-focusable and in the a11y tree in current browsers, but it paints no box, so the hover fill is inert on a /for/ page — that block is inside @media (hover: hover) and the affordance is the underline either way"},{"rule":"A logo drawn in one ink supplies logo_url_dark; the pair is rendered and the theme picks one","reason":"Ground is time-derived, so a wordmark that reads at noon can be invisible at night. jackandjill is the variant that depends on this. The swap itself is Mark's — this component only forwards the pair. It used to be a second branch here that sized differently from the single-image one, which is why jackandjill's mark rendered at 29px against everyone else's 48px: which branch a company took depended only on whether it happened to have a night counterpart"},{"rule":"Both marks on the line are Mark at the large size","reason":"The headshot and the company mark are the two halves of one statement — who is writing and who is being addressed — and a line where one is visibly taller than the other reads as one of them being the subject. Large is the size Identity already gives the headshot from its two-line label, so stating it for the company mark is what makes the pair match rather than nearly match"},{"rule":"Whether the company mark takes a tile is the variant's mark_fill, not a guess from the filename","reason":"This block used to test logo_url.endsWith('.svg') and tile the raster ones, on the theory that an SVG recolours with the clock. It does not — loaded through <img src> an SVG is an isolated document, and none of the logos use currentColor anyway. The thing that needs a tile is transparency, and only the content knows (Mark → constraints)"},{"rule":"The company is its mark. The name never renders beside it","reason":"The clause is [headshot] /for/ [mark] and nothing else. Setting the name next to the mark says the same thing twice, and for the several logos that already contain their own wordmark it reads as a stutter. The name lives in alt so it is still announced and still in the page title; it is not on screen"},{"rule":"The clause never reaches for .inline-logo","reason":"That utility is em-relative, sized for a logo sitting inside a line of body copy and deliberately off the grid. The byline is not body copy — it is a two-cell mark next to a name — so it takes Mark, and the utility keeps its one job. The old code used both, and the difference between them was the 29px bug"}],"a11y":{"role":"group (div); the name is a link to LinkedIn","keyboard":"Enter follows the LinkedIn link on the name — the only focusable thing in the block. Focus takes the global accent outline","notes":"The /for/ clause is read inline as prose (\"/for/ CompanyName\") — the label, then the mark's alt, which is where the company name lives now that it does not render on screen. It stays last in source and is lifted visually onto the headshot's line with CSS order, so the reading order a screen reader gets (name, role, then the company being addressed) is deliberately not the order on screen. The night half of a day/night logo pair is aria-hidden so the pair is announced once, not twice. The headshot carries alt=\"\" from Identity — it is decorative next to the name it sits beside."},"agentContext":{"when":"The opening line of the home page or a /for/ variant page — once per page, above the headline.","notWhen":"Attributing a passage of prose further down the page — use Passage. Naming a person anywhere else — use Identity directly.","prefer":"Identity for any attribution that is not the page's own byline"},"spacing":{"internal":"gap: var(--space-related) between the name and the /for/ clause, in both axes — and, on a /for/ page where Identity flattens into the same flex context, the same role carries the distance from the headshot to the clause beside it and from that line to the name beneath, because Identity's own mark-to-text gap is the identical role — when the clause wraps to its own line it is still the second half of one line, so it takes the related role rather than a larger one. Inside the clause the spacing is margin, not gap: the /for/ label owns var(--space-related) to the mark that follows it. This is the sanctioned inline text-level margin context, not structural spacing. The mark's own tile inset is Mark's and is not restated here.","external":"gap only — the parent .flow owns the distance to the headline below, and the block carries no margin"}},{"kind":"component","name":"Clock Group","slug":"clock-group","href":"/system/layout#clock-group","sourceFile":"src/components/shared/ClockGroup.astro","propsApi":{"signature":"interface Props { compact?: boolean; specimen?: boolean; clipId?: string; }","binding":"const { compact = false, specimen = false, clipId = undefined } = Astro.props;"},"icon":"clock-countdown","tier":"component","why":"The clock, time readout, and timezone toggle composed as a single unit. The connection between viewer and maker rendered as a navigable control.","internal":false,"group":{"name":"Layout","order":3,"description":"Structural components that define page regions and navigation."},"foundations":{"type":[".t-heading-lg-mono",".t-body-mono"],"colour":["--ink","--ink-secondary","--accent"],"spacing":["--space-within","--space-related","--space-025","--cell"]},"composition":{"contains":["ColourClock","Button","Icon"],"containedBy":["Nav","System Section"],"slots":[]},"constraints":[{"rule":"One live instance per page; documentation renders use specimen mode","reason":"Mirrors ColourClock: a specimen is static and carries none of the data hooks (readout, toggles, phase) the nav script binds, so it can sit on /system without stealing the live bindings"},{"rule":"The phase icon is reported, never chosen","reason":"It is derived from the solar bounds for the viewer's latitude, so it stays true at any location and on any date. An authored icon would be wrong twice a year at every latitude and permanently wrong at the poles"},{"rule":"Dawn and dusk share one icon","reason":"The sun is on the horizon in both; which way it is travelling is what the dial beside it already says, and a second glyph would state it twice"},{"rule":"Below 800px the compact form is the dial alone — the readout stack is not rendered","reason":"In the flow at the top of a phone screen (Nav) the cluster gets a fraction of the band it gets beside a reading column, and the dial is the part of this group that is read rather than stated. It still scrubs, so the time is still reachable; it is just not written out. Both lines go together: a toggle for a time that is not shown selects nothing visible, and a time with no toggle cannot say whose it is"}],"a11y":{"role":"group","keyboard":"Toggle buttons are keyboard-accessible via Button component; the phase icon is a button and reachable in sequence","notes":"The phase icon's label names its destination rather than its state — \"Switch to night\" says what pressing it does, where \"Daytime\" would only repeat what the glyph already shows. The scrub it triggers is a sweep, and honours prefers-reduced-motion by placing the palette in one step instead."},"agentContext":{"when":"Nav left group — always present via Nav.","notWhen":"Never instantiate outside Nav."},"spacing":{"internal":"flex with gap: var(--space-within) between clock, toggles, and readout; readout stack padding: var(--space-related) (h) / var(--space-within) (bottom)","external":"Parent (Nav) controls placement"}},{"kind":"foundation","name":"Colour","slug":"colour","href":"/system/foundations#colour","sourceFile":"src/foundations/Colour.astro","propsApi":null,"icon":"palette","tier":"foundation","order":4,"internal":false,"why":"The palette is modelled on natural light — one input (time) generates every colour. The colour creates a connection between Alan and the viewer: both share the same sky.","searchTerms":"colour color clock oklch ink accent atmosphere solar bayer dither ground surface border token","foundations":{"colour":["--ground","--surface","--ink","--ink-secondary","--accent","--atmosphere-shadow","--atmosphere-body","--atmosphere-light","--border"]},"composition":{"containedBy":["System Section"],"contains":["ColourClock","LiveDemo","TokenReadout","AtmosphereSpecimen"]},"constraints":[{"rule":"No hardcoded colour values. Everything derives from time.","reason":"The generation function replaces the static primitive layer — the math is the primitive."},{"rule":"WCAG contrast must hold at every point on the spectrum.","reason":"The palette moves through light and dark continuously — contrast cannot be verified at one snapshot."}],"a11y":{"role":"region","wcag":"WCAG contrast maintained across the full day cycle by tested ink/ground and ink/surface pairs; field text clears to ground."},"agentContext":{"when":"Understanding the colour system or adding a new semantic token. Ground holds the page, ink reads, accent identifies interaction, and a separate atmospheric family colours Bayer fields.","notWhen":"Do not add static colour swatches. The generation function is the source of truth."}},{"kind":"component","name":"Colour Clock","slug":"colour-clock","href":"/system/primitives#colour-clock","sourceFile":"src/components/shared/ColourClock.astro","propsApi":{"signature":"interface Props { specimen?: boolean; clipId?: string; }","binding":"const { specimen = false, clipId = 'clock-gear-clip' } = Astro.props;"},"icon":"clock","tier":"component","why":"The connection between viewer and maker. Time of day generates every colour on the page.","internal":false,"group":{"name":"Primitives","order":1,"description":"Atomic building blocks composed into larger components."},"foundations":{"colour":["--ink","--accent","--border","--ink-secondary","--surface"],"spacing":["--clock-size"]},"composition":{"containedBy":["ClockGroup","Colour","System Section"]},"constraints":[{"rule":"One live instance per page","reason":"Multiple live clocks would fight over CSS custom property writes"},{"rule":"Documentation renders use specimen mode with a unique clipId","reason":"A specimen is a static, non-interactive drawing at noon — no data-clock hook for the script to bind, no slider role, and its own clip-path id so the SVG stays valid alongside the live dial"}],"a11y":{"role":"slider","keyboard":"Arrow keys scrub time, drag to adjust. Specimen mode is a labelled image, not a control."},"agentContext":{"when":"Never instantiate directly — the live dial is a singleton in the Nav. On /system, mount it with the specimen prop and a unique clipId.","notWhen":"Don't add a second live instance anywhere."},"spacing":{"external":"One size everywhere, from --clock-size. Nav reads the same token to reach back over the dial's optical inset."}},{"kind":"component","name":"Cycling Map","slug":"cycling-map","href":"/system/content#cycling-map","sourceFile":"src/components/shared/CyclingMap.astro","propsApi":null,"icon":"bicycle","tier":"component","internal":false,"why":"SVG map of a summer cycling route across Europe, drawn as a payload for the Figure frame. It carries no frame, no title and no height of its own — the figure around it supplies the hairline and the corner label, and the map supplies the proportion those are drawn at.","group":{"name":"Content","order":2,"description":"People, quotes, and metrics — the substance that fills layouts."},"foundations":{"type":[],"colour":["--ink","--ink-secondary","--accent","--border","--ground","--surface"],"spacing":["--space-blocks","--space-within","--cell"]},"composition":{"containedBy":["PageFlow","System Section"]},"constraints":[{"rule":"The map sets its own height and never takes one","reason":"Settled in the home overhaul dispatch: the figure adapts to its payload. Give the map a fixed box and the only options left are letterboxing — dead bands inside a hairline border, which reads as a bug — or slicing, which cuts route geography"},{"rule":"The viewBox frames the route, not the drawing","reason":"The land paths run from Norway to Greece; the ride runs Kerry to Tampere. Fitting the drawing would render the subject as a speck in an ocean of empty coastline"},{"rule":"One viewBox, two framings, chosen by aspect-ratio and resolved by slice","reason":"CSS cannot set a viewBox, and the two honest alternatives both cost: a second svg doubles ~30kb of path data in the DOM, and a <use> reference puts the stops in shadow content, killing their cursor hints. The viewBox holds the tall crop, which is a superset of the wide one on the same centre, so a container wider than its ratio crops top and bottom away and lands on the wide framing exactly"},{"rule":"The legend stays","reason":"Solid against dashed is the difference between the miles ridden and the miles taken by ferry and train, which is the point of the picture. Confirmed by Alan in the overhaul brief"},{"rule":"No title inside the map","reason":"The season and the subject are the Figure's corner label. A second title inside the payload would name the same thing twice, in a different treatment, one line below itself"},{"rule":"containedBy names the files that render the map, never Figure","reason":"The map reaches the frame through a slot, and a slot's occupant is chosen by whoever composes the two — so Figure.astro never imports it and never could. Naming Figure here fails the composition validator, which checks that the declared parent really renders the child. The frame relationship lives in why and in the rules above instead"}],"a11y":{"role":"img","wcag":"The SVG carries a descriptive aria-label and is exposed to assistive tech. Inside a Figure the figcaption names the block and this names the picture; the stop markers are pointer affordances only — their labels render as cursor hints, so the route is described in prose by the label rather than enumerated as 34 unreachable points."},"agentContext":{"when":"The travel block on the home flow — the payload of the Figure labelled with the season.","notWhen":"Any other context — the route is one specific summer, not a reusable map.","prefer":"Media for project imagery inside a frame"},"spacing":{"internal":"The legend is held off the payload edge by --space-blocks, matching the figure label above it so the two share a left edge","external":"None — the map has no margin and no gap; the Figure it sits in owns its distance to the blocks either side"}},{"kind":"component","name":"Divider","slug":"divider","href":"/system/layout#divider","sourceFile":"src/components/shared/Divider.astro","propsApi":null,"icon":"minus","tier":"component","why":"One hairline across the reading area, marking where one group of blocks ends and the next begins. The page used to draw a rule under every section automatically (the bottom hairline on .grid); the design wants rules between groups, not after every block, so the rule stops being a side effect of being a section and becomes a thing an author places on purpose.","internal":false,"group":{"name":"Layout","order":3,"description":"Structural components that define page regions and navigation."},"foundations":{"type":[],"colour":["--border"],"spacing":[]},"composition":{"contains":[],"containedBy":["PageFlow","Project","System Section"],"slots":[]},"constraints":[{"rule":"It spans .col-reading, not the prose column","reason":"What it separates is groups of full-reading-width blocks — a rule inset to the prose column would read as belonging to the passage above it rather than to the boundary between two runs of them"},{"rule":"No margin and no padding, ever — and no prop that would add one","reason":"The parent .flow owns every vertical distance on the page (DESIGN.md → Where space comes from, third principle: space belongs to the relationship and is owned by the container). A margin here would be the component holding itself off its neighbours, which is the exact thing the principle forbids, and validate-css-patterns.mjs fails the build on it"},{"rule":"It takes no props — no weight, no spacing, no variant","reason":"One boundary treatment. A second weight would mean two kinds of boundary, and nothing in the design has two; a spacing prop would move space ownership out of the flow. If a boundary ever needs to look different, that is a change to this rule, not a parameter on it"},{"rule":"Rules mark group boundaries, not every block","reason":"A rule after every block makes all the boundaries equal, and then none of them means anything. The blocks inside a group are separated by gap alone"}],"a11y":{"role":"presentation","keyboard":"None — not focusable and not interactive","notes":"An <hr> defaults to role=\"separator\", which a screen reader announces. Here the grouping it marks is already carried by the content: the project entries below it are h2s, so the document outline states the same boundary the rule states visually. Announcing it as well would be the same information twice, in a place where the reader has no way to tell it is a repeat. role=\"presentation\" says precisely that — the element is present but carries no semantics — which is more accurate than aria-hidden=\"true\", the assertion that the whole subtree should not exist for a reader. There is no subtree; there is one element whose meaning is visual."},"agentContext":{"when":"Between two groups of blocks in the page flow, where the change of subject should be seen rather than only felt.","notWhen":"Between blocks inside one group — the flow gap already separates them. Around a bordered block — use Figure, whose frame is its own edge.","prefer":"The flow's --space-sections gap alone where the break does not need to be drawn"},"spacing":{"internal":"none — the element is a single rule with no children","external":"gap only, and none of its own: the parent .flow supplies the distance on both sides. The reset zeroes the default <hr> margin every browser applies"}},{"kind":"component","name":"Easing Demo","slug":"easing-demo","href":"/system/internal#easing-demo","sourceFile":"src/components/system/EasingDemo.astro","propsApi":null,"icon":"chart-line","tier":"component","internal":true,"why":"Visualizes the site's cubic-bezier curve and shows it animating a sample element.","foundations":{"type":[".t-body-mono"],"colour":["--accent","--ink-secondary","--border","--ink","--surface","--ground"],"spacing":["--space-within","--space-related","--space-subsections","--cell"]},"composition":{"containedBy":["Motion","System Section"]},"a11y":{"role":"figure","keyboard":"Trigger button toggles animation"},"agentContext":{"when":"System page motion section only.","notWhen":"Never used outside /system."},"constraints":[{"rule":"Curve constants must match --ease","reason":"P1X/P1Y/P2X/P2Y are hardcoded, not derived from the token — update both if the easing curve changes"}],"spacing":{"internal":"subsections gap between curve and preview; within/related gaps inside each; track and box sized in cells (dimension, not rhythm)","external":"Contained by system foundations section"}},{"kind":"component","name":"Entry Card","slug":"entry-card","href":"/system/internal#entry-card","sourceFile":"src/components/system/EntryCard.astro","propsApi":{"signature":"interface Props { entry?: SystemEntry; id?: string; name?: string; why?: string; usedIn?: string; foundations?: LegacyFoundation[]; tier?: string; prose?: any; hasVariant?: boolean; variantLabels?: [string, string]; }","binding":"const { entry, tier = 'components', prose: Prose, hasVariant = false, variantLabels = ['Base', '/for/'], ...legacyProps } = Astro.props;"},"icon":"note-blank","tier":"component","internal":true,"why":"Renders each component, pattern, and view on the system page with heading, description, foundations block, and live demo slot.","foundations":{"type":[".t-heading",".t-body",".t-body-mono"],"colour":["--ink","--ink-secondary","--surface","--border","--ground"],"spacing":["--space-within","--space-related","--space-blocks","--space-heading-after"]},"composition":{"contains":["FoundationsBlock","BehaviourBlock"],"containedBy":["System Section"],"slots":["default","variant"]},"a11y":{"role":"region","keyboard":"Size toggle buttons and variant toggle are keyboard-accessible"},"agentContext":{"when":"System page only — renders one entry per component/pattern/view.","notWhen":"Never used outside /system."},"constraints":[{"rule":"One per system entry","reason":"Each entry maps to exactly one EntryCard"}],"spacing":{"internal":"gap: var(--space-blocks); heading group gap: var(--space-heading-after)","external":"gap only, no margin"}},{"kind":"component","name":"Figure","slug":"figure","href":"/system/layout#figure","sourceFile":"src/components/shared/Figure.astro","propsApi":{"signature":"interface Props { mark?: string; markType?: 'person' | 'org'; markFill?: 'fill' | 'inset'; label?: string; sublabel?: string; labelAs?: 'span' | 'h2' | 'h3'; }","binding":"const { mark, markType = 'org', markFill, label, sublabel, labelAs = 'span', } = Astro.props;"},"icon":"frame-corners","tier":"component","why":"The framed block in the home flow: a hairline frame drawn as an outline, a label in its top-left corner, and a payload that fills everything below. It is the container shape of the reading area — where a passage splits itself into a label column and a prose column, a figure ignores the split and takes the full reading width. The cycling map is its one payload today; anything else that wants the frame gets it by being put inside one.","internal":false,"group":{"name":"Layout","order":3,"description":"Structural components that define page regions and navigation."},"foundations":{"type":[],"colour":["--border"],"spacing":["--space-inset"]},"composition":{"contains":["Identity"],"containedBy":["PageFlow","System Section"],"slots":["default"]},"constraints":[{"rule":"The frame is an outline, never a border","reason":"A border occupies layout — with border-box the outer size holds but everything inside starts a pixel in, which put the label and payload off the 24px cell. An outline takes no space, so the inset stays a spacing role and nothing needs a compensating calc. Enforced by validate-css-patterns.mjs check 9"},{"rule":"The frame spans the full reading width, label column included","reason":"A container block is the shape that ignores the label/prose split — inset it to the prose column and it stops reading as a frame around the page and starts reading as an image with a caption beside it"},{"rule":"The payload sits flush to the frame on every edge except under the label","reason":"The frame is the payload's edge. A map that stops short of it, or a grid whose cells float inside it, has two boundaries — the one drawn and the one implied by the inset. The frame taking no layout space is what makes this literal: the payload really does reach the edge, and the hairline is drawn over its first pixel. That last half was aspiration until 2026-08-04 — an outline declared on the figure paints before its positioned descendants, so the cycling map's own fill covered the frame on all four of its edges. The stroke is on an absolutely positioned ::after now, which is still out of flow and still takes no space, but paints after every payload"},{"rule":"The label is Identity, sized by Identity; the mark is optional, the label text is not","reason":"One treatment for every corner label, whether the thing it names has a logo or not (a season of riding does not, and is the only labelled figure left now the Tenon block is a SpotlightCard rather than a framed embed). A second markless label component would be the same two lines rendered twice. The frame asks for no size and no arrangement: a sub-line makes the label two lines and its mark large, and that rule lives in Identity so this corner label matches every other attribution on the page rather than being the one that does not"},{"rule":"A payload may define its own internal columns; the frame never does","reason":"The sanctioned internal-division exception belongs to whatever the payload is, not to the frame. The frame's only job horizontally is to span .col-reading"},{"rule":"No label at all is a supported state, not a degraded one","reason":"A frame is a boundary, not a captioned exhibit — a payload that names itself needs no label, and making the caption mandatory would put an empty row above it"}],"a11y":{"role":"figure","keyboard":"None of its own — the frame is not interactive; anything focusable is in the payload","notes":"Renders as <figure> with the label as its <figcaption>, so the label is the figure's accessible name. labelAs promotes that name to a heading when the figure titles a section of the page; left as a span it stays out of the document outline. With no label the figure is unnamed, which is correct for a frame whose payload names itself."},"agentContext":{"when":"A block in the home flow that needs a frame around it — a map, an embed, a grid of cards.","notWhen":"A run of attributed prose — use Passage. A single case study that should read as one clickable card — use SpotlightCard.","prefer":"Passage for anything that is mostly words"},"spacing":{"internal":"padding: var(--space-inset) on the label — the frame's inset, and the payload's clearance below it. The payload itself takes no inset; it meets the border and owns any padding of its own.","external":"gap only — the parent .flow owns the distance to the blocks either side, and the frame carries no margin"}},{"kind":"component","name":"Footer","slug":"footer","href":"/system/layout#footer","sourceFile":"src/components/layout/Footer.astro","propsApi":null,"icon":"align-bottom","tier":"component","why":"Page footer with copyright, profile links, and the build credit. Sits inside the site grid using col-page placement. Email is deliberately not here — it is the nav cluster's call to action, and a second copy of it in the footer would split the one thing the page is asking for.","internal":false,"group":{"name":"Layout","order":3,"description":"Structural components that define page regions and navigation."},"foundations":{"type":[".t-body-mono"],"colour":["--ink","--ink-secondary","--surface"],"spacing":["--space-related","--space-blocks","--space-sections","--space-page","--footer-sky"]},"composition":{"contains":[],"containedBy":["BaseLayout","System Section"],"slots":[]},"constraints":[],"a11y":{"role":"contentinfo","keyboard":"Links are standard anchor elements"},"agentContext":{"when":"Every page — included via BaseLayout.","notWhen":"Never instantiate manually — BaseLayout handles it."},"spacing":{"internal":"related gap between links; blocks gap and inset when stacked at narrow widths; padding-block is the block's own height, replacing the three grid rows it used to reserve — var(--space-page) above the line of afterword and var(--footer-sky) below it, which is the footer making room for the band pinned to the end of the document rather than a height chosen here","external":"Uses grid placement (col-page), no margin"}},{"kind":"component","name":"Foundations Block","slug":"foundations-block","href":"/system/internal#foundations-block","sourceFile":"src/components/system/FoundationsBlock.astro","propsApi":{"signature":"interface Props { items: FoundationRow[]; }","binding":"const { items } = Astro.props;"},"icon":"stack","tier":"component","internal":true,"why":"Displays which design tokens an entry consumes, linking it back to the foundation layer.","foundations":{"type":[".t-body-mono"],"colour":["--ink","--ink-secondary","--surface"],"spacing":["--space-within","--space-related","--space-blocks","--space-150"]},"composition":{"contains":["Label"],"containedBy":["EntryCard","System Section"]},"a11y":{"role":"generic"},"agentContext":{"when":"Inside EntryCard only.","notWhen":"Never used standalone."},"constraints":[],"spacing":{"internal":"Rows use label-value pairs"}},{"kind":"component","name":"Grid Demo","slug":"grid-demo","href":"/system/internal#grid-demo","sourceFile":"src/components/system/GridDemo.astro","propsApi":null,"icon":"crosshair-simple","tier":"component","internal":true,"why":"Live grid visualization showing actual column count and cell size at current viewport.","foundations":{"type":[".t-body-mono",".t-heading-lg-mono"],"colour":["--accent","--ink-secondary","--ink"],"spacing":["--space-within","--space-related","--cell"]},"composition":{"containedBy":["Layout","System Section"]},"a11y":{"role":"figure"},"agentContext":{"when":"System page layout section only.","notWhen":"Never used outside /system."},"constraints":[{"rule":"The column count is derived from the page rule, never a hardcoded band.","reason":"The cell is a constant 24px and .page lays repeat(auto-fill, var(--cell)) capped at --page-max (55 cells / 1320px) and centred above it. The script recomputes cols the same way — min(cap, floor(pageWidth / cell)) — so the readout tracks the real grid at every width. There are no 40/30/20 bands left to keep in sync."}],"spacing":{"internal":"related gap between strip, measure, and readout; within gap inside the readout; measure bracket sits 2px from its label (EX-09, diagram construction)","external":"Contained by system foundations section"}},{"kind":"component","name":"Grid Overlay","slug":"grid-overlay","href":"/system/internal#grid-overlay","sourceFile":"src/components/shared/GridOverlay.astro","propsApi":null,"icon":"selection-all","tier":"component","internal":true,"why":"Visible construction grid — column lines at --cell intervals, stretching full page height. Debug and demonstration tool.","foundations":{"colour":[]},"composition":{"containedBy":["BaseLayout","SystemLayout"]},"constraints":[{"rule":"aria-hidden","reason":"Purely decorative — no semantic content."}],"a11y":{"role":"presentation","keyboard":"Global 'g' key shortcut toggles visibility (see src/scripts/main.ts)"},"agentContext":{"when":"Checking grid alignment — mounted on every page by BaseLayout and SystemLayout, hidden by default, toggled with the global \"g\" shortcut (src/scripts/main.ts).","notWhen":"Production display unless the design calls for visible construction."},"spacing":{"internal":"none — the column lines at --cell intervals are the content","external":"none — an absolutely positioned, pointer-events-none overlay; it takes no part in flow"}},{"kind":"view","name":"Home","slug":"home","href":"/system/views#home","sourceFile":"src/pages/index.astro","propsApi":null,"icon":"house","tier":"view","why":"Landing page. A vertical flow of blocks in a two-column reading layout — byline and headline flush left, the prior engagements as a bordered grid of metrics, then a run of attributed passages and framed figures. The composition is PageFlow, shared with every /for/ page; this file supplies the content a variant would otherwise supply.","internal":false,"foundations":{"spacing":[]},"composition":{"contains":["BaseLayout","PageFlow"]},"constraints":[{"rule":"The composition is PageFlow and this file adds no blocks of its own","reason":"Home and the /for/ pages are the same page with different content. Anything added here and not there is a divergence nobody will notice until a variant page is already sent"},{"rule":"Home's content lives in src/lib/home.ts, not in this file","reason":"A /for/ page gets its headline and its selected work from one YAML file. / needs the same decisions made somewhere, and one named file keeps changing home the same size of edit as adding a variant"}],"a11y":{"role":"main"},"agentContext":{"when":"The default, un-personalised landing page at /.","notWhen":"A company-specific landing page — use the Variant view at /for/[slug] instead.","prefer":"Variant for personalised outreach links"}},{"kind":"component","name":"Icon","slug":"icon","href":"/system/primitives#icon","sourceFile":"src/components/shared/Icon.astro","propsApi":{"signature":"interface Props { name: IconName; size?: number; [key: string]: any; }","binding":"const { name, size = 14, ...attrs } = Astro.props;"},"icon":"star-four","tier":"component","why":"SVG icon from the Phosphor Light set. Uses fill-based paths on a 256-unit viewBox, matching Pentameter optical weight.","internal":false,"group":{"name":"Primitives","order":1,"description":"Atomic building blocks composed into larger components."},"foundations":{"colour":[]},"composition":{"containedBy":["Button","ClockGroup","Media","Project","SummaryCard","System Section","SystemTopNavBar","SystemSidebarNav","Iconography"]},"constraints":[{"rule":"Always set size prop","reason":"The default 14px is the .t-body-mono line fit — right beside mono body text and wrong everywhere else. An unsized icon beside a larger type role reads as a different element that happens to be nearby"},{"rule":"Add icons to src/lib/icons.ts","reason":"Registry is the single source — no inline SVG paths in components"}],"spacing":{"internal":"none — a single inline SVG","external":"none — inline; the parent's gap or line box places it"},"a11y":{"role":"presentation (aria-hidden)","keyboard":"None — decorative"},"agentContext":{"when":"Inline icons alongside text. Always pass a size prop.","notWhen":"Standalone illustrations or logos — use an img or inline SVG."}},{"kind":"component","name":"Icon Specimen","slug":"icon-specimen","href":"/system/internal#icon-specimen","sourceFile":"src/components/system/IconSpecimen.astro","propsApi":{"signature":"interface Props { name: string; source: string; usedIn: string; size: number; }","binding":"const { name, source, usedIn, size } = Astro.props;"},"icon":"bounding-box","tier":"component","internal":true,"why":"Displays an icon at its actual size with metadata (source, dimensions, usage).","foundations":{"type":[".t-body-mono"],"colour":["--ink","--ink-secondary","--surface"],"spacing":["--space-within","--space-blocks","--space-inset"]},"composition":{"containedBy":["Iconography","System Section"],"slots":["default"]},"a11y":{"role":"figure"},"agentContext":{"when":"System page iconography section only.","notWhen":"Never used outside /system."},"constraints":[],"spacing":{"internal":"gap: var(--space-blocks) between the icon and its metadata — the two things the specimen puts side by side; card inset: var(--space-inset); gap: var(--space-within) between the metadata lines"}},{"kind":"foundation","name":"Iconography","slug":"iconography","href":"/system/foundations#iconography","sourceFile":"src/foundations/Iconography.astro","propsApi":null,"icon":"pen-nib","tier":"foundation","order":6,"internal":false,"why":"Phosphor Light at 256-unit viewBox — the light weight matches Pentameter 400 optically. Icons live in a central registry, which since 2026-08-05 holds two populations: the handful the interface reuses, and one mark per design-system entry.","searchTerms":"iconography icon phosphor svg registry","foundations":{"type":[".t-display"]},"composition":{"containedBy":["System Section"],"contains":["Icon","IconSpecimen"]},"constraints":[{"rule":"All recurring icons come from Phosphor Light via the registry.","reason":"Consistency — one source, one weight, one viewBox. The registry is the single point of control."},{"rule":"A one-off icon in the interface uses inline SVG; the registry is for icons the interface reuses.","reason":"Adding a single-use interface icon to the registry creates dead weight — a name everyone has to read past on the way to the ones that matter."},{"rule":"The design-system entry marks are the exception, and they are a set rather than a run of one-offs.","reason":"Every /system entry declares an icon on its own metadata, which put roughly sixty single-use names in the registry and reads at first like the rule above being broken wholesale. It is not the same case. Those are not one-offs that happen to be used once; they are one population, added and removed as entries are added and removed, and each is used exactly once for the same reason a client logo is — it identifies the one thing it names. The rule above exists to stop the registry accumulating unrelated marks nobody can account for, and a set with a rule for its membership is the opposite of that. The test is whether the name is reachable from a rule: if you cannot say which entry a registry name belongs to, it is dead weight."}],"a11y":{"role":"region","wcag":"All icons use aria-hidden=\"true\" and focusable=\"false\" — meaning is carried by adjacent text, not the icon."},"agentContext":{"when":"Adding an icon to the site. Copy the Phosphor Light path data into src/lib/icons.ts, pick a name, and it is available everywhere via <Icon>.","notWhen":"Do not use icon fonts, sprite sheets, or runtime icon libraries."}},{"kind":"component","name":"Identity","slug":"identity","href":"/system/content#identity","sourceFile":"src/components/shared/Identity.astro","propsApi":{"signature":"interface Props { src?: string; srcDark?: string; name: string; title?: string; type?: 'person' | 'org'; markFill?: 'fill' | 'inset'; nameAs?: 'span' | 'h2' | 'h3' | 'cite'; href?: string; linkName?: string; summary?: string; }","binding":"const { src, srcDark, name, title, type = 'person', markFill, nameAs = 'span', href, linkName, summary, } = Astro.props;"},"icon":"user","tier":"component","why":"Names a person, an organisation or a subject, optionally giving it a face. A Mark next to a name and an optional sub-line. The square itself is Mark's; what Identity owns is the rule connecting it to the label — which size, and which treatment when the content has not said. Optionally links out with a cursor hint.","internal":false,"group":{"name":"Content","order":2,"description":"People, quotes, and metrics — the substance that fills layouts."},"foundations":{"type":[".t-body-mono",".t-body"],"colour":["--ink","--ink-secondary","--surface"],"spacing":["--space-related","--space-blocks","--space-012"]},"composition":{"contains":["Mark"],"containedBy":["TeamPanel","Project","Byline","Figure","Passage","System Section"],"slots":["default"]},"constraints":[{"rule":"The mark is large beside a sub-line, standard everywhere else","reason":"Beside a name and a sub-line the text block is two .t-body-mono lines — 48px — so a 2-cell mark and the text are the same height and align rather than approximately centre. Beside a lone name there is one 24px line and the mark takes the standard 1.5 cells"},{"rule":"Size comes from the label and from nothing the caller passes","reason":"The old `scale` prop is gone. It survived the removal of the 12px type tier doing one thing — sizing the mark off the grid instead of the text — and with Mark down to two sizes there is nothing left for it to choose. A prop that lets one attribution be smaller than another is how the page ended up with 24px, 36px and 48px marks and no rule saying which was which"},{"rule":"The mark is always beside the text, never above it","reason":"One arrangement, so every attribution on the site reads the same way and the two-line ones all get the large mark. The stacked layout that used to be a prop produced the opposite: Passage and Figure asked for it precisely when they had a sub-line, so the attributions with the most to say were the only ones rendered small — and Passage then turned it back into a row above 1320px anyway. An arrangement a caller overrides in CSS was never a choice worth offering"},{"rule":"src is omittable — the mark is the optional part, the name is not","reason":"Some things a label names have no face and no logo: a period of time, a place, a subject. Dropping the mark keeps them on the same two-line treatment as an attribution that has one, instead of forking a second label component to render the same two lines"},{"rule":"type chooses the mark's treatment only where the content has no answer; markFill overrides it","reason":"A person is a face and fills the square; an organisation is usually a wordmark with transparency and needs the tile. That default is right often enough to be worth having and wrong often enough that content must be able to say so — Tenon's icon and the two photographic company marks all carry their own ground and would sit on a tile they do not need. What the file is called never enters into it (Mark → constraints)"},{"rule":"A logo drawn in one ink needs srcDark; one drawn in its own colours does not","reason":"Ground is time-derived, so a mark that is legible at noon can be invisible at night. An SVG wordmark in a single dark ink is the case that breaks — hence the work collection carries logo_dark for exactly those clients and only those"},{"rule":"nameAs is semantics only — an h2, a cite and a span render identically","reason":"Only the document outline changes. This was stated here long before it was true: <cite> is italic by user-agent default and .t-body-mono did not state font-style, so every quote attribution rendered italic while this line promised it could not. The type classes now state the whole face and a check in validate-css-patterns.mjs fails the build if one of them stops doing so — the promise is enforced rather than asserted"},{"rule":"The label wraps. It is never truncated, at any width, in any container","reason":"It used to be nowrap with an ellipsis, holding the text block to the mark's height — two 24px lines against a 48px square. The alignment was real and the price was words: \"Senior Brand Manager, GSK\" needs 252px of the 168px the label column leaves, so it rendered as \"Senior Brand Manag…\". It also could not hold everywhere, and had acquired one exception for below 800px and another for the detailed form — two exceptions to a three-line rule, which is what a wrong rule looks like rather than an under-specified one. A label a line taller than its mark is a smaller cost than a name the reader cannot finish, and it is the reason Passage needs no second arrangement to attribute a person"},{"rule":"An ancestor link gets the same name underline without a caller prop","reason":"Identity can see the ancestor in CSS. Requiring each container to remember an inLink flag duplicated a fact already present in the DOM, and any container that forgot it lost the affordance silently"}],"a11y":{"role":"link | div","keyboard":"Enter follows link when href provided","notes":"The mark carries alt=\"\" — it is decorative next to the name it repeats. nameAs promotes the name to a heading or a <cite> so attributions land in the document outline only when they title a section."},"agentContext":{"when":"Any person or org attribution. Use type='org' for companies.","notWhen":"Decorative avatars without a name beside them. A square mark with no name at all — that is Mark on its own.","prefer":"Identity over a bare Mark wherever there is a name: the size rule lives here, and a caller placing its own Mark beside its own text has to reinvent it"},"spacing":{"internal":"gap: var(--space-related) between mark and text, padding-right: var(--space-related) on text; detailed variant column-gap: var(--space-blocks), row-gap: var(--space-related). The mark's own tile inset belongs to Mark. The sub-line is nudged up by var(--space-012) to close the 24px line box","external":"gap only, no margin"},"usage":[{"kind":"requires","when":{"prop":"srcDark"},"props":["src"]}]},{"kind":"component","name":"Impact Metrics","slug":"impact-metrics","href":"/system/content#impact-metrics","sourceFile":"src/components/shared/ImpactMetrics.astro","propsApi":{"signature":"interface Props { metrics: Array<{ value: string; label: string; description?: string; }>; [key: string]: any; }","binding":"const { metrics, ...attrs } = Astro.props;"},"icon":"chart-bar","tier":"component","why":"A project's measurable outcomes as a run of cells — value, label, and an optional line saying what it means. It is scanned rather than read, so it takes the scan column rather than the prose measure, and it draws nothing: the numbers are the run.","internal":false,"group":{"name":"Content","order":2,"description":"People, quotes, and metrics — the substance that fills layouts."},"foundations":{"type":[".t-heading-lg-mono",".t-body"],"colour":["--ink","--ink-secondary"],"spacing":["--space-blocks","--space-inset","--space-related"]},"composition":{"containedBy":["Project","System Section"],"placement":["col-scan"]},"constraints":[{"rule":"2-4 metrics per stack","reason":"Fewer than 2 looks sparse, more than 4 stretches the page"},{"rule":"It spans .col-scan, not .col-page or the prose column","reason":"The run is scanned, not read, so no reading measure binds it — but it belongs to a labelled section, and .col-page would cut back across the label column beside it. .col-scan is the scanned width of the reading area: it starts where the prose starts and ends where the figures end"},{"rule":"Equal cells from the row's own grid-template-columns","reason":"The sanctioned internal-division exception (CLAUDE.md → Grid placement). Three equal cells across 30 cells is 240px each and does not land on page column lines; the count is a property of the content, and the page grid has no opinion about three"},{"rule":"The cell takes SummaryCard's grouping — two parts at two distances, not three things at one gap","reason":"The value and the line naming it bind to each other; the sentence explaining them stands one step off. Three items at one gap read as three unrelated things, which is what the stacked form did before. Same reason, same two roles, as the card in the prior-work row"},{"rule":"Nothing is drawn, and only the trailing edge is inset","reason":"The same rule the work card states: a left inset would hold the value off the line the prose starts on, and the trailing inset is what keeps a description off the value of the cell beside it"}],"a11y":{"role":"list (ul) of cells","notes":"Renders as a <ul> so the count is announced — the run is a set of parallel facts, and how many there are is part of what it says."},"agentContext":{"when":"Quantitative project outcomes — numbers that prove impact.","notWhen":"Qualitative results — use prose or a Passage quote."},"spacing":{"internal":"row-gap: var(--space-blocks) between wrapped rows and no column gap — the cells meet, and each cell's trailing inset is its own. Inside a cell: var(--space-related) between the value and its label, var(--space-inset) from that pair to the description","external":"gap only — the parent .block owns the distance to the label beside it and the flow owns the distance to the quote below"}},{"kind":"foundation","name":"Intro","slug":"intro","href":"/system#intro","sourceFile":"src/foundations/Intro.astro","propsApi":null,"icon":"hand-waving","tier":"foundation","order":0,"intro":true,"internal":false,"why":"A portfolio made to form connections — the Gulbenkian aesthetic and typography express the relationship between people and technology.","searchTerms":"intro design system purpose","foundations":{"type":[".t-display",".t-heading",".t-body"],"colour":["--ink","--ink-secondary","--surface","--border"],"spacing":["--space-within","--space-related","--space-inset","--space-blocks","--space-heading-after"]},"composition":{"containedBy":["System"],"slots":["default"]},"constraints":[],"a11y":{"role":"region"},"agentContext":{"when":"System page preamble. Sets the frame for everything that follows.","notWhen":"Not a reusable component — only appears once at the top of /system."}},{"kind":"component","name":"Label","slug":"label","href":"/system/primitives#label","sourceFile":"src/components/shared/Label.astro","propsApi":{"signature":"interface Props { label: string; href?: string; [key: string]: any; }","binding":"const { label, href, ...attrs } = Astro.props;"},"icon":"tag","tier":"component","why":"Compact inline metadata — a token reference or a cross-link, set in mono inside a hairline outline. Optional href. It carried an optional icon and listed status indicators as a third job until 2026-08-05: the /system status badge was the only caller that ever passed an icon, and when that badge went the prop had nothing left exercising it but its own specimen, so it went too.","internal":false,"group":{"name":"Primitives","order":1,"description":"Atomic building blocks composed into larger components."},"foundations":{"type":[".t-body-mono"],"colour":["--ink-secondary","--border","--ground","--ink"],"spacing":["--space-within"]},"composition":{"contains":[],"containedBy":["FoundationsBlock","BehaviourBlock","System Section"]},"constraints":[{"rule":"No semantic colour variants","reason":"A label is metadata, not a state: it names a token or points at an entry, and neither of those is a thing to be green or red about. The reason used to be that the icon communicated status instead of the background — both halves of that are now gone, and the rule survives them because a coloured chip would still be the label claiming a meaning it does not have"},{"rule":"Text and an optional link, and no third affordance","reason":"The icon prop was that third thing. It existed for one caller — the /system status badge — and outlived it by nothing: FoundationsBlock and BehaviourBlock, the two components that actually render labels, both pass text and an href and never passed an icon. A prop kept alive by its own specimen is a prop the system is demonstrating rather than using"}],"a11y":{"role":"When href: link. Otherwise: static text.","keyboard":"When href: focusable, Enter to follow."},"agentContext":{"when":"Compact metadata — token references and component cross-links.","notWhen":"Interactive chips, toggles, or buttons with actions."},"spacing":{"internal":"padding: var(--space-within) — the outline's hold on the text. No gap: one child","external":"Inline with adjacent text or labels"}},{"kind":"foundation","name":"Layout","slug":"layout","href":"/system/foundations#layout","sourceFile":"src/foundations/Layout.astro","propsApi":null,"icon":"grid-nine","tier":"foundation","order":2,"internal":false,"why":"The 24px cell derived from body line-height is the atomic unit. Every spacing token, grid column, and component dimension is a multiple or fraction of it. The grid is not hidden infrastructure — it is visible construction.","searchTerms":"layout grid spacing cell subgrid column measure inset","foundations":{"spacing":["--space-012","--space-025","--space-050","--space-100","--space-150","--space-200","--space-300","--space-400","--space-within","--space-related","--space-inset","--space-blocks","--space-subsections","--space-sections","--space-page","--space-heading-before","--space-heading-after"]},"composition":{"containedBy":["System Section"],"contains":["GridDemo","SpacingSpecimen"]},"constraints":[{"rule":"Every spacing value must be a cell multiple or fraction.","reason":"Arbitrary values break the mathematical relationship that holds the layout together."},{"rule":"Spacing is measured because it is derived.","reason":"Every space comes from the cell; the scale itself may be generous."}],"a11y":{"role":"region"},"agentContext":{"when":"Choosing spacing or grid placement for new UI. Use role tokens (--space-within through --space-sections, --space-heading-before/-after) — the scale steps are the generator behind them. The cell is a CSS custom property; every step is a calc() expression derived from it.","notWhen":"Do not introduce spacing values outside the token set."}},{"kind":"component","name":"Live Demo","slug":"live-demo","href":"/system/internal#live-demo","sourceFile":"src/components/system/LiveDemo.astro","propsApi":{"signature":"interface Props { label: string; }","binding":"const { label } = Astro.props;"},"icon":"play","tier":"component","internal":true,"why":"Wraps a live component instance on the system page in a labelled figure — one consistent surface and label for every interactive specimen, so a demo reads as a demo and not as page content.","foundations":{"type":[".t-body-mono"],"colour":["--ink-secondary","--surface"],"spacing":["--space-related","--space-200"]},"composition":{"containedBy":["Colour","System Section"],"slots":["default"]},"a11y":{"role":"figure"},"agentContext":{"when":"System page only — wraps interactive demos with a label.","notWhen":"Never used outside /system."},"constraints":[],"spacing":{"external":"Contained by system foundations section"}},{"kind":"component","name":"Mark","slug":"mark","href":"/system/primitives#mark","sourceFile":"src/components/shared/Mark.astro","propsApi":{"signature":"interface Props { src: string; srcDark?: string; alt: string; size?: 'standard' | 'large'; fill?: 'fill' | 'inset'; }","binding":"const { src, srcDark, alt, size = 'standard', fill = 'inset' } = Astro.props;"},"icon":"image-square","tier":"component","why":"The square image that identifies something: a headshot, a logo, an app icon. It is one square in two sizes, and the picture either fills it or is inset on a surface tile — that is the whole model. It exists because the same square was being declared six times across Identity, SummaryCard and Byline, each with its own height, its own tile rule and its own copy of the day/night swap, and every one of those declarations drifted from the others. A mark that is 24px in one block and 48px in another is not a size decision anyone made; it is the arithmetic of three components each sizing from something local.","internal":false,"group":{"name":"Primitives","order":1,"description":"Atomic building blocks composed into larger components."},"foundations":{"type":[],"colour":["--surface"],"spacing":["--space-within","--cell"]},"composition":{"contains":[],"containedBy":["Identity","Summary Card","Byline","System Section"],"slots":[]},"constraints":[{"rule":"Two sizes and no third: standard is 1.5 cells, large is 2","reason":"Both are line boxes, not avatar sizes. 1.5 cells (36px) is the .t-heading-lg-mono line and a single .t-body-mono line beside a mark; 2 cells (48px) is two stacked .t-body-mono lines. A third size would have to be measured against something, and there is no third thing a mark sits beside"},{"rule":"No per-instance sizing prop, and no way to nudge one square off the pair","reason":"The six declarations this component replaced were all per-instance exceptions that each looked reasonable alone — a one-cell chip here, a zeroed inset there, a logo_scale field in the content schema. Together they meant no two marks on the page agreed. The size is chosen by the caller from two named options and by nothing else"},{"rule":"fill vs inset is stated by the content, never derived from the file extension","reason":"The rule this replaces read \"a raster logo takes the tile, an SVG does not\", on the theory that an SVG recolours with the clock. It cannot: loaded through <img src> an SVG is an isolated document with no access to the page, so it can never inherit currentColor — and none of the logo SVGs in /public/logos use currentColor anyway; they all carry hardcoded fills. What actually needs the tile is transparency, which is orthogonal to the format. tenon-icon.svg is the proof: a solid-ground SVG that must fill, exactly the case any extension heuristic gets backwards"},{"rule":"inset holds the mark off the tile by --space-within, and that value is the same everywhere","reason":"The inset is the tile's relationship to the thing on it, and that relationship does not change with the size of the square. A per-size inset would be two tile treatments wearing one name — which is how the old one-cell chip ended up with its padding zeroed to stay readable"},{"rule":"A mark drawn in a single ink supplies srcDark; both are rendered and the theme picks one","reason":"Ground is time-derived, so a wordmark legible at noon can be invisible at night. The swap is display rather than opacity so the hidden half takes no space in the row it sits in. This is the one copy of that swap for square marks — .inline-logo in utilities.css keeps its own because it is em-relative for a logo inside a line of body copy, deliberately off the grid"},{"rule":"alt describes the mark once; the night half is always silent","reason":"The pair is one mark rendered twice for the clock, not two images. Announcing both says the same company's name twice with no way for a listener to tell it is a repeat, which is what the SummaryCard pair did before it came through here"}],"a11y":{"role":"img","keyboard":"None — a mark is never focusable; anything interactive is the element around it","notes":"alt is required and may be the empty string — the caller has to decide, because whether a mark names the thing or merely decorates a name already beside it is the caller's composition, not the mark's. Identity passes alt=\"\" (the name is the text next to it); Byline passes the company name, because in the /for/ clause the mark is the only thing naming the company. The night half of a day/night pair carries alt=\"\" and aria-hidden=\"true\" regardless."},"agentContext":{"when":"Any square image identifying a person or an organisation — a headshot, a logo, an app icon.","notWhen":"A logo sitting inside a line of body copy — that is .inline-logo, sized in em against the text around it. Project imagery — use Media. A decorative image that is not square and does not identify anything.","prefer":"Identity over Mark directly wherever the mark has a name beside it — Identity owns the size rule that follows from the label"},"spacing":{"internal":"padding: var(--space-within) on the inset variant only — the tile's hold on the mark. The fill variant has no padding: it is the tile","external":"none — the mark has no margin and no gap of its own; whatever places it beside a name owns that distance"}},{"kind":"component","name":"Media","slug":"media","href":"/system/primitives#media","sourceFile":"src/components/shared/Media.astro","propsApi":{"signature":"interface Props { src: ImageMetadata | string; alt: string; caption?: string; variant?: 'surface' | 'fill' | 'card'; columns?: 1 | 2; padding?: 100 | 200 | 300; width?: number; height?: number; poster?: string; playback?: 'loop' | 'once'; preload?: 'none' | 'metadata' | 'auto'; lightbox?: boolean; }","binding":"const { src, alt, caption, variant = 'surface', columns = 1, padding = 200, width, height, poster, playback = 'once', preload = 'none', lightbox = true, } = Astro.props;"},"icon":"film-strip","tier":"component","why":"Presents media with grid-aligned height, or fills a caller-sized container. Surface variant pads and mounts on a backdrop; fill variant lets the image go edge-to-edge with the container snapped to the nearest grid line; card variant drops a bare, edge-to-edge image/video into the caller's own layout (e.g. a homepage card) with no figure chrome.","internal":false,"group":{"name":"Primitives","order":1,"description":"Atomic building blocks composed into larger components."},"foundations":{"colour":["--surface","--overlay-ink","--overlay-ink-bright","--overlay-scrim","--overlay-scrim-heavy"],"spacing":["--space-within","--space-related","--cell"],"type":[".t-body-mono"]},"composition":{"contains":["Icon"],"containedBy":["Project","SpotlightCard","System Section"],"slots":[]},"constraints":[{"rule":"Alt text is mandatory; caption is optional","reason":"Every image needs alt text for screen readers; a visible caption is only meaningful where the layout has room for one (omit for card variant)"},{"rule":"The caption wraps and is never truncated","reason":"It was nowrap with an ellipsis, which cut every case-study caption mid-sentence at narrow widths — six of six on /project/google at 390px, one of six still at 1440px — leaving the full text reachable only by opening the lightbox. The grid snap models a one-line caption (CAPTION_PX), so a wrapped caption puts its figure a cell below where the snap intends; that is the accepted cost, because the snap exists to regularise the composition and a sentence the reader cannot finish is content thrown away rather than a composition being tidied"},{"rule":"Height snaps to the cell grid only where the container is a known cell count","reason":"Surface uses padding; fill uses object-fit cover with a snapped container height. Both snaps model the container as a cell count, which is exact above the composition breakpoint (.col-reading is a fixed 40) and a guess below it, where the columns run 3 / -3 and track the viewport. Fill is the variant where a guess does damage — it crops to the height it was given, so a count modelled on a 960px viewport showed 73% of the image at 1319 and 43% at 799. Below the breakpoint the fill frame takes the image ratio instead and is not snapped. Surface keeps its per-band adjustment because it pads rather than crops: being off there costs a fraction of a cell of position, never a pixel of image. Card is unsnapped everywhere and fills whatever container the caller provides."},{"rule":"Videos should provide a poster","reason":"Fallback frame when autoplay is blocked or while loading"},{"rule":"Videos default to play-once mode","reason":"Plays on scroll entry, stops at end with replay button (surface/fill only); use playback=\"loop\" for continuous"},{"rule":"Autoplay is suppressed under prefers-reduced-motion","reason":"Scroll-entry autoplay is gated on matchMedia(reduce); the video stays on its poster/first frame and the replay button is shown as a manual play control"},{"rule":"BREAKPOINTS grid constants must match the CSS grid","reason":"Each entry resolves to a 24px cell (viewportPx / gridCols = 24), matching the constant --cell in src/styles/base.css. primarySpan (40) must equal .col-reading's span in utilities.css, insetOffset must equal that band's page inset in cells (two between 800 and 1319, one below 800), and the <style> media queries plus lib/cloudinary buildSizes are keyed to the same boundaries (1319 / 799) — update them together if the grid changes. They had drifted: tablet modelled a one-cell inset the columns stopped using, and the lower boundary was still 399 from before the columns split into three bands"},{"rule":"Cloudinary image src gets a generated srcset","reason":"Every image src is a Cloudinary delivery URL with one w_<n> transform — srcset/sizes are derived from it, never re-processed through Astro's own image pipeline"}],"a11y":{"role":"figure (surface/fill) | none (card)","keyboard":"Lightbox trigger is a button (Enter/Space opens the full-screen viewer); replay/play is a button. Card and non-lightbox media are noninteractive — they carry no tab stop, but the figure and its alt text stay in the accessibility tree.","wcag":"Mandatory alt text; caption provides visible context when present; lightbox trigger carries an aria-label naming the media"},"agentContext":{"when":"Project pages — any image or video (variant=\"surface\"|\"fill\"). Fixed-size card slots — e.g. the homepage SpotlightCard — use variant=\"card\".","notWhen":"Inline illustrations or icons that don't need a figure wrapper — those stay on plain <img> (e.g. SummaryCard/Hero logos, Identity avatars)."},"spacing":{"internal":"surface: padding with grid-snap; fill: container height snapped to grid above the composition breakpoint, the image's own aspect-ratio below it; card: fills the caller's container, no grid-snap chrome; frame-to-caption gap: var(--space-related)","external":"gap only, no margin"},"usage":[{"kind":"forbids","when":{"prop":"variant","equals":"card"},"props":["caption","columns","padding","lightbox"]}]},{"kind":"foundation","name":"Motion","slug":"motion","href":"/system/foundations#motion","sourceFile":"src/foundations/Motion.astro","propsApi":null,"icon":"wave-sine","tier":"foundation","order":5,"internal":false,"why":"Motion creates the sense that content belongs where it lands. One easing curve applied to everything. Composition, not animation — content finds its place on the grid as the viewer scrolls.","searchTerms":"motion easing animation transition ease duration cubic-bezier","foundations":{},"composition":{"containedBy":["System Section"],"contains":["EasingDemo"]},"constraints":[{"rule":"One easing curve: cubic-bezier(0.05, 0.7, 0.1, 1.0). Applied to everything.","reason":"Consistency is the point — one curve means every transition shares the same physical character."},{"rule":"prefers-reduced-motion removes scroll-driven composition entirely.","reason":"The page works perfectly static because the structure holds without motion."}],"a11y":{"role":"region","wcag":"prefers-reduced-motion fully respected — all scroll-driven composition removed, content simply present."},"agentContext":{"when":"Adding transitions or scroll-driven effects. Use var(--ease) for the timing function. Pace is slow — content is being told, not thrown.","notWhen":"Do not introduce bounce, spring, or snap easings. Do not add decorative animation."}},{"kind":"component","name":"Nav","slug":"nav","href":"/system/layout#nav","sourceFile":"src/components/layout/Nav.astro","propsApi":{"signature":"interface Props { specimen?: boolean; }","binding":"const { specimen = false } = Astro.props;"},"icon":"list","tier":"component","why":"The site's only navigation, composed as a floating cluster rather than a bar. Time readout and colour clock on the left, the contact disc and the design-system disc stacked on the right. It sits over the page instead of banding across the top of it, so no page ever starts underneath a strip of chrome.","internal":false,"group":{"name":"Layout","order":3,"description":"Structural components that define page regions and navigation."},"foundations":{"type":[],"colour":[],"spacing":["--space-within","--space-page","--space-inset","--clock-size","--cell","--page-max"]},"composition":{"contains":["ClockGroup","Button"],"containedBy":["BaseLayout","System Section"],"slots":[]},"constraints":[{"rule":"One live Nav per page; /system documents it in specimen mode","reason":"Fixed positioning and the ColourClock singleton inside it both assume a single live instance. The specimen renders the same cluster static and in flow — no veil, no landmark role, no data hooks — so the documentation page shows the composition without mounting a second navigation over itself"},{"rule":"Out of the document flow above 800px, in it below","reason":"Above 800px the page composition leaves the right of the grid empty for this cluster, and a nav that occupied a row would put every page back under a bar with that column serving no purpose. Below 800px there is no empty column — the reading column spans the whole grid — so there is nothing for the cluster to float over that is not content, and staying fixed was costing every phone screen 204px of clearance at the top to keep out from under it. In the flow it contributes its own height and that clearance disappears"},{"rule":"Top-right of the centred page at every width","reason":"Above 800px: --space-page from the top and in from the centred page's right edge, which is the centring offset plus --space-page (the page centres above the 1320px cap and the cluster's cells are counted inside it; below the cap the offset is zero). Below 800px it is a block on the page's own columns, 2 / -2 and right-aligned, one cell down from the top — the same corner, reached by placement rather than by fixing. The position the earlier rule was written to remove is a different one: bottom-anchored, still fixed, still overlaying the reading column and legible only because of a --ground scrim under it. That is not what is here"},{"rule":"A page either aligns to the cluster or clears it, and either way the page decides","reason":"A fixed element contributes no height, so nothing below can space itself off it — whatever a page does about the cluster, the page has to state. The home flow aligns: its byline sits on the cluster's own centre line at every width above 800px, from var(--nav-cluster-align). 404 clears it instead, from var(--nav-cluster-bottom), because its first block is a reading column rather than a byline. The project page does neither any more: it aligns a return trail to the cluster from var(--nav-cluster-align-line), which is the same construction for a one-line block. Both tokens are composed in base.css from the cluster's own parts, so neither page measures anything off a render"},{"rule":"Below 800px the cluster is the dial and the two discs, and the readout stack is not rendered","reason":"The band the cluster gets on a phone is a fraction of the one it gets beside a reading column, and the time is the part of it that can be dropped without dropping a route: the dial still scrubs, so the time is still reachable — it is just not stated in words. The two discs are the site's only navigation and stay at every width. The cost of the flow position is that they scroll away, which is accepted: the footer carries Email and LinkedIn, and returning to the top is one gesture"},{"rule":"The veil is derived from --ground, never a literal colour","reason":"Ground is a function of the time of day and flips at 6am/6pm — a fixed light scrim reads as a white smear across a night page"},{"rule":"The veil is a Dithered Field, not a gradient — the pattern states the grid, the pitch and how coverage ends","reason":"A gradient has a box and the box has to stop somewhere. Here it stopped on the page's right edge with the fade still at full ground, which was invisible until the sky band ran past the page to the window and put a seam down every wide screen. Dithered coverage reaches zero on its own terms, so there is no boundary for anything behind it to reveal at any width, and cover at the cluster is unchanged: coverage is 1.0 there, which is every cell, which is solid"},{"rule":"The cover is the cluster's own measured box and the spread is what happens outside it","reason":"It was a falloff from the page's top-right corner for two rounds, and the reach was never the problem: the cluster sits some way in from that corner, so a shape centred there has to swell until it reaches the far side of the clock, and by then most of what it covers is page rather than cluster. Measuring nav's rect — per line, plus the dial, and never the discs, which carry their own backgrounds — is also the only version of this that follows the cluster when it changes shape at 800px"},{"rule":"The fringe breathes by cross-fading two fixed noise fields, and redraws at 12fps","reason":"Translating a field would be drift, and the sky above already drifts — a second thing sliding at the top of the page reads as the veil crawling. Cross-fading flips cells at the limit without any cell travelling. The throttle is because this element is fixed and always on screen, so unlike the band there is no scrolling out of view to stop it, and the output is quantised: between one cell flipping and the next there is nothing to draw"},{"rule":"One shape at every width the cluster is fixed at — a falloff centred on the page's top-right corner","reason":"It is a legibility treatment for the text the fixed cluster passes over, which is why it is centred on the cluster and why it reaches as far as it does. An earlier note here claimed the opposite — that content was not what the veil was for and only the clock needed a ground — and narrowed it to cover the dial alone. That was wrong: below the cap the reading column spans the full grid and body copy scrolls under the whole cluster, which is exactly the case the veil exists for and exactly where it matters most"},{"rule":"The veil belongs to Nav, not to the page","reason":"It is fixed to the same corner by the same tokens, exists for no other element, and must move if the cluster moves. Unlike the clearance the page needs, it reserves no flow space, so there is nothing the page has to be told"},{"rule":"The veil renders as a sibling of <nav>, never inside it, at z-index 8","reason":"The cluster sets z-index: 10 and so is a stacking context — a child of it paints above everything it outranks, whatever z-index the child is given. Written as a child, the veil painted ground over the chrome beneath it rather than under it. The order it needs is page content, then the veil, then the cluster; only the clock lacks a background of its own"},{"rule":"The home link stays a plain href=\"/\" anchor","reason":"initVariantOrigin() in main.ts rewrites a[href=\"/\"] so /for/ visitors return to their variant page; a computed or scripted link is invisible to it"}],"a11y":{"role":"navigation","keyboard":"Tab order follows the cluster left to right, top to bottom: clock dial (arrow keys scrub time), the two time toggles, the contact disc, the design-system disc. Both discs take the global accent focus ring.","notes":"The cluster overlays page content and is not in the flow, so it reads last in the document — after the page it floats above. The discs carry aria-labels because \"DS\" and a bare envelope glyph name nothing on their own. The legibility veil is an aria-hidden canvas with pointer-events: none, so it is invisible to assistive technology and to the cursor; what it does for a sighted reader is restore the clock's contrast where page content scrolls under it."},"agentContext":{"when":"Every page — included via BaseLayout. On /system, mount with the specimen prop.","notWhen":"Never instantiate a live Nav manually — BaseLayout handles it."},"spacing":{"internal":"flex, gap: var(--space-within) between the clock group and the disc stack, and the same between the two discs. The dial's box is larger than the face drawn in it, so the stack is pulled back over that inset (margin-left, derived from --clock-size) and the stated gap is the gap that is seen. The legibility veil behind them takes no space: it is a fixed sibling canvas whose falloff covers the cluster's own measured box and spreads four cells past it, in an element that runs the full width of the window","external":"Above 800px: none — fixed and out of flow, so it has no neighbours to hold itself off. It sits var(--space-page) from the top and var(--space-page) in from the centred page's right edge (the page centres above the --page-max cap; below it the offset is zero and this is var(--space-page) from the viewport), so it lands in the page's own frame rather than floating free of it. Below 800px it is the first block of the page, placed on columns 2 / -2 and right-aligned, with padding-block-start: var(--space-inset) — its distance to the top of the document, which is not a sibling and so cannot be a gap. The block below it owns the distance down, as PageFlow's own top padding."}},{"kind":"view","name":"NotFound","slug":"not-found","href":"/system/views#not-found","sourceFile":"src/pages/404.astro","propsApi":null,"icon":"question","tier":"view","why":"The branded 404. Astro emits it as 404.html, which the static host serves with a real 404 status for any URL that matches no route — without it, unknown URLs fall back to the homepage with HTTP 200 and read as soft 404s to crawlers, monitors, and link checkers.","internal":false,"foundations":{"spacing":["--space-page","--nav-cluster-bottom","--space-blocks","--space-inset"]},"composition":{"contains":["BaseLayout"]},"constraints":[{"rule":"Never linked to from the site","reason":"It is the host's fallback document, not a destination — the only way to arrive is a URL that matches nothing"},{"rule":"Offers the way back rather than a search or a menu","reason":"The site is one page of work and a handful of case studies; the front page is the index"}],"a11y":{"role":"main"},"agentContext":{"when":"Any request that matches no built route — the host serves this automatically.","notWhen":"Never rendered on a valid route."}},{"kind":"component","name":"PageFlow","slug":"page-flow","href":"/system/layout#page-flow","sourceFile":"src/components/layout/PageFlow.astro","propsApi":{"signature":"interface Props { variant?: Pick< CollectionEntry<'variants'>['data'], 'company_name' | 'logo_url' | 'logo_url_dark' | 'mark_fill' >; headline: string; intro?: string; caseStudyIntros: Record<string, string>; hiddenBlocks?: readonly string[]; from?: string; }","binding":"const { variant, headline, intro, caseStudyIntros, hiddenBlocks = [], from, } = Astro.props;"},"icon":"flow-arrow","tier":"component","why":"The home composition — the sequence of blocks that / and every /for/ page are both made of. It exists because the two views differ in four values and in nothing else: who is addressed, what the headline says, which projects appear, and what is said about them. Holding the sequence in one place is what makes block order a fact rather than a rule two files have to keep agreeing on, and it is why a new outreach page is a YAML file and no code at all.","internal":false,"group":{"name":"Layout","order":3,"description":"Structural components that define page regions and navigation."},"foundations":{"type":[".t-display",".t-body-lg"],"colour":[],"spacing":["--space-page","--nav-cluster-align","--space-inset"]},"composition":{"contains":["Byline","PriorWorkGrid","SelectedWork","Passage","Figure","SpotlightCard","CyclingMap","Divider"],"containedBy":["Home","Variant"],"slots":[]},"constraints":[{"rule":"The sequence of blocks is fixed and takes no prop that could reorder it","reason":"What a variant may change is settled — the headline, the company, and which projects appear with what copy. Order is not on that list, and a per-page order prop would make it possible without anyone deciding to"},{"rule":"Vertical distance comes from the nested flows and from nothing else","reason":"The outer flow separates groups at --space-sections, an inner flow separates the blocks of one group at --space-blocks, and no block carries a margin. That is what lets a variant add or drop a passage without a spacing value being retuned"},{"rule":"The metrics grid is a group of its own, and it precedes the claim","reason":"The evidence is read before the argument, not offered in support of one already made — five clients and five numbers are what earn the headline the right to make a claim, so they come first and stand at a section's distance rather than sitting inside the head as its last block. This reverses the earlier reading, where the grid was a block of the head group following the headline; the August designs put the row above the h1 and separate all three at --space-sections. The order is the DOM's — the row precedes the headline in the document exactly as it does on screen, so the `order` rule that once floated it up is still gone"},{"rule":"Below 800px the grid falls below the claim, by `order` and only there","reason":"On a phone the head is the whole screen and the row would push the headline off it, so the head reads first and the evidence follows. It is the one place on the page where the visual order and the document order differ, and the direction is chosen: the DOM keeps the desktop reading, so a screen reader hears byline, evidence, claim at every width. Everything below the row keeps its document order — equal `order` values sort by source"},{"rule":"The selected-work group is absent rather than empty when a page has no projects","reason":"A variant's passages argue the work against the role it addresses. `/` addresses nobody, so there is no argument to make and no empty state to render — it goes from the evidence to the quote"},{"rule":"A rule closes every group except the head group, and each rule is conditional on the group it closes","reason":"A rule separates subjects, where a subject is a passage together with the container that illustrates it — one group, not two. The head group is the exception: it does not close with a rule, so on `/for/` the first rule falls after the project passages and never between the metrics row and the first passage, and on `/`, where there are no project passages, the head group runs straight into the quote with nothing drawn between them. Read off Alan's July frames, which draw exactly three rules on `/for/` and none in the head. An earlier candidate — draw a rule where a text block meets a text block — was tested against those frames and rejected: it mispredicts two of the three. Do not reintroduce it. This is why the first Divider sits inside the selected-work conditional rather than after it"},{"rule":"The h1's break is never forced by a <br> or a &nbsp;","reason":"The headline changes per variant, so any break has to hold for text nobody has written yet. .t-display's own text-wrap: balance (base.css) does that generally; a component-level override tuned to one sentence's word lengths was tried here and removed once a new headline exposed it as a hack that only worked for the sentence it was measured against"},{"rule":"A hideable block is gated through the block registry, never a literal string","reason":"hidden_sections is validated against HIDEABLE_BLOCKS in src/lib/blocks.ts, so a typo in a variant file fails the build rather than quietly hiding nothing"}],"a11y":{"role":"none — a flow container, its children carry the semantics","keyboard":"None of its own; each passage and each metric cell is a link","notes":"The h1 is the headline. The selected projects are the only h2s, so the outline is the page's argument and not its furniture — the quote is a <cite>, the personal note is a plain label, and the figure captions stay out of the outline."},"agentContext":{"when":"The body of / or of any /for/[slug] page — both views render this and add nothing to it.","notWhen":"A project page, which has its own composition. Any single block of this flow — use that block directly.","prefer":"A new variant YAML file over any change to this component"},"spacing":{"internal":"row-gap only, from .flow (--space-blocks between the blocks of a group) and .flow--sections (--space-sections between groups). No block carries a margin.","external":"padding-block: the distance to the top of the document and to the footer below, which are not siblings and so cannot be reached by a gap. The bottom is var(--space-page). The top has two forms, not three: wherever the cluster is fixed — every width above 800px — it is var(--space-page) plus var(--nav-cluster-align), which lands the byline's first line on the centre line of the cluster's disc stack across the page from it. Below 800 the cluster is in the flow and contributes its own height, so this is only the page's inset from the document edge, var(--space-inset) — one cell, matching the horizontal inset at that width. The alignment token is composed in base.css from what the cluster is made of, so this file states no measured number"}},{"kind":"component","name":"Passage","slug":"passage","href":"/system/content#passage","sourceFile":"src/components/shared/Passage.astro","propsApi":{"signature":"interface Props { mark?: string; markDark?: string; markType?: 'person' | 'org'; markFill?: 'fill' | 'inset'; name: string; role?: string; labelAs?: 'h2' | 'h3' | 'cite' | 'span'; slug?: string; from?: string; href?: string; linkName?: string; paragraphs: string[]; [key: string]: any; }","binding":"const { mark, markDark, markType = 'person', markFill, name, role, labelAs = 'h2', slug, from, href: externalHref, linkName, paragraphs, ...attrs } = Astro.props;"},"icon":"text-align-left","tier":"component","why":"One attributed run of prose: who is speaking in a narrow left column, what they say in a wider right one. The home page is a vertical sequence of these, so the page is content rather than layout — add a passage and the flow re-spaces itself. Case-study quotes are passages too, not a separate card.","internal":false,"group":{"name":"Content","order":2,"description":"People, quotes, and metrics — the substance that fills layouts."},"foundations":{"type":[".t-body-lg",".t-body-mono"],"colour":[],"spacing":["--space-related"]},"composition":{"contains":["Identity"],"containedBy":["PageFlow","SelectedWork","Project","System Section"],"slots":[],"placement":["block","col-label","col-body"]},"constraints":[{"rule":"A sub-line (role) makes the attribution two lines and its mark large; omit it for a single-line label","reason":"The two forms are a consequence of the content, not a variant an author picks. Which one appears is Identity's to decide from the label — Passage passes the role and nothing about size or arrangement"},{"rule":"slug links to the project page, href links out; a passage never carries both","reason":"One passage names one destination. slug produces an internal projectHref and a \"View {name} project\" cursor hint / touch label; href is an external URL (opens in a new tab) and uses linkName for both the cursor hint (\"See {name}'s {linkName}\") and the touch label — the same convention Identity itself uses for a standalone external link"},{"rule":"mark is optional — omit it for an attribution with no photo or logo","reason":"A case-study quote without a matched team member has nothing to show but a name; Identity already renders cleanly with no src, so Passage does not need a placeholder"},{"rule":"Prose in a linked passage must not contain inline links","reason":"A linked passage is itself an <a>; a nested anchor is invalid HTML and the inner link becomes unreachable"},{"rule":"labelAs is h2 for a project entry, cite for a quote, span for a note the author signs","reason":"A project entry titles a section of the page and belongs in the document outline; a citation names the source of words quoted from elsewhere; and a passage the author writes about himself is neither — <cite> around his own name would claim he is quoting someone, and a heading would put a personal aside in the outline beside the case studies"},{"rule":"The attribution passes no size of its own — Identity reads it off the sub-line","reason":"It used to ask for Identity's compact scale, on the grounds that a label annotating prose should not compete with it for first read. That was true of the type, and the type is the same 16px either way now; all compact still did was hold the mark at one cell, which made a single-line label sit beside a 24px chip and a two-line one beside a 36px portrait for no reason a reader could use. Size now follows the label, and the visual weight of the label is carried where it always actually was — by the ink colour and the .col-label column"},{"rule":"Passage does not restyle the attribution, at any width","reason":"It used to, above 1320px: the name was given the prose's 1.5-cell line box so it sat optically level with the opening prose line, and the stacked attribution was turned back into a row. Both compensated for an arrangement Identity no longer has — the mark is beside the text at every width and large beside a sub-line. The label and the prose are now top-aligned as blocks rather than on their first lines, which is a real change: the label's first line sits a few pixels above the prose's. Chasing it again would mean pulling the text block out of true with its own mark, which is the alignment that belongs to the attribution"},{"rule":"One arrangement: the attribution is in the label column, at every width and for every kind of passage","reason":"A case-study quote briefly took a second arrangement — attribution under the prose in the body column — because a long role clipped in the ten-cell label column: \"Senior Brand Manager, GSK\" needs 252px of the 168px there. But the clipping was Identity refusing to wrap, not the column being too narrow, and once the label wraps the same attribution fits in place at 69px. A prop that moved the block to a different column was a layout answer to a text-overflow question, and it made one passage on the site read differently from every other"},{"rule":"paragraphs is prose, not a schema — any number, no required shape","reason":"The moment the component expects an opener and an outcome, every passage has to be written to fit it"},{"rule":"The prose is .t-body-lg, the same size as the page intro","reason":"They are one editorial role — the page talking to the reader — and they were set at two sizes only because they sit in different blocks. Two sizes for one voice is a distinction the reader has to account for and cannot use"},{"rule":"A mark drawn in a single ink must supply markDark alongside mark","reason":"Ground is time-derived, so a wordmark that reads at noon disappears at night. The work collection already carries the pair as logo / logo_dark for exactly those clients, and a passage that only forwards the day one loses its attribution after dark"}],"a11y":{"role":"link (whole block) | div","keyboard":"Enter follows the link when slug or href is set; focus takes the global accent outline, which is input-independent","notes":"Pointer devices get the cursor hint and an underline on the label; touch devices get a static touch label instead (\"View project\" for slug, linkName for href), since there is no cursor to carry the hint."},"agentContext":{"when":"Any attributed block of prose in the home flow — a project entry, a pulled quote, a personal note, a case-study testimonial.","notWhen":"A project reduced to a single metric — use SummaryCard.","prefer":"slug for a project entry, href+linkName for a quote attributed to someone with a public profile, neither for prose that names itself without pointing anywhere"},"spacing":{"internal":"gap: var(--space-related) between prose paragraphs, and between the attribution and its touch affordance label. The attribution's own mark-to-text distance is Identity's and is not restated here","external":"gap only — .block supplies the label/prose row-gap when the columns stack, and the parent .flow owns the distance to the next passage"},"usage":[{"kind":"mutuallyExclusive","props":["slug","href"]},{"kind":"requires","when":{"prop":"href"},"props":["linkName"]}]},{"kind":"component","name":"Prior Work Grid","slug":"prior-work-grid","href":"/system/layout#prior-work-grid","sourceFile":"src/components/shared/PriorWorkGrid.astro","propsApi":{"signature":"interface Props { from?: string; }","binding":"const { from } = Astro.props;"},"icon":"squares-four","tier":"component","why":"The five prior engagements as a row of metric cells, at the head of the home flow before anything is said in words — the evidence the headline below it then argues from. It used to draw a frame from the union of its cells' edges; it draws nothing now. Five client marks on a line already read as a run, and dropping the frame is what lets each mark sit on the page's own left column line with the byline and the headline instead of a cell-inset in from it.","internal":false,"group":{"name":"Layout","order":3,"description":"Structural components that define page regions and navigation."},"foundations":{"type":[],"colour":[],"spacing":["--space-blocks"]},"composition":{"contains":["SummaryCard"],"containedBy":["PageFlow","System Section"]},"constraints":[{"rule":"Five or three cells across, then the list — never four, and never two","reason":"Four leaves the fifth card alone beside four empty tracks, which reads as a cell that failed to load rather than as a run that ended; three divides five into rows that end deliberately (3+2). Two was the third count until the list replaced it: at a phone's width a card beside another card gives each of them half a label, and a fourth arrangement between two that already work was not worth a breakpoint of its own"},{"rule":"Equal cells from the block's own grid-template-columns","reason":"The sanctioned internal-division exception (CLAUDE.md → Grid placement, DESIGN.md → Columns). The block takes its outer span from a .col-* utility and divides itself; the divisions deliberately do not land on page column lines, because the count is a property of the content and the page grid has no opinion about five"},{"rule":"The count changes where a cell stops holding its label; the list arrives where the page's own columns stack","reason":"How many columns the page has and how many cards fit across it are different questions, so 1128px is measured off the longest label and not off a breakpoint chosen for the reading measure. The drop to the list is the other kind of change — the card stops being a stack and becomes a row, which is a change of arrangement rather than of count — and it belongs at 800px, where the page's columns stack and the whole reading composition changes with it"},{"rule":"It spans .col-page, not .col-reading","reason":"The row is scanned, not read — five parallel facts taken in at a glance — so no reading measure applies to it and it is not bound by the column that holds the prose. It shares the page's left edge with everything else and simply reaches further right"},{"rule":"Nothing is drawn — no frame, no cell edges","reason":"The frame was the union of the cells' edges, drawn that way so a trailing empty position stayed unframed; that problem left with the drawing. What replaces it is the run itself — each cell carries its client's mark, and five marks on a line read as a run without a stroke around them. The reason for removing it is the mark's left edge: with a frame the card needs a cell inset, and the inset is what held the marks 24px in from the page's left column line the byline and the headline sit on"},{"rule":"The row distance is the grid's, not the card's","reason":"Once the frame is gone the wrapped rows have nothing separating them, and that separation is the relationship between one row of cells and the next — a container's to own, at the same role the flow spells between one block and the next. Row gap only: the columns still meet, because the cards are read as a row rather than as five separate tiles"},{"rule":"Every entry renders at every width","reason":"The count is content, not layout. Hiding the fifth cell below a breakpoint drops a client from the page on a phone, and makes the grid's shape depend on how many cells it has"},{"rule":"No label","reason":"Each cell carries its client's mark, so the cells name themselves. A caption above them would be a heading for a list of headings"}],"a11y":{"role":"list (ul) of links","keyboard":"Enter follows the focused cell; each cell is a SummaryCard link into its project page","notes":"Renders as a <ul> so the count is announced — \"list, 5 items\" is the fact the grid is making. The empty sixth position is the absence of an item rather than an empty one, so nothing is announced for it."},"agentContext":{"when":"The run of prior engagements, as metric cells, anywhere in the home flow.","notWhen":"A single case study — use SpotlightCard. A project told in prose — use Passage. A framed block whose payload is one thing rather than a run of cells — use Figure.","prefer":"Figure for any other payload that wants a frame around it"},"spacing":{"internal":"row-gap: var(--space-blocks) between wrapped rows, and no column gap — the cells meet, and each card's trailing inset is SummaryCard's own","external":"gap only — the parent .flow owns the distance to the blocks either side, and the grid carries no margin"}},{"kind":"view","name":"Project","slug":"project","href":"/system/views#project","sourceFile":"src/pages/project/[slug].astro","propsApi":{"signature":null,"binding":"const { entry, next } = Astro.props;"},"icon":"newspaper","tier":"view","why":"A case study, told in the same grammar as the home page: a flow of blocks, labelled in the label column, separated by gap roles, with a rule only where a group ends. It has no chrome of its own — the return is a trail in the flow, not a toolbar — and it ends on one considered destination rather than a row of equal chips. It also owns the Lightbox: a full-screen media viewer that opens from any Media on the page, showing an image or video at maximum size with its caption. The Lightbox is not a separate component; it is a native <dialog> opened as a modal, with inline markup, script, and styles on this page, triggered by the `lightbox` prop on Media.","internal":false,"foundations":{"type":[".t-display",".t-body-lg",".t-body",".t-body-mono"],"colour":["--ink","--ink-secondary","--lightbox-backdrop","--lightbox-ink","--lightbox-ink-bright","--lightbox-chrome","--lightbox-chrome-hover","--lightbox-hairline"],"spacing":["--space-within","--space-related","--space-inset","--space-blocks","--space-page","--nav-cluster-align-line","--cell","--measure-body"]},"composition":{"contains":["BaseLayout","Icon","Identity","Passage","Divider","ImpactMetrics","TeamPanel","Media","SummaryCard"],"placement":["flow","flow--sections","block","col-flush","col-label","col-body","col-reading","col-scan"]},"constraints":[{"rule":"One static page per work YAML entry","reason":"getStaticPaths() generates a route for every file in src/data/work"},{"rule":"The return is a trail in the flow, and the page carries no other chrome","reason":"The sticky toolbar it replaces was the only piece of chrome on the site: full-bleed, opaque, carrying a hairline nothing else earns, and pinned even though the nav cluster beside it scrolls away. Its IntersectionObserver project name existed only because a bar that height had to justify itself. The trail is a line of type on the byline's column, sitting on the cluster's centre line across the page from it, and it scrolls with everything else"},{"rule":"A rule closes every group, and there are four","reason":"PageFlow's rule, applied here: after the narrative, after impact-and-quote, after Credits, and after the next project — which is also what holds the footer off the reading. The page used to draw one after every section, seven in all, which makes all the boundaries equal and then none of them means anything. The narrative sections are separated from each other by --space-sections and their labels alone"},{"rule":"Impact and the quote are one group; the narrative sections precede them","reason":"The numbers and what was said about them are one subject, so they sit in a .flow at --space-blocks with no rule between. An earlier attempt moved impact into the head, echoing home's evidence-before-claim order; that is wrong on a case study, where the numbers are what the quote speaks to rather than what the headline argues from"},{"rule":"The page ends on one SummaryCard, not the prior-work row","reason":"Home's grid is the head of a page that has not said anything yet, where five parallel facts are the argument. At the end of a case study the argument is made, and a row of equal chips asks the reader to start over. The card is the next entry in the prior-work order, wrapping — the same order the home row states, so \"next\" means the same thing in both places"},{"rule":"The credit is team[0], and the role beside his name is that engagement's, not his current one","reason":"The work collection carries Alan as the first team member with the role he held for that project — \"Lead Product Designer\" on GSK, \"UX/UI Designer & Front-end Developer\" on Google. It is per-project and it changes between them, so neither the byline's standing title nor a new field is the answer: the fact was already recorded, in the one place that knows it. role_summary is the paragraph under it"},{"rule":"The credit names the role and not the employer, unlike every other member of the team","reason":"The organisations row two steps below already states it, and captions it as exactly the thing the comma-clause would say — \"Interbrand · My employer\". Setting it beside his name as well is the same fact twice in one section. It also does not fit: \"UX/UI Designer & Front-end Developer, Cheerful Twentyfirst\" runs 585px against the 504px the body measure leaves beside a large mark, so on Google and WSJ the line wrapped to a second line for no reason a reader could use. Inside TeamPanel the pair stays, because there is no organisations row under a list entry to carry it"},{"rule":"Sections and people are both labelled in the label column, at .t-body-mono","reason":"The section label used to be .t-body-lg-mono — a 24px mono label against 16px serif prose, so it out-measured the thing it annotates, where home pairs a 16px mono label with 24px serif prose. The quote attribution briefly moved out of the column entirely, because a long role clipped there; it moved back once Identity started wrapping, which is what the clipping was actually about. One column, one type role, every label on the page"},{"rule":"Lightbox opens only from elements with data-lightbox-trigger","reason":"Set by Media.astro when its lightbox prop is true (the default for surface/fill variants)"},{"rule":"Lightbox closes via Escape, backdrop click, or the close button","reason":"Three redundant dismissal paths so no user gets stuck in the overlay"},{"rule":"Lightbox is a modal <dialog>, opened with showModal()","reason":"The platform supplies the modal contract: role and aria-modal, top-layer paint above all page chrome, an inert page behind it so focus is contained, focus to the close button on open, and back to the trigger on close"},{"rule":"Lightbox videos autoplay muted and loop only when the source Media used playback=\"loop\"","reason":"Matches the inline playback behaviour so the lightbox doesn't surprise the viewer with sound or a frozen frame"}],"a11y":{"role":"main","keyboard":"Lightbox: triggers are buttons (Enter/Space opens); while open the page behind is inert so focus stays in the dialog — the close button takes it on open and the trigger takes it back on close; Escape (native cancel), close button, and backdrop all dismiss"},"agentContext":{"when":"Any case study route (/project/[slug]) and its full-screen media viewing behaviour.","notWhen":"Home, Variant, or System — those never render project content or the Lightbox."}},{"kind":"component","name":"Selected Work","slug":"selected-work","href":"/system/content#selected-work","sourceFile":"src/components/shared/SelectedWork.astro","propsApi":{"signature":"interface Props { caseStudyIntros: Record<string, string>; from?: string; }","binding":"const { caseStudyIntros, from } = Astro.props;"},"icon":"bookmarks-simple","tier":"component","why":"The projects a variant chose to show, as a run of Passages. It is the one place the variant's content model meets the work collection: the variant supplies which projects and what is said about each, the collection supplies the client, the mark and the link. A new /for/ page is then a block of copy in a YAML file and nothing else.","internal":false,"group":{"name":"Content","order":2,"description":"People, quotes, and metrics — the substance that fills layouts."},"foundations":{"type":[],"colour":[],"spacing":[]},"composition":{"contains":["Passage"],"containedBy":["PageFlow","System Section"]},"constraints":[{"rule":"The keys of caseStudyIntros are the set — never a list held anywhere else","reason":"A variant selects its projects by writing copy for them. Two sources for one decision means a variant can name a project and show nothing, or show a project it never wrote about"},{"rule":"The spotlit entry is pinned first, read from the collection's spotlight flag","reason":"Which project leads is a property of the work, not of any one variant, and not of the string \"tenon\". Deriving it means the pin follows the flag if the spotlight ever moves"},{"rule":"A key with no work entry fails the build, naming the key and the valid slugs","reason":"The alternative is skipping it, which renders a plausible page missing a project nobody notices is gone. The variant schema is written by an agent, so the error is the feedback loop — it has to say what to type instead"},{"rule":"Copy is split into paragraphs on blank lines and passed through unshaped","reason":"The copy is freeform per variant. Any structure imposed here — an opener, an outcome — becomes a rule every future variant has to write to"},{"rule":"Renders sibling Passages with no wrapper element","reason":"The passages are blocks of the page's flow, not a group within it. A wrapper would take the row-gap meant for the passages and would sit in the grid with no column of its own"}],"a11y":{"role":"none — the component adds no element of its own","keyboard":"Each passage is a link into its project page; tab order is the render order, spotlit entry first","notes":"Every passage label renders as an h2, so the selected projects form the page's second-level outline in the order they are shown."},"agentContext":{"when":"The variant-chosen run of projects, on home or on any /for/ page.","notWhen":"One project told in prose — use Passage directly. The prior engagements as metric cells — use PriorWorkGrid.","prefer":"Passage directly for a single entry that is not part of the selected set"},"spacing":{"internal":"none — each Passage is a sibling in the parent .flow, which owns the distance between them","external":"none — the component renders no element and so has no box to space"}},{"kind":"component","name":"SkyBand","slug":"sky-band","href":"/system/layout#sky-band","sourceFile":"src/components/shared/SkyBand.astro","propsApi":{"signature":"interface Props { edge?: 'top' | 'bottom'; }","binding":"const { edge = 'top' } = Astro.props;"},"icon":"cloud","tier":"component","internal":false,"why":"A Bayer sky at the two ends of the page. Shadow, body and light follow the cloud contour while seasonal solar time generates their palette. Text lines clear to ground, and coverage remains independent of the colour clock.","group":{"name":"Layout","order":3,"description":"Structural components that define page regions and navigation."},"foundations":{"colour":[],"spacing":["--sky-band-height"]},"composition":{"containedBy":["BaseLayout","System Section"]},"variants":[{"prop":"edge","options":["top","bottom"],"default":"top"}],"constraints":[{"rule":"The field itself is the Dithered Field pattern — grid, pitch, shape, edges, legibility and motion are stated there","reason":"Nav's veil is the same construction doing a different job, and the technique was written out in full on both. Two copies of a rule are two things to keep in agreement, and the one that matters here is that a change to the pitch or to how the field clears text has to land in one place. What stays below is what is true of this surface and not of the other"},{"rule":"Cells use the atmospheric shadow, body and light roles, never accent","reason":"Interactive colour and atmospheric colour have different jobs. A single rank distribution assigns colour without increasing coverage, and rendered text lines clear completely to ground."},{"rule":"Cloud cover is weather, not the clock","reason":"intensity(t) was the first candidate and is the wrong signal — it is a cosine with zeroes at the centre of each mode, so cover would collapse to exactly nothing at midday and midnight and no multiplier could lift it. Density and chroma are different phenomena and take different drivers"},{"rule":"The footer band is this component flipped, never a second effect","reason":"Both bands keep a hard edge where the document itself ends — the top of the page and the bottom — because that is not an edge the field invented, and both dissolve at the end that runs into the page. One is the other read upside down, so a single u_flip decides which end is dense and everything else about the two is identical by construction rather than by two sets of constants being kept in agreement"}],"a11y":{"role":"presentation — aria-hidden, and absent from the accessibility tree: it carries no information a screen reader could use, and the time it encodes is announced by ClockGroup","keyboard":"None — pointer-events: none, and nothing in it is focusable","notes":"Reduced motion freezes cloud drift and coverage; palette and resize events still redraw the field. Without WebGL2 the page ground remains. The canvas is decorative and never receives pointer input."},"agentContext":{"when":"Mounted once by BaseLayout. Nothing else in production should render it; the /system specimen is the one exception, and it supplies its own positioned box because the canvas is absolute.","notWhen":"As a background for any block that carries text — the field is a page-level backdrop with a legibility floor, not a surface treatment a component can opt into.","prefer":"A --surface panel where a block needs to sit off the ground"},"spacing":{"internal":"None — a single canvas with no children.","external":"None. It is absolutely positioned against the initial containing block and takes no part in the flow, so it holds nothing off its neighbours and nothing spaces itself off it."}},{"kind":"component","name":"Spacing Specimen","slug":"spacing-specimen","href":"/system/internal#spacing-specimen","sourceFile":"src/components/system/SpacingSpecimen.astro","propsApi":{"signature":"interface Props { token: string; alias?: string; }","binding":"const { token, alias } = Astro.props;"},"icon":"arrows-out-line-horizontal","tier":"component","internal":true,"why":"Visualizes a spacing token as a proportional bar with cell-multiple label. Role tokens pass alias — the step they alias — so the readout shows role, step, and live px.","foundations":{"type":[".t-body-mono"],"colour":["--accent","--ink-secondary","--ink"],"spacing":["--space-012","--space-within","--space-blocks"]},"composition":{"containedBy":["Layout","System Section"]},"a11y":{"role":"figure"},"agentContext":{"when":"System page layout section only. Scale steps pass token alone; role tokens also pass alias (the step they alias).","notWhen":"Never used outside /system."},"constraints":[{"rule":"token prop is interpolated unvalidated into inline style","reason":"style={`width: var(--${token}); height: var(--${token})`} injects the raw prop string directly — any non-token value would break or leak into the style attribute"},{"rule":"The cell multiple is measured, never mapped","reason":"It was a hardcoded token → label map, which is a second statement of a value the stylesheet already owns. It drifted: --space-blocks was captioned \"1 cell\" while measuring 48px. The readout now divides the measured px by the measured cell, so the caption cannot disagree with the bar above it"}],"spacing":{"internal":"gap: var(--space-100) outer, var(--space-025) readout","external":"Contained by system spacing specimens grid"}},{"kind":"component","name":"Spotlight Card","slug":"spotlight-card","href":"/system/layout#spotlight-card","sourceFile":"src/components/shared/SpotlightCard.astro","propsApi":{"signature":"interface Props { logoSrc?: string; logo: string; tagline: string; intro: string; slug: string; from?: string; videoSrc?: string; }","binding":"const { logoSrc, logo, tagline, intro, slug, from, videoSrc } = Astro.props;"},"icon":"star","tier":"component","why":"The featured case study. Two-column layout with content and media, hairline borders on all sides. Optional video plays once on viewport entry (via Media, variant=\"card\"). The card you want someone to click.","internal":false,"group":{"name":"Layout","order":3,"description":"Structural components that define page regions and navigation."},"foundations":{"type":[".t-heading-lg",".t-body-lg",".t-body-mono"],"colour":["--ink","--ink-secondary","--border","--brand-tenon"],"spacing":["--space-blocks","--space-inset","--space-subsections","--cell"]},"composition":{"contains":["Media"],"containedBy":["PageFlow","System Section"],"placement":["col-reading"]},"constraints":[{"rule":"One per section","reason":"Multiple spotlights dilute the hero treatment"},{"rule":"The whole card is one anchor","reason":"One destination, one tab stop. The internal spans carry the semantics — the wordmark names the client, the static label is the touch affordance — so a keyboard user meets one link, not a cluster of them"},{"rule":"The media rides along; it is not the link","reason":"The video is ambient evidence — empty alt, plays once on viewport entry via Media variant=\"card\". Giving it its own interaction would put a control inside an anchor"}],"a11y":{"role":"link (card)","keyboard":"Enter follows the card link; the card is a single focus target, and :focus-visible swaps the hairline frame for the 2px accent ring (the outline comment in the styles has the reasoning)","notes":"The masked wordmark is role=\"img\" named by the client via aria-label; the text fallback needs no naming. The video is decorative and carries an empty alt."},"agentContext":{"when":"The hero case study — one per page section, maximum prominence.","notWhen":"Secondary case studies — use SummaryCard.","prefer":"SummaryCard for the prior work grid"},"spacing":{"internal":"gap: var(--space-blocks) content stack; card inset: var(--space-inset), which the absolutely positioned wordmark repeats as its top/left offset so it sits on the same edge as the text below it; media placeholder inset: var(--space-subsections)","external":"gap only, no margin"}},{"kind":"foundation","name":"Stroke","slug":"stroke","href":"/system/foundations#stroke","sourceFile":"src/foundations/Stroke.astro","propsApi":null,"icon":"line-segment","tier":"foundation","order":3,"internal":false,"why":"Every visual boundary is a 1px hairline. No decorative strokes, no rounded corners, no variable weights. How it is drawn depends on what it bounds: a stroke that encloses content is an outline, so it takes no layout space and the content inside stays on the cell; a stroke that marks one edge is a border; and adjacent items sharing an edge use the collapsed single-source shadow so the hairline never doubles.","searchTerms":"stroke border hairline divider outline focus ring shadow collapsed edge line separator","foundations":{"colour":["--border","--accent","--ink-secondary","--ground"]},"composition":{"containedBy":["System Section"]},"constraints":[{"rule":"Structural hairlines are 1px. Focus rings are 2px.","reason":"Variable stroke weight competes with the typographic hierarchy."},{"rule":"All corners are sharp — border-radius is a banned property, enforced by stylelint.","reason":"Rounded corners introduce softness that conflicts with grid-based construction. The exceptions are true circles rather than rounded rectangles — the nav's contact and /DS discs, and the status dot on /system — and they are named file by file in .stylelintrc.json, so a rounded corner cannot arrive anywhere else without the exemption list changing."},{"rule":"Collapsed borders use single-source box-shadow, never overlapping borders.","reason":"Overlapping borders at fractional pixel positions cause visible doubling."},{"rule":"A stroke that encloses content is an outline; a stroke that marks one edge is a border.","reason":"The offset is not optional — outline defaults to 0, which paints the stroke OUTSIDE the box, so a frame written without it bleeds a pixel past its own column line and a one-cell swatch renders 26px. Both halves are enforced (validate-css-patterns.mjs checks 9 and 10). A border occupies layout. With the global border-box reset an element's outer size is unaffected, but everything inside it starts a pixel in — so a framed block put its own contents off the 24px cell, and the only remedies a border allows are per-component arithmetic or a token whose whole job is to subtract a pixel. An outline occupies nothing, so the inset stays a spacing role and the content lands on the grid untouched. Outline over box-shadow for the same job because outlines print and survive forced-colors, where shadows are dropped and the boundary disappears. Enforced by validate-css-patterns.mjs check 9"},{"rule":"A focused frame shows the focus ring in place of its hairline, not alongside it.","reason":"An element gets one outline. SpotlightCard is the one framed element that is also a link, so while focused the 2px accent ring at +2px replaces the 1px hairline and the frame returns when focus leaves — which is the right outcome, since a focused card should read as focused. If a case ever needs both at once, the fix is one rule: a transparent outline plus a box-shadow ring, the standard idiom precisely because the transparent outline keeps forced-colors working"}],"a11y":{"role":"region","wcag":"Focus rings use 2px solid --accent with 2px offset (WCAG 2.4.7)."},"agentContext":{"when":"Drawing any visual boundary — dividers, card edges, grid hairlines, focus states. Start here before choosing border vs box-shadow vs outline.","notWhen":"Background fills and typographic underlines are not strokes."}},{"kind":"component","name":"Summary Card","slug":"summary-card","href":"/system/layout#summary-card","sourceFile":"src/components/shared/SummaryCard.astro","propsApi":{"signature":"interface Props { variant: 'metric' | 'index'; href: string; value: string; label: string; count?: number; icon?: IconName; logo?: string; logoSrc?: string; logoSrcDark?: string; logoFill?: 'fill' | 'inset'; arrangement?: 'stack' | 'list'; prefetch?: 'tap' | 'hover' | 'viewport' | 'load'; }","binding":"const { variant, href, value, label, count, icon, logo, logoSrc, logoSrcDark, logoFill = 'inset', arrangement = 'stack', prefetch, } = Astro.props;"},"icon":"identification-card","tier":"component","why":"A thing summarised to one value and one line, linking to the thing itself. A mark, a value in Pentameter, and a line of Sectra explaining it — that shape held for a prior engagement reduced to its metric and for a design-system section reduced to its name, in two components that had independently arrived at the same three parts and disagreed about all of the details. The metric card set its summary in Pentameter and had no inset; the index card set prose in Pentameter, was inset 12px against 48px, and drew a full box border the stroke foundation forbids. Unifying them is what makes the disagreements visible as disagreements rather than as two components each looking reasonable alone.","internal":false,"group":{"name":"Layout","order":3,"description":"Structural components that define page regions and navigation."},"foundations":{"type":[".t-heading-lg-mono",".t-body",".t-body-mono"],"colour":["--ink","--ink-secondary"],"spacing":["--space-inset","--space-related","--cell"]},"composition":{"contains":["Mark","Icon"],"containedBy":["PriorWorkGrid","Project","System Section","SystemNextCard"]},"variants":[{"prop":"variant","options":["metric","index"]}],"constraints":[{"rule":"Two variants, and they differ in exactly two things: what the mark is, and which mono role the value takes","reason":"Everything else — the order of the three parts, the two groups at two distances, the trailing inset, that nothing is drawn, the list arrangement — is the same in both, which is the whole reason they are one component. A third difference is the signal that they are drifting back into two: it would mean the shared grammar has an exception, and an exception is how the two cards this replaced got where they were"},{"rule":"The value is one number or one name — never a phrase, and never a sentence","reason":"The layout is a single line at a data scale with an explanation under it. A value that wraps has already become the label, and then the card has two labels and no value. This is the rule the retired overview card broke in the other direction: it set its explanation in Pentameter, which made a sentence look like a value"},{"rule":"Mark, then value, then label — top to bottom in the stack","reason":"They read in the order they matter: what it is, what it says, what that means. The mark shared a line with the value while the grid was three cards across and could afford the width; at five across, a chip beside the number spends the card on the chip"},{"rule":"Two groups at two distances: the mark at --space-inset, the value and its label at --space-related","reason":"Three items at one gap read as three unrelated things. The value and the line explaining it bind to each other; the mark is what the pair belongs to, so it stands one step further off. This is the card stating its own grouping rather than leaving it to be inferred from equal distances — and it is why the mark is no longer sized against the value"},{"rule":"The mark is sized against what it belongs to: two cells for a logo, one for an icon","reason":"The metric variant takes Mark at the large size to match the byline, because the metrics row and the byline are the two blocks at the head of the page and a mark a half-cell shorter in one of them reads as a different kind of thing. The index variant has no logo to be — its mark carries an idea rather than an identity, and it sits above a single .t-body-mono line rather than a data-scale number, so a two-cell square would out-measure the thing it names. That measurement rule was released for the metric variant only because the grouping above put a step between mark and value; on a mono line there is no such step to spend"},{"rule":"The card is inset on its trailing edge only","reason":"A left inset would hold the mark a cell in from the page's left column line, which is the line the byline and the headline start on — and being flush with them is the whole reason PriorWorkGrid dropped its frame. The trailing inset stays, because it is what keeps a label off the mark of the card beside it. In the list form there is no card beside it, so it goes too"},{"rule":"Nothing is drawn — no frame, no cell edges, no surface fill","reason":"PriorWorkGrid's reasoning, taken whole: the marks are what make a run read as a run, and a frame costs the cell inset that holds the mark off the page's own column line. This replaces the rule that said the card drew its own right edge. That edge was real — box-shadow: 1px 0 0 var(--border) with a :last-child reset — and it painted in exactly one place in the repo, the Card pattern specimen, because it is the only place the card ever has a sibling. Everywhere it actually shipped, :last-child suppressed it and PriorWorkGrid suppressed it again through :global. A rule guarded twice and visible nowhere is not a rule the component is following"},{"rule":"Hover underlines one part, and which part follows from what the value is","reason":"The metric variant underlines the label and leaves the number alone, because underlining a number reads as a correction to it. The index variant underlines the value, because that is the section name — the thing being navigated to — and it is not a number; underlining its label instead would put the line under the description of the destination rather than under the destination. The .card fill stays off in both: a surface fill needs the cell inset back to have anything to fill, and the row would have to hang a cell left to keep the marks flush"},{"rule":"Below 800px the card is a row, not a stack","reason":"One column at a phone's width (PriorWorkGrid), and a full-width card stacked three deep spends the whole screen on five entries. The mark moves beside the pair and the gap drops to --space-related, because side by side they are one line and the inset distance that reads as a step when it is vertical reads as a hole when it is horizontal"},{"rule":"arrangement=\"list\" is the same form, asked for rather than reached by width","reason":"The row arrangement was only ever available below 800px, because the only caller that wanted it was PriorWorkGrid at a phone's width. A single card ending a case study wants it at every width — one card in a row is one card in a row, whatever the viewport — and the alternative was the project page restating the arrangement from outside through :global, which is the component losing ownership of what it looks like. The prop names the arrangement; the breakpoint stays, because at a phone's width it is not a choice a caller gets to make"},{"rule":"The count belongs inside the value, not beside it","reason":"It is part of naming the section — \"Foundations (6)\" is one line, and a count on its own row would be a fourth part in a card that has three. It takes the value's type class by inheritance rather than declaring one, and it is inline-block so the hover underline stops at the name: text-decoration propagates to in-flow descendants and cannot be switched off by one, and an atomic inline-level box is the only thing it does not cross"}],"a11y":{"role":"link (card)","keyboard":"Enter follows link"},"agentContext":{"when":"A thing reduced to one value and one line that links to the thing itself: the run of prior engagements at the head of the home flow (variant=\"metric\" via PriorWorkGrid), the single onward card closing a case study (metric, arrangement=\"list\"), and the section and entry grids on /system (variant=\"index\").","notWhen":"A project told in prose with an attribution — that is Passage. A project reduced to a framed payload — that is Figure. A value that needs a sentence rather than a line, which is the card asking to be a Passage.","prefer":"arrangement=\"list\" whenever the card stands alone rather than in a row of cells"},"spacing":{"internal":"gap: var(--space-inset) between the mark and the pair below it, and var(--space-related) inside that pair, between the value and the line explaining it. Below 800px the card is a row and the outer gap drops to var(--space-related). The label is held to calc(var(--cell) * 8), capped at the track by min(…, 100%) — the second arm is what stops a three-column grid overflowing, not a belt-and-braces guard. In the list form the value takes the mark's height and centres in it, which is two cells for a logo and one for an icon. The mark's own height and tile inset are Mark's — the card states which size it wants and why, and owns neither number","external":"padding-right: var(--space-inset) — the card's own trailing inset, which keeps its label off the mark of the card beside it. None in the list form, where there is no card beside it. No other side is inset: the mark sits flush on the page's left column line"},"usage":[{"kind":"requires","when":{"prop":"logoSrcDark"},"props":["logoSrc"]},{"kind":"requires","when":{"prop":"logoFill"},"props":["logoSrc"]},{"kind":"forbids","when":{"prop":"variant","equals":"index"},"props":["logo","logoSrc","logoSrcDark","logoFill"]},{"kind":"forbids","when":{"prop":"variant","equals":"metric"},"props":["icon","count"]}]},{"kind":"view","name":"System","slug":"system","href":"/system/views#system","sourceFile":"src/pages/system/index.astro","propsApi":null,"icon":"tree-structure","tier":"view","why":"The design behind a portfolio made to form connections. Intro explains the Gulbenkian aesthetic and typography; a reference index immediately after the welcome lets readers choose their route.","internal":false,"foundations":{"type":[".t-body-mono",".t-heading"],"spacing":[]},"composition":{"contains":["SystemLayout","Intro"]},"constraints":[{"rule":"The reference index follows the welcome and precedes the design rationale","reason":"Readers can jump directly into reference content without scrolling through the why."},{"rule":"Reference sections are discovered, never hand-listed","reason":"getNavSections() derives the same destinations as the navigation."},{"rule":"Internal remains discoverable as implementation reference","reason":"The tooling is useful depth, but it does not carry the primary portfolio story."}],"a11y":{"role":"main"},"agentContext":{"when":"Changing the /system overview — the intro block or the reference index.","notWhen":"Changing how entries render — that is System Section ([section].astro). Adding metadata for a single component — that belongs in the component's own export const system."}},{"kind":"component","name":"System Next Card","slug":"system-next-card","href":"/system/internal#system-next-card","sourceFile":"src/components/system/SystemNextCard.astro","propsApi":{"signature":"interface Props { name: string; href: string; summary: string; }","binding":"const { name, href, summary } = Astro.props;"},"icon":"skip-forward","tier":"component","internal":true,"why":"Continuity card ending each system reference route: \"Next\", then the section it hands off to, in reading order and wrapping back to the overview. It is a SummaryCard in the index variant with a label above it — the same card the section grids are built from, rather than a second copy of that card's type, spacing and hover rules, which is what it carried while the retired OverviewCard existed to be copied. It carries Astro's viewport prefetch so the next route is loaded before it is clicked.","foundations":{"type":[".t-body-mono"],"colour":["--ink-secondary"],"spacing":["--space-heading-after"]},"composition":{"contains":["SummaryCard"],"containedBy":["System Section","Schema"],"slots":[]},"constraints":[{"rule":"Each reference route ends with exactly one, in reading order","reason":"The per-route split must not fragment the browsing experience — each reference page hands off to the next section, and the chain wraps back to the overview, whose compact index lets the reader choose a destination"},{"rule":"The chain is every section, internal included; only genuinely off-chain routes (schema) jump back to the overview","reason":"This replaces a rule that excluded internal, whose reason was that internal is hidden behind an internals toggle everywhere else so the main reading flow must not advertise it. That stopped being true when internal was ungated across /system — it is a normal section in both navs and on the overview, and a section that is visible everywhere but unreachable by the one control whose whole job is \"what comes next\" is a gap with no reason behind it. The toggle itself was removed 2026-08-14"},{"rule":"The mark is an arrow, where every other index card carries an idea","reason":"The card names a direction rather than a subject — there is no glyph for \"the next one\". It replaces a literal \"→\" appended to the section name, which was this card drawing an arrow in text in the position the unified card gives to a mark, and which made the name read as though the arrow were part of it"}],"a11y":{"role":"complementary","keyboard":"Enter follows the SummaryCard link inside it — the label itself is not interactive, so the aside contributes exactly one tab stop"},"agentContext":{"when":"Rendered by the system pages via nextSystemSection() — never place by hand with an ad-hoc target.","notWhen":"Not on the overview, which has a compact reference index, or outside /system.","prefer":"SummaryCard in the index variant for table-of-contents grids — this component is that card plus the \"Next\" label and nothing else"},"spacing":{"internal":"gap: var(--space-heading-after) between the label and the card — the label is a heading for the card under it, which is the one sanctioned asymmetry","external":"A top-level block of .system-content — the section rhythm and hairline come from the layout"}},{"kind":"view","name":"System Section","slug":"system-section","href":"/system/views#system-section","sourceFile":"src/pages/system/[section].astro","propsApi":{"signature":null,"binding":"const { section } = Astro.props;"},"icon":"browser","tier":"view","why":"One route per design-system section — /system/foundations, the component groups derived from groupEntries(), patterns, views, and internal. Hosts every entry's full documentation: live specimens colocated with EntryCard rendering, metadata, and JSON-LD. The overview at /system links in; the section set is derived, never hand-listed.","internal":false,"foundations":{"type":[".t-body-mono",".t-heading-lg",".t-body"],"spacing":["--space-related","--space-blocks","--cell"]},"composition":{"contains":["SystemLayout","EntryCard","SystemNextCard","Button","Byline","ClockGroup","ColourClock","CyclingMap","Divider","Figure","Footer","Icon","Identity","ImpactMetrics","Label","Mark","Media","Nav","Passage","PriorWorkGrid","SelectedWork","SkyBand","SpotlightCard","TeamPanel","SummaryCard","BehaviourBlock","EasingDemo","FoundationsBlock","GridDemo","IconSpecimen","LiveDemo","SpacingSpecimen","TokenReadout","TypeSpecimen"]},"constraints":[{"rule":"The route set derives from getSystemSections(), never a hand-written list","reason":"Component groups come from groupEntries() over discovered metadata — adding a group to a component is the only step needed to mint its route"},{"rule":"Entry mounts stay colocated with EntryCard rendering in this file","reason":"One place to look for how any specimen is mounted; unmatched slugs render the placeholder"},{"rule":"Foundations share one route","reason":"Decided 2026-07-23: they read as one continuum (colour, type, spacing, grid); split one out only if it outgrows the page"}],"a11y":{"role":"main"},"agentContext":{"when":"Changing how a section route renders — entry order, group headers, specimen mounts, or the per-section overview grid.","notWhen":"Adding metadata for a single component — that belongs in the component's own export const system. Changing the overview — that is /system (index.astro)."}},{"kind":"component","name":"System Sidebar Nav","slug":"system-sidebar-nav","href":"/system/internal#system-sidebar-nav","sourceFile":"src/components/system/SystemSidebarNav.astro","propsApi":null,"icon":"sidebar-simple","tier":"component","internal":true,"why":"Left sidebar navigation for the /system routes on wide viewports. One item per section route, auto-derived from getNavSections(); the current route's entry list is expanded, entries elsewhere navigate cross-route.","foundations":{"type":[".t-body-mono"],"colour":["--ink","--ink-secondary","--accent","--border","--surface","--ground"],"spacing":["--space-within","--space-related","--space-blocks","--space-subsections","--cell"]},"composition":{"contains":["Icon"],"containedBy":["SystemLayout"]},"constraints":[{"rule":"Desktop only","reason":"Replaced by SystemTopNavBar on narrow viewports."}],"a11y":{"role":"navigation","keyboard":"Standard link navigation; search input is a combobox (role=combobox, aria-expanded, aria-controls, aria-activedescendant) with Arrow/Enter/Escape handling — see src/scripts/system-search.ts"},"agentContext":{"when":"System page sidebar.","notWhen":"Never used outside /system."},"spacing":{"internal":"Nested gaps: --space-within items; item padding --space-within/--space-related; outer padding --space-subsections/--space-blocks","external":"Fixed sidebar width based on --cell"}},{"kind":"component","name":"System Top Nav Bar","slug":"system-top-nav-bar","href":"/system/internal#system-top-nav-bar","sourceFile":"src/components/system/SystemTopNavBar.astro","propsApi":null,"icon":"align-top","tier":"component","internal":true,"why":"Horizontal top navigation bar for the /system routes on narrow viewports. Mirrors the sidebar's section list in a compact horizontal layout; the sub-row lists the current route's entries.","foundations":{"type":[".t-body-mono"],"colour":["--ink","--ink-secondary","--accent","--border","--surface","--ground"],"spacing":["--space-within","--space-related","--space-blocks"]},"composition":{"contains":["Icon"],"containedBy":["SystemLayout"]},"constraints":[{"rule":"Mobile/tablet only","reason":"Replaced by SystemSidebarNav on wide viewports."}],"a11y":{"role":"navigation","keyboard":"Standard link navigation; search input is a combobox (role=combobox, aria-expanded, aria-controls, aria-activedescendant) with Arrow/Enter/Escape handling — see src/scripts/system-search.ts"},"agentContext":{"when":"System page top nav on narrow screens.","notWhen":"Never used outside /system."},"spacing":{"internal":"Flexbox with gap: --space-within, padding: --space-related/--space-blocks","external":"Sticky top bar, full width"}},{"kind":"component","name":"SystemLayout","slug":"system-layout","href":"/system/layout#system-layout","sourceFile":"src/components/layout/SystemLayout.astro","propsApi":{"signature":"interface Props { title?: string; description?: string; }","binding":"const { title = 'Design System — Alan Long', description = 'The live design system behind alanlong.design — typography, colour, layout, and components, all in one place.', } = Astro.props;"},"icon":"sidebar","tier":"component","why":"Root HTML shell for every /system route. Mounts the construction grid, the sidebar nav (desktop) and top nav bar (narrow viewports), and the scrollable system content region, then boots the system-specific client scripts — scroll tracking and search.","internal":false,"group":{"name":"Layout","order":3,"description":"Structural components that define page regions and navigation."},"foundations":{"colour":[],"spacing":[]},"composition":{"contains":["GridOverlay","SystemSidebarNav","SystemTopNavBar"],"containedBy":["System","System Section","Schema"],"slots":["default"]},"constraints":[{"rule":"One per page, only used by /system routes","reason":"Owns a distinct document shell (system-shell/system-content) separate from BaseLayout's nav/footer chrome"},{"rule":"SystemSidebarNav and SystemTopNavBar are both always rendered","reason":"Sidebar is desktop-only and top bar is narrow-viewport-only — CSS media queries decide which is visible, not conditional rendering"}],"a11y":{"role":"document","keyboard":"Delegates to SystemSidebarNav and SystemTopNavBar for navigation; #main-content is the landmark for skip-links"},"agentContext":{"when":"The /system routes only.","notWhen":"Any other page — use BaseLayout for Home, Variant, and Project views.","prefer":"BaseLayout for non-system routes"}},{"kind":"component","name":"Team Panel","slug":"team-panel","href":"/system/content#team-panel","sourceFile":"src/components/shared/TeamPanel.astro","propsApi":{"signature":"interface Props { team: Array<{ headshot: string; name: string; role: string; company: string; linkedin?: string; description?: string; }>; }","binding":"const { team } = Astro.props;"},"icon":"users-three","tier":"component","why":"The people involved in a project, folded away behind one control. It is the last step of the Credits section: Alan's own credit is stated in full, and this is everyone else, disclosed on request rather than listed at the reader by default.","internal":false,"group":{"name":"Content","order":2,"description":"People, quotes, and metrics — the substance that fills layouts."},"foundations":{"type":[".t-body-mono"],"colour":["--accent","--border","--surface"],"spacing":["--space-blocks","--space-within","--space-related"]},"composition":{"contains":["Identity"],"containedBy":["Project","System Section"]},"constraints":[{"rule":"All team members use Identity with summary","reason":"Uniform rendering — every member gets avatar, name, title, and description"},{"rule":"Every member is in the list, Alan included, and the count is the whole team","reason":"team[0] is Alan — the work collection orders it that way — and this component used to render him outside the list as a standing Identity, with the rest folded behind the trigger and the count still naming all of them. That division now belongs to the Credits section, which states his credit itself from the same team[0]. What is left here is what the old site's panel was: everyone who worked on the project, at one treatment, revealed on request. He appears twice in an open section — once as the credit, once in the list of everyone — which is the panel answering a different question rather than a repeat"},{"rule":"The control comes before the list it opens, in the DOM and on screen","reason":"It used to be the other way round — the list was written first and the button sat under it — so activating \"See full team\" inserted six people behind the reader's position. A screen reader heard nothing (aria-controls is honoured by almost nothing, and the label swap is not announced), and a keyboard user's next Tab went past the links that had just appeared rather than into them. Disclosed content belongs after its disclosure"}],"a11y":{"role":"group — a disclosure button and the list it controls; no landmark","notes":"It carried role=\"complementary\" and does not any more. That role is for top-level supporting content, and this is a collapsed disclosure nested inside a section of a case study — it put an unlabelled \"complementary\" region into the landmark list of every project page with a team, pointing at nothing the reader could use.","keyboard":"Button toggles visibility"},"agentContext":{"when":"Project pages with team context — shows who worked on it.","notWhen":"Single-person attribution — use Identity directly."},"spacing":{"internal":"gap: var(--space-blocks) between the list and the control that opens it, and the same role between members inside the list; trigger gap: var(--space-within), padding: var(--space-within) (v) / var(--space-related) (h)","external":"gap only, no margin"}},{"kind":"component","name":"Token Readout","slug":"token-readout","href":"/system/internal#token-readout","sourceFile":"src/components/system/TokenReadout.astro","propsApi":{"signature":"interface Props { token: string; label: string; }","binding":"const { token, label } = Astro.props;"},"icon":"code","tier":"component","internal":true,"why":"Displays a colour token's live swatch and computed OKLCH value.","foundations":{"type":[".t-body-mono"],"colour":["--ink","--ink-secondary","--surface","--border"],"spacing":["--space-related","--cell"]},"composition":{"containedBy":["Colour","System Section"]},"a11y":{"role":"figure"},"agentContext":{"when":"System page colour section only.","notWhen":"Never used outside /system."},"constraints":[],"spacing":{"internal":"gap and padding: var(--space-related); swatch sized to --cell; value occupies its own line","external":"Contained by system token readouts grid"}},{"kind":"component","name":"Type Specimen","slug":"type-specimen","href":"/system/internal#type-specimen","sourceFile":"src/components/system/TypeSpecimen.astro","propsApi":{"signature":"interface Props { role: string; sample: string; cssClass: string; }","binding":"const { role, sample, cssClass } = Astro.props;"},"icon":"text-h","tier":"component","internal":true,"why":"Shows a type role's actual rendering alongside computed specs (family, weight, size, line-height).","foundations":{"type":[".t-body-mono"],"colour":["--ink-secondary"],"spacing":["--space-within"]},"composition":{"containedBy":["Typography","System Section"]},"a11y":{"role":"figure"},"agentContext":{"when":"System page typography section only.","notWhen":"Never used outside /system."},"constraints":[],"spacing":{"internal":"gap: var(--space-within)","external":"Contained by system specimens grid"}},{"kind":"foundation","name":"Typography","slug":"typography","href":"/system/foundations#typography","sourceFile":"src/foundations/Typography.astro","propsApi":null,"icon":"text-t","tier":"foundation","order":1,"internal":false,"why":"Two typefaces encode the built x natural tension. Sectra speaks for people, Pentameter speaks for the system. Every type role maps to a CSS class — the specimens here render in the same styles that build the site.","searchTerms":"typography type specimen display heading body system font sectra pentameter","foundations":{"type":[".t-display",".t-heading-lg",".t-heading-lg-mono",".t-heading",".t-body-lg",".t-body-lg-mono",".t-body",".t-body-mono"]},"composition":{"containedBy":["System Section"],"contains":["TypeSpecimen"]},"constraints":[{"rule":"Sentences use Sectra. Interface labels use Pentameter.","reason":"The two typefaces have distinct semantic roles — mixing them breaks the voice."},{"rule":"Bold (700) is reserved for case study content only.","reason":"Weight hierarchy is deliberate — bold in system UI would flatten the typographic contrast."}],"a11y":{"role":"region","wcag":"Text sizing uses rem/px with consistent line-height for readability."},"agentContext":{"when":"Choosing a type style for new content. The role names (display, heading-lg, heading-lg-mono, heading, body-lg, body-lg-mono, body, body-mono) map directly to CSS classes. The -mono suffix is Pentameter; the plain name is GT Sectra.","notWhen":"Do not create new type roles. The eight roles here cover every use case."}},{"kind":"view","name":"Variant","slug":"variant","href":"/system/views#variant","sourceFile":"src/pages/for/[slug].astro","propsApi":{"signature":null,"binding":"const { variant } = Astro.props;"},"icon":"user-focus","tier":"view","why":"Personalised home page for a specific company. The same composition as Home, from the same PageFlow component, with the headline, the addressed company and the selected projects coming from a variant YAML file. A new outreach page is that file and nothing else — no new components, no bespoke layout.","internal":false,"foundations":{"spacing":[]},"composition":{"contains":["BaseLayout","PageFlow"]},"constraints":[{"rule":"One static page per variants YAML entry","reason":"getStaticPaths() generates a route for every file in src/data/variants"},{"rule":"The composition is PageFlow, identical to Home","reason":"What a variant changes is settled — the headline, the company, and which projects appear with what copy. Block order is not on that list, and sharing the component is what makes that structurally true rather than a rule two files have to keep agreeing on"},{"rule":"Per-variant hiding goes through the block registry, never a literal string","reason":"variant.hidden_sections is validated against HIDEABLE_BLOCKS in src/lib/blocks.ts, so a typo fails the build instead of silently hiding nothing — which is what comparing against a hardcoded name here used to do"},{"rule":"The variant slug is threaded through as `from`","reason":"Every project link carries the origin so initVariantOrigin() can send the visitor back to their own page rather than to /"}],"a11y":{"role":"main"},"agentContext":{"when":"A company-specific outreach link — reuses Home's composition with copy swapped in from a variant YAML file.","notWhen":"The default landing page — use Home for that.","prefer":"Home for the un-personalised page"}}],"patterns":[{"kind":"pattern","tier":"pattern","internal":false,"name":"Card","icon":"rectangle","slug":"card","href":"/system/patterns#card","sourceFile":"src/content/system/patterns/card.md","why":"Base interaction affordance shared by SummaryCard and SpotlightCard. Provides the surface hover fill, active-press opacity, and removes default link/button styling. The hover fill is a default, not a guarantee — SummaryCard overrides it back to transparent and carries its hover as an underline instead, for the reasons documented in its own styles.","usedIn":"SummaryCard, SpotlightCard","foundations":[{"label":"Colour","value":"surface (hover)"},{"label":"Motion","value":"--ease, 0.15s transition"}]},{"kind":"pattern","tier":"pattern","internal":false,"name":"Cursor Affordance","icon":"cursor","slug":"cursor-affordance","href":"/system/patterns#cursor-affordance","sourceFile":"src/content/system/patterns/cursor-affordance.md","why":"Pointer-following label on hover, with a static fallback on touch where a component needs one. Provides interaction hints without cluttering the resting state.","usedIn":"Button, ColourClock, CyclingMap, Identity, Passage, SpotlightCard, SummaryCard, PageFlow","foundations":[{"label":"Typography","value":"t-body-mono (static fallback)"},{"label":"Colour","value":"ink (background), ground (text), ink-secondary (static fallback)"},{"label":"Spacing","value":"--space-within, --space-related"},{"label":"Motion","value":"--ease, 0.15s opacity"}]},{"kind":"pattern","tier":"pattern","internal":false,"name":"Dithered Field","icon":"dots-nine","slug":"dithered-field","href":"/system/patterns#dithered-field","sourceFile":"src/content/system/patterns/dithered-field.md","why":"A continuous quantity rendered through a fixed Bayer 8×8 distribution on a 2px grid. SkyBand distributes ground, shadow, body and light through one cumulative rank; Nav's veil distributes ground and transparency. The Colour foundation exposes the daily palette and a sparse boundary in AtmosphereSpecimen.","usedIn":"SkyBand, Nav, AtmosphereSpecimen","foundations":[{"label":"Grid","value":"Bayer 8×8 at a 2px pitch, a whole division of --cell. Square cells, filled whole. The lattice stays fixed while coverage or colour changes."},{"label":"Colour","value":"Every colour comes from the shared solar palette. Atmospheric shadow, body and light are independent of interactive accent. One cell holds one colour; distributing the family never stacks masks or increases coverage. CSS and canvas share an sRGB gamut policy."},{"label":"Edges","value":"Cloud coverage reaches zero before an invented edge, with a wandering limit. Document edges stay hard. The day readout has explicit time bounds."},{"label":"Legibility","value":"SkyBand clears completely around rendered text lines, measured through text-boxes.ts. Noise varies the recovery outside the protected area, never the clearance beneath a glyph. Nav's veil supplies ground beneath its cluster. Reading surfaces and text change polarity together."},{"label":"Motion","value":"Cloud coverage and shape may drift over a fixed lattice. Reduced motion freezes ambient drift; time controls still update colour. The day readout and boundary specimen have no ambient animation."}]},{"kind":"pattern","tier":"pattern","internal":false,"name":"Prose","icon":"paragraph","slug":"prose","href":"/system/patterns#prose","sourceFile":"src/content/system/patterns/prose.md","why":"Renders markdown-to-HTML content with consistent treatment — ink-secondary dash list markers, inline link hovers, paragraph spacing. Applied via the `.prose` class on any `set:html` container.","usedIn":"Project page sections","foundations":[{"label":"Typography","value":"t-body (applied by the owning project section)"},{"label":"Colour","value":"--ink, --ink-secondary, --accent, --surface"},{"label":"Spacing","value":"--space-related, --space-inset, --space-heading-before, --space-heading-after; --space-050 marker width"}]},{"kind":"pattern","tier":"pattern","internal":false,"name":"Scroll Reveal","icon":"eye","slug":"scroll-reveal","href":"/system/patterns#scroll-reveal","sourceFile":"src/content/system/patterns/scroll-reveal.md","why":"Scroll-triggered entrance animation. Elements start invisible and translated down by half a cell, then fade and slide in over 0.45s when they enter the viewport. The stagger advances children in 0.05s increments. Respects prefers-reduced-motion. On the home flow the reveal is per block — each passage, figure, divider and byline carries it — rather than per group or per paragraph.","usedIn":"Byline, Passage, Figure, PriorWorkGrid, Divider, SpotlightCard, SummaryCard, Project page","foundations":[{"label":"Motion","value":"--ease, 0.45s opacity + half-cell transform, 0.05s stagger increment"},{"label":"Accessibility","value":"prefers-reduced-motion disables all animation"}]}],"tokens":{"format":"DTCG","sourceFile":"tokens/portfolio.tokens.json","entries":[{"path":"color.ground","type":"color","value":"oklch(0.964 0.008 225)","description":"Page background — never pure white/black, always material"},{"path":"color.surface","type":"color","value":"oklch(0.929 0.0056 225)","description":"Layer in front of ground — cards, containers, hover reveal"},{"path":"color.ink","type":"color","value":"oklch(0.20 0.008 245)","description":"Primary text — independently readable against ground and surface"},{"path":"color.ink-secondary","type":"color","value":"oklch(0.43 0.012 245)","description":"Supporting text, labels, metadata"},{"path":"color.accent","type":"color","value":"oklch(0.45 0.12 225)","description":"Structural marks — grid joints, interactive states"},{"path":"color.border","type":"color","value":"oklch(0.874 0.0048 225)","description":"Structural hairlines"},{"path":"color.atmosphere-shadow","type":"color","value":"oklch(0.55 0.0196 245)","description":"Atmospheric shadow — shared solar palette for Bayer fields, never text or interactive marks"},{"path":"color.atmosphere-body","type":"color","value":"oklch(0.67 0.028 225)","description":"Atmospheric body — shared solar palette for Bayer fields, never text or interactive marks"},{"path":"color.atmosphere-light","type":"color","value":"oklch(0.79 0.0224 85)","description":"Atmospheric light — shared solar palette for Bayer fields, never text or interactive marks"},{"path":"spacing.100","type":"dimension","value":"24px","description":"1 cell — base unit"},{"path":"spacing.150","type":"dimension","value":"36px","description":"1½ cells — heading-before"},{"path":"spacing.200","type":"dimension","value":"48px","description":"2 cells — block and page spacing"},{"path":"spacing.300","type":"dimension","value":"72px","description":"3 cells — subsection spacing"},{"path":"spacing.400","type":"dimension","value":"96px","description":"4 cells — section spacing; top of scale"},{"path":"spacing.012","type":"dimension","value":"3px","description":"⅛ cell — optical hairline"},{"path":"spacing.025","type":"dimension","value":"6px","description":"¼ cell — nudge"},{"path":"spacing.050","type":"dimension","value":"12px","description":"½ cell — tight gaps"},{"path":"spacing.role.within","type":"dimension","value":"{spacing.025}","description":"Between parts of one element"},{"path":"spacing.role.related","type":"dimension","value":"{spacing.050}","description":"Between related siblings in a group"},{"path":"spacing.role.inset","type":"dimension","value":"{spacing.100}","description":"Between a container edge and its contents"},{"path":"spacing.role.blocks","type":"dimension","value":"{spacing.200}","description":"Between blocks in one group"},{"path":"spacing.role.subsections","type":"dimension","value":"{spacing.300}","description":"Between block clusters"},{"path":"spacing.role.sections","type":"dimension","value":"{spacing.400}","description":"Between page sections — total; hairline-split contexts take half per side"},{"path":"spacing.role.page","type":"dimension","value":"{spacing.200}","description":"The page's distance from its own edges"},{"path":"spacing.role.heading-before","type":"dimension","value":"{spacing.150}","description":"Space above a heading — asymmetric attachment"},{"path":"spacing.role.heading-after","type":"dimension","value":"{spacing.050}","description":"Space below a heading — asymmetric attachment"},{"path":"grid.max","type":"dimension","value":"1320px","description":"Page maximum — the effective canvas width; 55 cells at desktop"},{"path":"grid.measure","type":"dimension","value":"720px","description":"Reading measure — 30 cells; prose/text max-width"},{"path":"easing.default","type":"cubicBezier","value":[0.05,0.7,0.1,1],"description":"Long deceleration — settling into position"}],"cssCustomProperties":{"--ground":"oklch(0.964 0.008 225)","--surface":"oklch(0.929 0.0056 225)","--ink":"oklch(0.20 0.008 245)","--ink-secondary":"oklch(0.43 0.012 245)","--accent":"oklch(0.45 0.12 225)","--border":"oklch(0.874 0.0048 225)","--atmosphere-shadow":"oklch(0.55 0.0196 245)","--atmosphere-body":"oklch(0.67 0.028 225)","--atmosphere-light":"oklch(0.79 0.0224 85)","--brand-tenon":"oklch(0.50 0.18 360)","--ground-clear":"oklch(from var(--ground) l c h / 0)","--overlay-ink":"oklch(90% 0 0)","--overlay-ink-bright":"oklch(100% 0 0)","--overlay-scrim":"oklch(0% 0 0 / 0.6)","--overlay-scrim-heavy":"oklch(0% 0 0 / 0.8)","--lightbox-backdrop":"oklch(0% 0 0 / 0.97)","--lightbox-ink":"oklch(75% 0 0)","--lightbox-ink-bright":"oklch(95% 0 0)","--lightbox-chrome":"oklch(0% 0 0)","--lightbox-chrome-hover":"oklch(20% 0 0)","--lightbox-hairline":"oklch(75% 0 0 / 0.3)","--page-max":"calc(var(--cell) * 55)","--measure":"calc(var(--cell) * 30)","--measure-ceiling":"36rem","--measure-body":"calc(20 * var(--cell))","--leading-large":"1.2","--leading-small":"1.5","--ease":"cubic-bezier(0.05, 0.7, 0.1, 1.0)","--cell":"24px","--space-012":"calc(var(--cell) / 8)","--space-025":"calc(var(--cell) / 4)","--space-050":"calc(var(--cell) / 2)","--space-100":"var(--cell)","--space-150":"calc(var(--cell) * 1.5)","--space-200":"calc(var(--cell) * 2)","--space-300":"calc(var(--cell) * 3)","--space-400":"calc(var(--cell) * 4)","--space-within":"var(--space-025)","--space-related":"var(--space-050)","--space-inset":"var(--space-100)","--space-blocks":"var(--space-200)","--space-subsections":"var(--space-300)","--space-sections":"var(--space-400)","--space-page":"var(--space-200)","--disc-size":"max(calc(var(--cell) * 2), 44px)","--clock-size":"calc(var(--cell) * 4)","--nav-cluster-bottom":"calc(var(--space-page) + var(--disc-size) * 2 + var(--space-within))","--nav-cluster-align":"calc((var(--disc-size) * 2 + var(--space-within) - var(--cell) * 2) / 2)","--nav-cluster-align-line":"calc((var(--disc-size) * 2 + var(--space-within) - var(--cell)) / 2)","--sky-band-height":"calc(var(--cell) * 24)","--footer-sky":"calc(var(--cell) * 16)","--space-heading-before":"var(--space-150)","--space-heading-after":"var(--space-050)"}},"authoring":{"typeClasses":["t-body","t-body-lg","t-body-lg-mono","t-body-mono","t-display","t-heading","t-heading-lg","t-heading-lg-mono"],"spacingRoles":["--space-blocks","--space-heading-after","--space-heading-before","--space-inset","--space-page","--space-related","--space-sections","--space-subsections","--space-within"],"columnUtilities":["col-body","col-flush","col-label","col-page","col-reading","col-scan"],"flowUtilities":["block","flow","flow--sections"],"iconNames":["arrow-left","x","envelope","sun","sun-horizon","moon","replay","text-aa","shapes","article","columns","grid-four","browsers","wrench","arrow-right","align-bottom","align-top","arrows-out-line-horizontal","bicycle","bookmarks-simple","bounding-box","browser","chart-bar","chart-line","clock","clock-countdown","cloud","code","crosshair-simple","cursor","cursor-click","dots-nine","eye","film-strip","flow-arrow","frame-corners","grid-nine","hand-waving","house","identification-card","image-square","lightning","line-segment","list","minus","newspaper","note-blank","palette","paragraph","pen-nib","play","question","quotes","rectangle","selection-all","sidebar","sidebar-simple","signature","skip-forward","square-half","squares-four","stack","stack-simple","star","star-four","tag","text-align-left","text-h","text-t","tree-structure","user","user-focus","users-three","magnifying-glass","wave-sine","corners-out","corners-in"],"bannedProperties":["border-radius"],"requiredChecks":["npm run lint","npm run check","npm run build","npm run test:system"]},"doctor":{"checks":[{"status":"pass","code":"SYSTEM_SOURCES_PARSE","message":"53 system entries and 5 patterns parsed successfully."},{"status":"pass","code":"ENTRY_IDENTITIES_UNIQUE","message":"Every system entry has a unique name and public href."},{"status":"pass","code":"DTCG_TOKENS_RESOLVE","message":"Every DTCG colour and spacing token resolves to a CSS custom property."},{"status":"pass","code":"DTCG_ROLE_COVERAGE","message":"Every CSS spacing role is represented in the DTCG source."}]}}