Pattern // surface shell

patterns/surface-shell/README.md · 56548 B · source sha256 ffdb0dbd0098

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.css binds html, body to the gradient, the foreground ramp, and the base font. The shell defines no body rule at all. A consuming surface may add a deliberate, token-based instance or payload default there — a page whose payload defaults to var(--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-stated background shorthand in particular is not free, because it also resets the foundation's background-attachment: fixed to scroll and gives that surface a different gradient behavior from the rest of its family.
  • Payload layout. Everything between .surface-rule and .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, in surface-shell.css is 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.