Pattern // surface shell
The shared page shell for a family of public surfaces.
on this page
What this pattern is
One page composition, applied across a family of surfaces so they read as one artifact family with different payloads rather than as separate sites. The shell carries an identity-mark slot, the surface identity, a lede, an optional status badge, an optional payload control slot, the rule that closes the header, and the footer that closes the page.
surface-shell is an ASK-family pattern. It inherits the shared Tier-1
foundations and the Tier-2 design language — role tokens, typography, spacing,
palette discipline, motion, and the interaction grammar — and it hard-codes no
consuming surface's Tier-3 identity. Tier-3-neutral is not ASK-neutral: a
consumer adopting this pattern is adopting the ASK design language, and supplies
only its own Tier 3. design-system-ASK's own pages are its first consumers,
not its required identity.
"Its own" means locally supplied by the consumer's source of intent — not necessarily a distinct mark. ASK may assign the ASK wordmark to an ASK-family project; the shell still ships no mark, makes no assignment, and does not make that project ASK-the-entity.
The shell owns the chrome around the payload. It owns no payload. A consuming surface supplies everything between the rule and the footer, the footer's own destinations, and its own Tier 3 identity.
The header is flush left and the footer is flush right. That pairing is the shell's, not a per-surface preference: the reader's eye travels top-left to bottom-right, so the page opens where reading starts and closes where reading ends. A left-aligned footer restarts the eye in a column the page has already finished with. Do not re-align either one locally.
This is a surface pattern, not Class A and not Class B. Class A templates render diagrams; Class B templates seal project-output artifacts. This one frames a live public page and seals nothing. It is catalogued in its own group for that reason. Do not file it under an existing class, and do not read a new class label into it — one surface pattern does not earn a taxonomy.
Files
| File | What it is |
|---|---|
Filesurface-shell.css |
What it isThe canonical implementation. Composes existing var() roles; defines no token. |
Filesurface-shell.template.html |
What it isThe canonical markup specimen, with the required structure and both optional slots. |
Filesurface-shell.js |
What it isOptional responsive-navigation runtime. Vendored only by surfaces that adopt navigation. |
The rendered specimen is generated to patterns/_preview/surface-shell.html by
tools/gen-pattern-previews.mjs. It is owner preview evidence — do not vendor
it.
What to vendor. The core shell is surface-shell.css plus the template, and
every consumer takes those. Two additions are conditional, and they are separate
questions:
| Also vendor | When |
|---|---|
Also vendorsurface-action.css (repo root) |
WhenThe surface renders footer destinations, or adopts navigation. |
Also vendorsurface-shell.js (this directory) |
WhenThe surface adopts navigation. A declining surface ships no copy. |
Navigation requires surface-action.css on its own, whether or not the surface has
a footer to style. The panel's close control is mandatory and is a compact action —
the runtime builds it for every adopter — while the utility row is optional. A surface
that adopts navigation, has no footer destinations, omits the utility row, and follows
these instructions to the letter would otherwise ship an unstyled mandatory button.
surface-action.css sits beside the vendored shell, referenced by bare filename,
exactly as the template does. It is not a _dsa-tokens/ file: that mirror is
for tokens and fonts, and routing a visual module through it would make a
convenient path into a false architecture. CSS pins are tracked for every
consumer; the JS pin only for adopters.
Required markup
<div class="surface">
<header class="surface-head">
<div class="surface-head-main">
<div class="surface-mark" role="img" aria-label="[org]">…your mark…</div>
<h1 class="surface-title"><span class="org">[org]</span> <span class="sep" aria-hidden="true">//</span> [repo]</h1>
<p class="surface-lede">…</p>
</div>
</header>
<hr class="surface-rule">
<main class="surface-payload">…payload…</main>
<footer class="surface-footer">
<a class="surface-action surface-action--secondary" href="…">…</a>
</footer>
</div>
The .surface-mark wrapper above is the non-interactive form, which is the
right one only where the surface has no home destination and adopts no
navigation. Everywhere else the wrapper itself becomes the link —
<a class="surface-mark" href="…" aria-label="…">, with no role="img". Both
forms are specified under §The identity mark.
The structural title, and its two variants
The title is a locator, not a display heading, and every structural separator is
// — never a single slash. Each separator is a decorative
<span class="sep" aria-hidden="true">, so the glyph stays uniform while segment
color remains free to express hierarchy. Those are separate decisions.
It carries the structural-locator role — mono, plus --fs-body,
--fw-light and --tracking-tight. Those three are shared with the
primary-label object, and a consuming home page's panel titles resolve to
exactly them, so a reader moving between surfaces meets one thing rather than
two dialects.
Both title forms share one leading; the panel label does not. The root-level
plain title and the breadcrumb form both sit on 1.16. .surface-panel-title
keeps --lh-heading (1.12) — it is a single-line label in a different
implementation, and the adjustment was earned by a condition it does not have.
1.16 is where two measurements meet. The breadcrumb's old pattern-local 1.35
was measured against an underline that was a border, and survived the change to a
text decoration by inertia; re-measured it is visibly loose. But --lh-heading
alone left only 2px between the underline and the next line's glyphs at 320 /
375 / 390 / 414 in both themes — clear, and visibly cramped on device. 1.16 adds
0.96px per line at 24px. It is the structural locator's own metric, not a
foundation token, and nothing else inherits it. The family is this role's own, and it
covers both title variants below: the plain heading and the breadcrumbed
title both carry .surface-title and both stay mono — the
two forms differ in landmark and linkability, never in family. A panel primary
label (.surface-panel-title) takes Inter on the same size, weight and
tracking — but on its own --lh-heading (1.12) leading — whatever the panel is
called. The allocation is per selector, never read off an instance's copy. Same
core, different family and different leading, deliberately — do not conform
either to the other. Being a locator is why the title sits on Body rather than
an H-step; being a title is why it does not sit on the supporting step its own
lede occupies. The pair
separates on size — 24 against 18 — not on weight.
The test is ancestry, not local rootedness. Every page shows its complete, real, navigable public ancestry, from the public family root down to itself:
| condition | treatment |
|---|---|
| conditionno real navigable public ancestor at any level | treatmentplain <h1 class="surface-title">, no landmark |
| conditionone or more real public ancestors, inside the local family or above it | treatmentthe same <h1> wrapped in a breadcrumb landmark |
A real ancestor is one with a public destination a reader can actually reach. An ancestor that exists only as a concept, or whose page is not public, is not one, and inventing a destination to manufacture a segment is the failure this rule guards against — not the absence of one.
The distinction is deliberately not "inside this surface's own family". A project root with a genuine public parent above it — a studio route above a project, an organization above a studio — has ancestry, and hiding it makes the same surface announce a different depth depending on which family happens to own it. A reader crossing between two of a studio's projects should meet one hierarchy, not two dialects of one.
This supersedes an earlier rule. Before this change the pattern said a
root-level surface kept a plain title even when its .org segment linked
outward, on the reasoning that one outward link does not make a surface a
subpage of its own family. That is true and no longer the test: the crumb
describes public ancestry, and a real public parent is ancestry. The
design-system's own root page was the named example, and it is now a
breadcrumb.
A breadcrumbed title. Linkability is decided by destination, not by segment class — the class names what a segment is, never whether it has somewhere to go:
| segment | treatment |
|---|---|
| segmenta real ancestor or home destination | treatmentlinked |
| segmentstructural context with no destination of its own | treatmentstatic |
| segmentthe current segment | treatmentunlinked, with aria-current="page" |
The classes say nothing about what kind of thing a segment names, and they do not share a scope — one is available to both title forms and one is not:
| class | plain title | breadcrumbed title |
|---|---|---|
class.org |
plain titlethe static owning context | breadcrumbed titlethe outermost ancestry segment |
| class(none) | plain titlethe local current surface label | breadcrumbed titlean intermediate ancestor |
class.page |
plain title— | breadcrumbed titlethe inert current leaf, at any depth in the path |
.org is valid in both forms, which is why the generic plain specimen
carries <span class="org">[org]</span>. Only .page and the intermediate
reading of an unclassed segment belong to a breadcrumb path, because only a path
has depth to grade.
.page is the current segment, not a "payload" one. A family root that is the
page you are on takes it, exactly as a deep subpage does — which is why the
design-system's own front door carries
<span class="page" aria-current="page">design-system-ASK</span>. There is no
root-page exception, because an exception is precisely what would reintroduce
two grammars.
Currentness and linkability are different axes, and the table above grades only
the first. A current leaf carries no href because it is current — that is a
consequence of being current, never the reason it takes .page. Never omit
.page from a breadcrumb leaf on the ground that it has no destination, and
never promote ordinary static context to .page merely because it also lacks
one.
A plain title's local label is unclassed because the plain form carries no breadcrumb path — not because classes are unavailable to it, and not because the label has no destination. That is a distinction between the two title forms, and it is not a licence to leave a breadcrumbed family root's leaf bare.
Never invent a destination to make a segment interactive.
The generic form below leaves [org] static, because a placeholder organization
has no destination to link to. That is the placeholder's topology, not a
default — an organization segment is static only while it genuinely has nowhere
to go:
<nav class="surface-breadcrumb" aria-label="Breadcrumb">
<h1 class="surface-title">
<span class="org">[org]</span>
<span class="sep" aria-hidden="true">//</span>
<a href="../index.html">[repo]</a>
<span class="sep" aria-hidden="true">//</span>
<span class="page" aria-current="page">[surface]</span>
</h1>
</nav>
design-system-ASK's own surfaces are the linked case, and they are worth reading against the generic form above. Every ancestor above them is real and public, so every one of them appears:
<a class="org" href="https://a-s-k.studio/">ASK</a> <span class="sep" aria-hidden="true">//</span> <a href="https://a-s-k.studio/apex-solar-kiss">apex solar kiss</a> <span class="sep" aria-hidden="true">//</span> <a href="../index.html">design-system-ASK</a> <span class="sep" aria-hidden="true">//</span> <span class="page" aria-current="page">style guide</span>
Two details there are consumer topology rather than pattern rule, and a
different consumer will resolve them differently. The human-facing brand name
is the label — apex solar kiss, not the apexSolarKiss GitHub handle, because
a breadcrumb names a place a reader goes rather than a repository namespace. And
within this breadcrumb path the segment classes grade depth: .org on the
outermost ancestor, .page on the inert current leaf, nothing on the segments
between. That grading is what lets a four-segment chain still read as one object
rather than four equal words. A plain title has no path to grade, so it has no
intermediate tier and no .page — it still carries .org for its owning
context.
The public IA parent is the destination, not the code host: a GitHub organization is a utility destination and belongs in the navigation panel, never in the breadcrumb's ancestry. Both forms are conformant, and the difference is topology rather than class.
The nav wraps the title only — never the mark, never the lede. nav inside
<h1> would be invalid, since a heading takes phrasing content; wrapping is the
valid arrangement and keeps the heading exposed as a heading.
Ancestor links wrap, so their press is non-geometric. The repo's default
press is transform: scale(0.97), which needs a transformable box. Giving a
breadcrumb link that box makes it shrink-to-fit: an ancestor wider than the
content column stops breaking into inline fragments, fills the whole column,
wraps inside itself, adds a line to the header, and draws its underline across
the column rather than under the words. So these links keep ordinary inline
wrapping and take the non-geometric limbs — opacity and underline — instead:
| State | Opacity | Underline |
|---|---|---|
| Staterest | Opacity1 | Underline--surface-shell-link-underline — the emphasis accent at 50% opacity |
| Statehover | Opacity1 | Underline--ask-emphasis-magenta at full opacity |
| Stateactive | Opacity0.92 | Underline--ask-emphasis-magenta at full opacity |
The underline is a text decoration, not a border. It was a 1px --line-2
border measuring 1.11:1 and 1.08:1 against the light gradient stops — texture
rather than information — brightening only to 1.26:1 on hover.
The new resting value is the accent's own hue at half dose, not a mix toward
the foreground: mixing changes what color it is, and the resulting plum reads as
a darker text color rather than a quieter magenta. It measures 1.72:1 / 1.82:1
light and 2.11:1 / 2.23:1 dark, so it does not clear the strict 3:1 non-text
floor in either theme, and no strict AA claim is made for it. In light that floor
is unreachable by any opacity; in dark a higher one would reach it, but that
would be a different treatment from the single uniform default. The affordance
rests on the presence of a rule where the surrounding text has none, plus an
independently governed focus indicator. surface-text-link.css carries the full
disposition.
Hover spends the brightening limb; press adds the opacity drop on top of the
already-bright underline. The :hover opacity declaration is an override: the
foundation binds a:hover { opacity: 0.92 }, which would otherwise make hover
and press compute identically. Declaring it inside .surface-title settles it on
specificity rather than on stylesheet order. The foundation rule is unchanged.
No state changes display, box construction, line breaking, measured width, or fragment count.
Focus is a text-decoration underline here, because this link fragments.
Anything drawn around the box fails on fragmented inline text. A box-shadow
ring sliced — the default — is drawn around the unbroken box and then cut, so it
opens on the cut edges. Cloned, each fragment closes, but the result is one ring
per line rather than a single typographic indicator. A rectangular outline is a
single shape only where consecutive fragments overlap
horizontally, which is a property of where the text happens to break, not of the
treatment: at a 256px column the break is asymptotic / system key and it is
one shape; at 311px it is asymptotic system / key and it splits into one ring
per line. All three were rendered before the rule was chosen.
A text decoration is fragment-native — it follows each line's own text — so the indicator is:
.surface-title a:focus-visible {
outline: none;
box-shadow: none;
text-decoration-color: var(--fg-1);
text-decoration-thickness: 2px;
text-underline-offset: 2px;
}
The rest state is also a text decoration, so focus recolors and thickens the
single decoration already there rather than clearing a competing one. Exactly one
underline renders in every state, and nothing reflows because only color,
thickness and offset change. --fg-1 is the existing theme-resolving default
foreground role: no new token, no --fg-high-contrast registration, and the same
treatment on the quieter .org home link as on an ordinary ancestor.
Fragment-native and box indicators are not interchangeable. A fragmenting inline link cannot carry a ring; a box can. Do not describe one anatomy across both:
| Role | Focus indicator | Rest underline during focus |
|---|---|---|
Role.surface-title a (breadcrumb) |
Focus indicatorthe same decoration, recolored to --fg-1 at 2px / 2px |
Rest underline during focusbecomes the indicator |
Role.surface-text-link (module) |
Focus indicator--fg-1 decoration at 2px / 2px |
Rest underline during focusbecomes the indicator |
| Rolethe identity mark | Focus indicatorthe white box-shadow ring |
Rest underline during focusn/a |
| Rolefooter destinations | Focus indicatorsurface-action.css's attention edge, shared with its hover |
Rest underline during focusn/a — they carry no underline |
Two properties are worth naming so they are not mistaken for defects.
text-decoration-skip-ink stays at its initial auto, so the underline breaks
around descenders — per-glyph typographic clearance, not a contour opening at a
line break. And :focus-visible is last among the equal-specificity state
rules, so while a link is focused its underline is the focus indicator and the
hover and active magenta limbs are suppressed; active still dims the whole
element, because the focus rule sets no opacity.
Measured at 320 / 360 / 375 / 393 / 414px in both themes:
| Property | Result |
|---|---|
| Propertyevery fragment carries the indicator | Resultyes, at every width |
| Propertyindicator pixels landing on neighboring glyph ink | Result0 |
| Propertycontrast against the light gradient stops | Result3.50:1 · 4.00:1 |
| Propertycontrast against the dark gradient stops | Result10.25:1 · 12.26:1 |
| Propertyfragment count, line breaks, width, title and header height, page overflow | Resultidentical focused and unfocused |
The identity mark does not fragment and keeps the box-shadow glow. Footer
destinations take surface-action.css's attention edge — a magenta border, with
a 0.5px ring in light only — not this pattern's.
This is a surface-pattern exception for wrapping text, not a general retirement
of press feedback. The identity mark is a block-level slot, and a footer
destination is a compact action with a box of its own, so both are objects a
transform can act on rather than inline text that fragments; both keep the scale
press — the footer's now through surface-action.css rather than through a rule
of the shell's.
The .org span carries the owning organization; the .page span carries the
current segment and takes aria-current="page". The root variant omits .page
because it has no navigable ancestor, not because the consumer has one surface.
Either of .org and the repo segment may be a link or static — apply the
destination test above to each, per surface.
The lede
One or two calm declarative sentences about the surface. The shell sets the
foundation's Small supporting-text role — 18px / 300 — and imposes no
measure, so the lede uses the full header width that .surface-head-main
grows into. A per-surface measure is the consuming surface's call.
Implementation detail belongs in the payload.
The lede is not Caption text. Caption is the 14px uppercase label role: the
.caption utility sets 400 weight, 0.14em tracking, and uppercase over the
foundation's inherited Inter. The lede is prose a reader reads, so it sits on
the supporting-text step instead.
The shell footer sits on no type step of its own, because it declares no
type. Its destinations are compact actions, and surface-action.css owns
their family, size, weight, tracking, padding, radius, fill, border, foreground,
hover, press and focus:
<footer class="surface-footer"> <a class="surface-action surface-action--secondary" href="…">…</a> </footer>
They stay anchors. A destination is navigation; a <button> would keep the
appearance and lose the destination, open-in-new-tab, copy-link, and every other
thing a link is. The --secondary variant is the quieter foreground-only form,
which preserves the footer's previous --fg-2 weighting.
The row declares nothing but layout — display, flex-wrap,
justify-content, gap, margin-top. That is deliberate rather than an
omission: leaving font-family, font-size, letter-spacing or color on the
row would let an item that forgot its classes inherit an approximation of the
retired treatment and look roughly correct. A missing class should fail
visibly. Any future non-action footer content earns an explicit role of its
own; it does not acquire typography by accident from a navigation row.
What this replaced. The footer used to carry its own unboxed terminal-link
treatment — a magenta text decoration, a load-bearing inline-block, and a
footer-specific focus ring. All of it is retired.
--surface-shell-link-underline survives, with two consumers in this file:
the breadcrumb and the operable panel hierarchy rows. It does not regain
the footer.
Landmarks are part of the contract
header, main, and footer are required elements, not styling
preferences. Because this pattern frames a whole page, it owns that page's
landmark structure: one banner, one main, one contentinfo per
surface. That is how a nonvisual reader skips the chrome and lands on the
payload.
Every selector in surface-shell.css is class-based, so restyling never depends
on the element — but substituting a <div> for any of the three silently
deletes a landmark while the page still looks correct. Keep <footer> outside
<main>, or it stops being contentinfo.
.surface-payload is a hook for your own layout. The shell requires the main
element and declares no payload styling of its own.
The identity mark
.surface-mark is a slot, not a mark. The shell owns its width, its
alignment, and the optional mode-pairing mechanism. You own what goes in it:
| The shell owns | The consuming surface owns |
|---|---|
| The shell ownsSlot width — a fixed 116px at every breakpoint — and its alignment | The consuming surface ownsThe mark itself — image asset or inline SVG |
| The shell ownsThe optional light/dark visibility mechanism | The consuming surface ownsWhether to have a pairing at all, and which asset is which mode |
| The shell ownsNothing about the wrapper's semantics | The consuming surface ownsThe wrapper element and its role — a non-interactive <div role="img">, or a native <a> when the mark is navigation |
| The shell ownsThe mark's action where the surface adopts navigation — see §Responsive navigation | The consuming surface ownsNothing about that action; the authored anchor is upgraded in place |
| The shell ownsNothing about identity | The consuming surface ownsThe accessible name, and the Tier 3 identity it names |
This pattern ships no organization's wordmark. A consuming project supplies
its own Tier 3 — the same boundary
output-artifact already draws. The specimen in
surface-shell.template.html is a neutral placeholder; replace it wholesale.
The accessible name goes on the wrapper, never on a child. A name carried by the light mark disappears with that element when dark mode hides it, leaving the visible mark unnamed. Which wrapper carries it depends on whether the mark is also navigation, and the two forms are not interchangeable.
A // a non-interactive identity mark. The wrapper is a <div> carrying
role="img" and the name; every child is decorative:
<!-- One mark, any mode --> <div class="surface-mark" role="img" aria-label="[org]"> <svg viewBox="…" aria-hidden="true" focusable="false">…</svg> </div> <!-- Or a light/dark pairing --> <div class="surface-mark" role="img" aria-label="[org]"> <img class="surface-mark-light" src="…" alt="" aria-hidden="true"> <img class="surface-mark-dark" src="…" alt="" aria-hidden="true"> </div>
B // a mark that is also navigation. The wrapper is the link. Do not put
role="img" on it, and do not nest a link inside a role="img" wrapper:
<a class="surface-mark" href="/" aria-label="[destination or purpose]"> <img class="surface-mark-light" src="…" alt="" aria-hidden="true"> <img class="surface-mark-dark" src="…" alt="" aria-hidden="true"> </a>
role="img" makes its descendants presentational, so an interactive descendant
inside one is pruned from the accessibility tree — a wordmark link that looks
and clicks correctly while being unreachable to a screen reader. The image role
must therefore neither contain nor be applied to an interactive element. Name
the anchor for where it goes rather than for what it depicts: "Back to
example.com", not "example".
Both forms share one rule: the accessible name belongs on the semantic wrapper, never on a child. The consuming surface owns that wrapper element, its native semantics, its accessible name, and the Tier 3 mark; the shell owns the slot geometry and the optional mode-pairing mechanism.
The rendered specimen in surface-shell.template.html is variant A, the
non-interactive form; the prose in both that template and surface-shell.css
documents both variants and how to switch between them.
A single responsive SVG is a first-class implementation — a vector that paints
with fill: currentColor covers both modes from one file, and needs neither
modifier class. Two PNGs are not required.
Why the pairing is done in CSS rather than <picture>/srcset. Not because
<picture> ignores environment changes — it does react to them; the HTML
standard requires it. The reason is narrower: a <picture> media query can only
see the environment, and this design system has two DOM-state theme
mechanisms that no media query can observe. The shell must therefore track all
three dark paths the foundation defines:
| Path | Set by | Media query can see it? |
|---|---|---|
Path@media (prefers-color-scheme: dark) |
Set bythe operating system | Media query can see it?yes |
Path:root[data-theme="dark"] |
Set bythe page, explicitly | Media query can see it?no |
Path.theme-dark |
Set bythe page, explicitly | Media query can see it?no |
CSS sees all three, so the mark stays consistent with the tokens under every
one. If the foundation ever adds a fourth path, surface-shell.css must gain it
too — a consumer whose tokens go dark under a mark that stays light is exactly
the failure this mirroring prevents.
Optional slots
| Slot | When to use it | When to omit it |
|---|---|---|
Slot.surface-badge |
When to use itThe surface carries a standing classification, environment, or similar short fact. | When to omit itOmit the element. |
Slot.surface-head-aside |
When to use itThe surface needs a payload-specific control in the header. | When to omit itOmit the element. The shell reserves no space for an absent control. |
Do not emit an empty slot element to hold space. An absent slot must be absent from the markup.
The status note
.surface-badge is a note, not a control. It is inert on every surface that
uses it, so it carries no button geometry — the repo README puts pills on
interactive elements only, and a bordered capsule around inert text offers an
affordance the element cannot honor. The shell opens the row with a decorative
/// instead, which marks it as an aside without implying it can be pressed.
The marker is generated content the shell supplies, so a consuming surface
writes only its own words — no markup changes to receive the marker, and
nothing to keep in sync across a fleet. Where the browser supports the
content: "…" / "" alt syntax the marker is drawn but not announced, so the
note reads as its own words to a screen reader; where it does not, the fallback
declaration draws the marker with its slashes rather than losing it.
The marker inherits the note's color. It is the same ink as the words it
opens, so a short note carries one hierarchy rather than two — and a consuming
surface that recolors the note recolors the marker with it. That is what makes
a color-only instance override sufficient: set color on .surface-badge and
the whole note follows, with no page-specific exception anywhere.
The note takes the Caption size in the mono family, uppercased, and it wraps.
Its payload is the consuming surface's, verbatim — a // or >> inside that
string is the author's own separator. The shell prefixes; it never rewrites.
The control inside .surface-head-aside belongs to the consuming surface — its
behavior, its styling, and its script. The shell positions the slot and stops
there. design-system-ASK's own style guide is the reference case: it places a
theme selector in the slot and owns that control entirely, in
styleguide-theme-control.js and its own page CSS. That control is not part
of this pattern and is not vendored with it.
Responsive navigation
Optional. A surface adopts it by authoring one navigation source and loading
surface-shell.js and surface-action.css — the panel's close control is
mandatory and is a compact action, so the module is required by navigation
itself and not only by a footer. A surface that adopts none of it renders
exactly as before: no panel, no trigger, no terminal reserve, no scroll
padding, no scrollbar gutter. None of the navigation CSS engages, because all
of it is gated on a state attribute only the runtime sets.
What the breadcrumb is for, and what it is not
The visible breadcrumb was doing two jobs. It keeps one.
| Header breadcrumb | orientation — the structural title, the current path, ancestor shortcuts. It may wrap. It is not the navigation interface. |
| Identity mark | the navigation disclosure. One meaning at every placement. |
| Panel | the navigation interface — a vertical, tiered hierarchy, identical on both breakpoints. |
| Footer | terminal destination actions. |
The breadcrumb is not removed, shortened, or moved into the panel.
The mark is the disclosure
Where navigation is adopted, the mark opens the panel — the persistent desktop mark, the mobile opening mark, and the mobile seated mark alike. At any instant exactly one trigger is operable and exactly one is in the tab order; operability transfers atomically at the phase-locked handoff. A visible but inert mark is the defect this replaces.
One authored mark. You author a single <a class="surface-mark" href="…">
with a real home destination. The runtime upgrades that element in place into
the navigation button and derives the seated placement from the same payload.
There is no second authored mark to drift out of step.
Without JavaScript it stays a home link. The panel and the button never appear, and no dead control is exposed. That is the whole reason the authored element is an anchor rather than a button.
One panel, two entrances
One native <dialog>, one content tree, one interaction contract:
| Desktop | Mobile | |
|---|---|---|
| trigger | Desktoppersistent top-left mark | Mobileopening mark, then the seated mark |
| entrance | Desktopdrawer, from the top | Mobilesheet, from the bottom |
Mobile is not a width. The mode is narrow or short and coarse:
(max-width: 767px) OR (min-width: 768px) and (max-height: 499px) and (hover: none) and (pointer: coarse)
A landscape phone at 844×390 is wider than the desktop breakpoint and still wants the lower-right mark; a width-only divide hands it a fixed top-left mark and a top drawer on a 390px-tall touch viewport. The mode is published as state by the runtime and the geometry keys to that state, never to a raw query — which is also what lets a rotation while the panel is open close under the geometry it opened with and commit the new mode afterwards, rather than swapping drawer for sheet mid-exit.
Identical in both: content, hierarchy, current-page state, focus entry and
return, the explicit close control, outside dismissal, Escape, background
scroll lock, and glass paint. Do not maintain separate desktop and mobile menu
markup.
Three details are contracts rather than implementation preferences. The dialog's
UA box is reset explicitly, because without box-sizing: border-box the
max-height bound constrains the content box while padding and safe-area
compensation extend past it — recreating overflow at exactly the short viewport
the bound protects. ::backdrop is made transparent by declaration, so no
unruled veil alters the composition. And the dialog stays open and in the top
layer for the whole exit, with close() called on completion, so the motion is
never cut short.
The mark is chrome, so the page fades behind it
A fixed mark that payload content scrolls through reads as floating in front of
the page rather than as part of its chrome. Both placements therefore carry a
gradient fade, on one DOM carrier — .surface-nav-fade, a body-level sibling
— with mode-specific geometry:
| Desktop | Mobile | |
|---|---|---|
| edge | Desktoptop | Mobilebottom |
| protects | Desktopthe persistent top-left mark | Mobilethe seated lower-right mark |
| opaque zone | Desktopviewport top to the mark's resting bottom | Mobilethe seated unit |
| ramp | Desktop--surface-nav-gap, ending exactly where payload content begins |
Mobile--surface-nav-fade, 48px |
| motion | Desktopnone — the mask is static | Mobilethe mask edge travels with --surface-nav-p |
Both paint var(--bg-gradient) with background-attachment: fixed, which is what
makes a settled frame's fade indistinguishable from the page rather than a panel
over it. That parity is spatial and is the contract; one real-device timing
residual is recorded below. The attachment is load-bearing and fragile in one
specific way: an ancestor with a transform collapses the background
positioning area from the viewport to the element's own box, and because the
gradient is 45°, its axis length and endpoint colours are functions of that box's
diagonal — so the fade becomes a different gradient, not a shifted one, and
reads as a hard rectangle. That is why the fade is a sibling of the transformed
mobile seat rather than a child of it.
The desktop opaque zone is measured from the mark's resting offset, which is
the lowest it ever sits; the 64px → 40px settle therefore travels entirely inside a
band that was already opaque, and the shield needs no per-frame geometry. Its ramp
depth is not a taste decision: .surface-head takes
padding-top: --surface-nav-mark-block + --space-5 in this mode, so ramping over
that same gap puts the fade at zero opacity exactly where the title starts. At the
top of the page nothing is attenuated and the band is invisible, because it paints
the page's own gradient over the page's own padding.
A mask changes what is painted and nothing about what is hit. Each placement therefore carries a shield over exactly its opaque region and no further, so content scrolled out of sight cannot still take a click while content that is merely dimmed stays usable. Scrolling is unaffected — the shields intercept pointer activation, not wheel or trackpad movement.
Known iOS Safari limitation
On real iPhone Safari, the mobile fade may intermittently keep painting a stale gradient after fast upward inertial scrolling, so a seam becomes briefly visible between the fade and the page behind it. Scrolling a small further amount normally repaints it. The effect has not reproduced in a desktop browser's device emulation, and no root cause has been established — it is not a claim about the progress value, the mask, or WebKit's compositing of a fixed-attachment background.
Settled-frame spatial parity remains the contract. Temporal pixel parity during iOS inertial scrolling is not claimed.
The limitation is accepted and non-blocking. It is a property of painting the page's own gradient twice, and the alternative that removes it entirely — a blurred glass shelf, which is not a copy of the page — was rejected because it is permanently visible and reduces the readability of content passing beneath it. The gradient fade is invisible when correct; ASK selected it knowing this residual.
Reproduction evidence, the full experiment inventory, and the criteria a remedy would have to satisfy live in issue #133, which is open to external investigation. Nothing further is authorized here.
Where the close control sits, and why it differs by mode
The control is the same button in both modes — same class, semantics, label and focus treatment. Only its corner differs, because the two panels enter from opposite edges:
| Desktop | Mobile | |
|---|---|---|
| panel enters from | Desktopthe top | Mobilethe bottom |
| close sits at | Desktopthe drawer's lower right | Mobilethe sheet's upper right |
| reason | Desktopthe corner furthest from the edge it came from, and nearest the reader | Mobilethe corner nearest the thumb |
On desktop the control sits in an ordinary layout row, not out of flow. The first attempt did pin it absolutely to the drawer's lower-right and padded the scroller to keep content out from under it; that produced two defects with one cause. It shared no layout with the repository utility, so the two could not be aligned to each other, and the padding protecting content from a floating button became empty depth the drawer had no content to fill. Both disappear once the control is simply in the layout.
display: contents on .surface-nav-head is what allows that without touching
the DOM. The head's own box is dropped, so the hierarchy, the utilities and the
close all become grid items of .surface-nav-panel-inner directly, and the
desktop override arranges them as a scrolling hierarchy above one shared bottom
row: hierarchy across the upper row, repository utilities lower-left, close
lower-right. The two footer items align to a common bottom edge because they are
siblings in one row that both end-align inside it — not because either is
positioned against the panel.
The scroller moves from the inner element to the hierarchy. That is what lets a short drawer fit its content and a long one cap at the viewport, while the action row stays visible in both cases — the hierarchy alone scrolls, and the drawer carries no bottom padding compensating for a floating control, because there is no longer one to compensate for. Mobile is unchanged: the sheet's inner element scrolls, the close stays at its upper-right corner, and the padding it does carry is the safe-area inset rather than close-button clearance. DOM order is unchanged in both modes, so the tab order a keyboard user walks is the one the markup states; only the boxes move.
Swipe-to-close, and why it is only ever an enhancement
The mobile sheet carries a visible drag handle. A downward drag on it dismisses the panel; the explicit close button remains, unchanged and mandatory.
The control line is the target; the close button is the hole in it. The bar a
reader sees is 2.25rem × 0.25rem, centred on the panel. The element that
accepts the pointer is the whole top control line — handle and close share one
grid cell, the handle spanning the row's full width and the close control painted
over it, end-aligned, keeping its own pointer box.
They are siblings rather than nested, so a press on close simply never reaches the handle's listener: there is no drag to cancel and no event-cancellation trick. The row is already as tall as the handle's block size, so the broad target costs no panel height.
Sizing the target to the bar leaves only the bar's own height to land on, which makes a correctly implemented gesture feel broken. A compact box floating in a wide landscape panel is the opposite error — needlessly precise. The visible bar is a pseudo-element of the target, so none of this costs an authored node or a tab stop.
That order matters. The gesture is discoverable only by trying it, has no
keyboard equivalent, and is invisible to assistive technology by design — the
handle is aria-hidden and carries no tab stop, because announcing a control
that only a finger can operate would be worse than not announcing it. So the
gesture never becomes the means of dismissal; it becomes a second means
alongside a button that every input can reach.
Two thresholds, either of which commits. A single threshold fails a whole class of natural gesture in one direction or the other: a quick flick never travels far, and a careful drag is never fast.
| value | commits on | |
|---|---|---|
| distance | value88px |
commits ona slow, deliberate pull, at any speed |
| velocity | value0.55px/ms downward at release |
commits ona short, fast flick |
Velocity measures the sheet's own clamped displacement, not raw pointer Y,
through the release sample over the recent 120ms window. Upward travel is clamped
to zero, so a finger that goes up and snaps back to its start would otherwise
produce a large raw-Y velocity while the sheet never moved — dismissing on a
gesture with zero net displacement. The quantity that commits is the one the
sheet actually performed. The release carries its own coordinates and timestamp, so movement
between the last pointermove and pointerup counts toward both arms; samples
older than the window are dropped, always retaining one predecessor. Reading only
the move stream averaged a final flick across any earlier stationary hold —
failing exactly the gesture this arm exists for.
Both thresholds are named constants in one place in surface-shell.js. This is
deliberately not a physics model or a tuning surface.
Upward travel is clamped to zero, not tracked — the sheet is bottom-anchored, so following a pointer upward would lift it off its own edge. A horizontal-dominant movement is a swipe across the handle rather than down it, and does not commit.
The handle is the only drag origin. touch-action: none is scoped to the
handle and appears nowhere else. Placing it on the panel, the hierarchy or the
utility region would take the browser's own scrolling away from the content —
which is exactly why the capture is constrained rather than the surface.
A committed swipe routes through the existing closePanel(), so open-state
bookkeeping, exit motion, focus return, dialog closure and queued-frame
cancellation are the ones already reviewed. There is no second close path. An
insufficient drag returns the sheet to its exact open position and clears every
temporary class, inline property and capture.
Mouse is excluded. A desktop drawer has no swipe affordance, and a mouse drag
that dismissed it would be an undiscoverable action with no visible handle. The
handle is display: none outside mobile mode, where mobile keeps the runtime's
existing definition — narrow, or short and coarse — not a width-only test.
While a finger is down the panel's transition is suppressed, so the sheet tracks the pointer directly instead of easing toward each move event. It is restored the moment the gesture ends, which is what makes the insufficient-drag return animate. Under reduced motion the settle simply snaps.
Focus is established the same way; only its presentation varies
Opening the panel always moves focus to the first destination — a modal needs an internal focus destination, and that does not change with input modality. What varies is whether a keyboard indicator is painted:
opened by pointer / touch focus moves · keyboard indicator suppressed opened by keyboard focus moves · keyboard ring visible first keypress after a suppression clears immediately, so a hardware pointer open keyboard gets its indicator the moment it is used Escape closes focus returns to the invoking mark, visibly
iOS Safari matches :focus-visible on programmatic focus whatever began the
interaction, so a tapped-open panel painted a white ring around the first row
and implied a selection the user never made. The suppression is scoped to that
one element by a bounded attribute and removes only the keyboard indicator:
outline and box-shadow on a row, and — on the close control, which the driver
focuses when the tree has no link and which is a compact action — its
attention-edge border too, while it is not hovered. The element keeps its
resting destination underline where it has one, its hover and active feedback,
and its real focus. The trigger carries the equivalent
treatment on the return path, under a separate attribute — the two states are
cleared by different events, and sharing one would let each clear the other's.
The visible ring after Escape is correct and deliberate. Escape is
keyboard input; focus returns to the mark, and the ring is what tells a keyboard
user where it landed. Removing it would make focus restoration invisible.
One path source, plus what a path cannot contain
The visible structural title owns the complete current public path — every real navigable ancestor and the inert current leaf. A breadcrumb cannot supply siblings it does not contain, so the remaining sources are strictly off-path:
| Source | Authored | Used for |
|---|---|---|
| Sourcethe header breadcrumb | Authoredonce, visibly | Used forthe panel's vertical current path, derived — never restated |
Source.surface-nav-local |
Authoredonce, optionally | Used forsibling or child destinations the path cannot contain |
Source.surface-nav-utilities |
Authoredonce, optionally | Used forrepository and other external routes |
No public ancestor is repeated in source configuration. A segment that belongs to the current path belongs in the visible title, where a reader can see it — not in an attribute that only the panel reads.
data-nav-root-* is legacy runtime compatibility, not current authoring.
It predates the ancestry rule, when the public root was configured above the
crumb instead of appearing inside it. The runtime still honours it so that
already-landed consumers are not broken by taking a newer surface-shell.js,
and it is retained for that reason alone.
Do not author it on a new surface. Authoring it alongside a visible root
produces the exact defect the one-path rule exists to prevent — the runtime
prepends the configured root and then appends the visible chain, so ASK
appears twice in the panel. A configured legacy root must never name a segment
the structural title already shows.
Removal is a later cleanup unit, gated on the propagation census reaching zero live use — not on this document.
The current page is never authored twice. data-surface-nav-current is an
inert position marker: you say where the current location sits among its
neighbours, and the runtime fills it with the segment derived from the visible
breadcrumb. Writing the label again — or a second aria-current — would give
the panel an independent current-page source that can drift from the visible
title, which is exactly what the one-authored-path rule exists to prevent.
<!-- SIBLING form: this surface sits among its siblings. The template carries no
root configuration — the public ancestry is already in the visible title. -->
<template class="surface-nav-source">
<ul class="surface-nav-local">
<li><a href="…">a sibling</a></li>
<li data-surface-nav-current></li>
</ul>
<p class="surface-nav-utilities">
<a class="surface-action surface-action--secondary" href="…">repository</a>
</p>
</template>
<!-- PARENT form: this surface is the root of its own family, and the local
destinations are its children rather than its siblings. Being a family root
says nothing about ancestry: this surface may still have public ancestors
above it, and if it does they are in its visible title. -->
<ul class="surface-nav-local">
<li data-surface-nav-current>
<ul>
<li><a href="…">a child</a></li>
<li><a href="…">another child</a></li>
</ul>
</li>
</ul>
The authored order and nesting are rendered as authored: the panel's shape is the consumer's, and only the current row's content comes from the shell.
A <template> is inert by definition, so an unenhanced page renders nothing from
it and exposes no partial control.
Do not list the ancestor twice. The deepest ancestor already has its own row and its own destination; adding an "overview" child pointing at the same place puts two rows on one target, which is the defect this IA exists to remove.
The hierarchy is a hierarchy
Rows are nested list items. Tier is carried by indentation and the branch guide;
the current item is inert and carries aria-current="page".
Hierarchy rows are not compact actions. Rendering every tier as an identical
pill would flatten exactly the structure the panel exists to express.
surface-action stays where it belongs: utilities, explicit local action rows,
and the footer.
An operable row carries the magenta text affordance — the same resting rule
the breadcrumb uses, at partial opacity, rising to full magenta on hover and
press. The glass row-fill is a hover response, so without the underline the
resting hierarchy would read as a diagram of the site rather than a set of
destinations. Shape communicates operability for a shaped control; text needs the
rule beneath it. The full-row ring remains the focus indicator and the
underline stays visible beneath it — these rows are blocks and do not fragment,
so the breadcrumb's fragment-native focus anatomy would be the wrong one to copy.
The current row is a span, carries no underline, and has no hover state.
What a declining surface gets
Nothing. That is the test, and it is worth running: no surface-shell.js, no
data-surface-nav attribute, no data-surface-nav-mode, no fixed mark, no
seated unit, no scroll-padding-bottom, no scrollbar-gutter, and an identity
mark that is still an ordinary anchor.
Declining is a real option for a surface with nowhere to go. It is not a way to give one page of a family a different mark behavior from the rest: the mark's meaning is supposed to be the same wherever it appears, so a family either adopts together or has a stated reason not to.
What the shell does not own
- The gradient field, base type, and base element styling.
colors_and_type.cssbindshtml, bodyto the gradient, the foreground ramp, and the base font. The shell defines nobodyrule at all. A consuming surface may add a deliberate, token-based instance or payload default there — a page whose payload defaults tovar(--font-mono), for instance. It may not redefine a Tier-1 or Tier-2 role, and duplicating a foundation binding to no purpose is noise rather than a default. Two of this repo's own three surfaces carry such a delta — a mono payload default — and nothing else; a re-statedbackgroundshorthand in particular is not free, because it also resets the foundation'sbackground-attachment: fixedtoscrolland gives that surface a different gradient behavior from the rest of its family. - Payload layout. Everything between
.surface-ruleand.surface-footer. - Footer content. Which destinations a surface closes with is the surface's
business. Their right alignment, wrap and row gap are the shell's; their
presentation and interaction are
surface-action.css's. Three owners, one row — and none of them is the consuming page's own stylesheet. - Identity. The mark slot is the shell's; the mark, its pairing, and its accessible name are the consuming project's Tier 3.
- Tokens. The shell composes existing
var()roles and introduces no token and no palette color. The one literal value is the focus glow's translucent white, which the repo README's focus contract specifies verbatim and for which no token exists. Any other raw color value, or a font stack, insurface-shell.cssis a defect. - Theme selection. The shell responds to the resolved mode; it never sets one.
Consuming this pattern
The mark is a fixed 116px at every breakpoint. Below the shell's 640px
breakpoint the header stacks and .surface-head-main takes the full width, but
that changes the header's arrangement, not the identity mark's scale — the same
116px on a 320px phone and a 1280px desktop is what makes the mark read as the
same object across a fleet of surfaces. That width is the shell's, not a
per-surface choice; a consumer whose mark is square rather than a wide wordmark
should check the resulting height, since the child keeps its own aspect ratio.
A surface that wants its wordmark to become the content column at a narrow viewport is describing a different composition, not a shell parameter. It owns that composition outside this generic shell rather than redefining the slot — the way a front-door homepage owns its own hero.
Live same-repo surfaces reference the canonical file directly.
design-system-ASK's own root, style guide, and pattern gallery page consume the
shell this way: no copied bundle, no pin, resolving the canonical owner file at
repo HEAD. When the shell contract changes, verify their rendering, landmarks,
interaction, and behavior. They are hand-maintained pages, and they are not
regenerated merely because the stylesheet they reference changed.
The generated owner preview is a distinct artifact.
patterns/_preview/surface-shell.html is regenerated from
surface-shell.template.html through the owner generator, and it takes the
generated-artifact treatment: generator --check, generated-set parity, and
visual verification. The gallery page consuming the shell for its own chrome is
a separate relationship from the gallery cataloguing that generated specimen.
Downstream repos vendor local pinned copies alongside the foundation mirror,
with no CDN and no live hot-link to a design-system deployment. A cross-origin
runtime dependency on another surface's host is not a consumption path this
pattern offers. What to vendor is in §Files: always surface-shell.css and the
template, plus surface-action.css wherever footer destinations are rendered,
plus surface-shell.js for navigation adopters only. A repo with at least one
adopting page may vendor one shared copy of the runtime.
Do not hand-edit a vendored copy. Local differences belong in the consuming repo's own stylesheet, layered over the vendored file — that keeps the vendored bytes verifiable against the owner at its pinned commit, and keeps the local delta legible as a delta.
Your own identity mark must be reachable from the consuming surface — from
wherever your repo already keeps it. Nothing about it is vendored from here:
design-system-ASK ships the slot, not the mark. A surface that legitimately
fills this slot with the logo-ASK wordmark does so on one of two bases, each a
property of that consumer and never an instruction from this pattern: the
surface is itself an ASK-instance surface carrying ASK's own Tier 3, or ASK has
explicitly assigned the logo-ASK wordmark to the consuming project as its
locally supplied Tier 3 and the surface falls within the assignment's recorded
scope. An assignment does not make that project ASK-the-entity, and this
pattern makes no assignment.
When surface-shell.css changes upstream, each downstream consumer re-syncs on
its own schedule. Consumption relationships are recorded in
CONSUMERS.md; vendored pins and currency are tracked
operator-side, not in this repo.