npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@clossys/publisher

v0.11.0

Published

The publisher role: did we put it out to an audience, and can we prove what shipped? Surface composition, media registries, and channel renderers for web, email, print, images, and slides, plus a product-neutral structured-document contract, under `./core

Readme

@clossys/publisher

The publisher role — did we put it out to an audience, and can we prove what shipped? This package is named for the job, not the artifact: it is recut from two donor packages, @example/surface (the composer half — eight subpaths, unchanged) and @example/ledger (the record half, now the ./record subpath), per decision 10. The vocabulary inside each half is unchanged: renaming the role does not rename what it composes or what it records.

npm install @clossys/publisher

This package is published to https://registry.npmjs.org with public access; installing it requires no authentication.

Verified publication rate

Independent consumer evidence shows the position's owned metric meets its setpoint over the declared review cadence. The owned metric is verified publication rate, computed by assessVerifiedPublicationRate(). An empty evaluated set is indeterminate, never a perfect rate of 1. publisher-media-check and publisher-record-check remain the gates they are; neither is this rate. The record half still records and does not judge. This package does not measure consumer evidence and does not close the loop. A green run of this package's tests is not a close.

import { assessVerifiedPublicationRate } from "@clossys/publisher/assessment";

const report = assessVerifiedPublicationRate(input);
publisher-rate-check assessment.json

The command prints JSON and exits 0 for satisfied, 1 for violated, and 2 for indeterminate, unreadable, or invalid input.

This package declares that command as its first-day assessment surface in its own manifest:

"foundry": { "assessment": { "bin": "publisher-rate-check", "invocation": "single-json-input" } }

Onboarding discovers that declaration from the installed manifest and never infers a surface. publisher-media-check and publisher-record-check remain gates and are not the assessment surface. Publisher is not a required first-day role; Advisor remains the only required first-day assessment.

The job

Aim — every audience-facing release is accounted for in an immutable publication record, and nothing in the record names something that never shipped.

Publisher is a reconciliation role, not a gate over a source tree. It does not scan prose or stylesheets and return a verdict the way writer-check or designer-fold-check do. It compares two records that can disagree: what an independent observer reports was released to an audience, and what the append-only ledger says was published.

Escape

An escape is either:

  1. Unrecorded release — something reached an audience with no matching immutable publication record.
  2. Phantom record — the ledger names a publication that never shipped, or names bytes or strategy citations that no longer match what actually went out.

A green run of this package's own tests, or a ledger authored from the same knowledge that would check it, is not evidence of zero escapes — that is the house failure pattern this role exists to prevent.

The metric

The charter metric is verified publication rate: due publication intents where independent observers report both audience release and a matching immutable record, divided by all due intents actually evaluated. An empty evaluated set is indeterminate, never a perfect rate of 1. See Verified publication rate above for assessVerifiedPublicationRate() and publisher-rate-check.

publisher-media-check reports registry coverage (referenced asset ids vs registered entries). publisher-record-check reports fact drift, append-only integrity, and join-key completeness on a ledger a consumer already holds. Those counts are inputs to reconciliation; none of them is the charter rate, and none of them alone proves the escape surface is closed.

Loop

  • sense — enumerate due publication intents; collect independent observations of audience release; hold the append-only ledger and the fact snapshot publisher-record-check compares against.
  • judge — assessVerifiedPublicationRate() for the charter metric; publisher-record-check for drift, append-only violations, and join-key gaps on declared ledger entries.
  • act — append an immutable PublicationEntry when something ships; remediate drift findings and missing join keys; retract or supersede entries when a release is withdrawn.
  • learn — repeated indeterminate or violated assessments, or drift findings that keep reopening on the same intent, signal that the publish path is not emitting a record an observer can match — not that the gate needs softer rules.

Measurer today: publisher-rate-check (charter metric from consumer-supplied observations) and publisher-record-check (ledger gates). Blocker: nothing in this package yet produces a second, publish-path-emitted view of what actually shipped for an automatic reconcile — issue #502 tracks that gap. Until two independent records exist, every rate reads unmeasured rather than zero; treat silence as indeterminate, not health.

Close condition

This loop closes when verified publication rate reads satisfied from independent consumer evidence over a non-empty set of due intents — each intent independently observed with both audienceReleased and matchingImmutableRecord — and when reconciliation machinery can compare that evidence to a publish-path-emitted ledger without hand-copying the same facts into both sides.

It does not close when: no observer runs the assessment; the evaluated set is empty; publisher observes itself; or the ledger and the "what shipped" view share one author. A consumer wiring publisher-record-check in CI proves ledger shape integrity, not that the escape surface is zero.

That close is not claimed today — the reconciler gap in #502 is the long pole. This README states the contract so the gap stays visible instead of reading as a perfect zero escape rate from outside.

Public entry points

Use explicit subpaths:

  • @clossys/publisher/assessment — assessVerifiedPublicationRate, the charter close metric. Empty evaluated set is indeterminate, never 1. The CLI is publisher-rate-check.
  • @clossys/publisher/core — canonical SurfaceDocument contract, validation, copy/media resolution, and output manifests.
  • @clossys/publisher/media — media registry, reader, and coverage check.
  • @clossys/publisher/web — web composition, head metadata (including site identity metadata and its head lint), and the dedicated resolved-model SectionedView renderer. Under React's react-server export condition it resolves a server-safe target with the same runtime export names and Designer's server-only component barrels; ordinary imports retain the interactive React Aria FAQ.
  • @clossys/publisher/web/consent — the client-only browser consent assembly: ConsentExperience, bindTransport, useAnalyticsAllowed and useConsentStatus. Under react-server it resolves a module that throws at import. See "Browser consent," below.
  • @clossys/publisher/web/consent/preview — the fixed-clock consent preview for development review. It resolves only under the development condition; every other condition, and react-server, resolves a module that throws at import.
  • @clossys/publisher/consent-copy — server-only consent copy resolution, resolveConsentCopy. Under the browser condition it resolves a module that throws at import.
  • @clossys/publisher/document — the product-neutral structured-document contract (sections, paragraphs, lists, tables, callouts, safe links) and its renderer.
  • @clossys/publisher/email, /print, /image, /slides — channel renderers.
  • @clossys/publisher/record — the append-only, content-addressed publication ledger and its drift checker. See "record — the append-only publication ledger," below.
  • @clossys/publisher/pack — the v0 Launch pack manifest contract: types, schema validation, needs-graph readiness, adopt-don't-override detection, and the evidence-gated website seal (publisher-seal) and the rendered-head lint (publisher-head-lint). See "The pack," below.
  • @clossys/publisher/surfaces — the one-owner-per-file contract for surface documents under clossys/publisher/surfaces/. See "Surface documents move to Publisher," below.
  • @clossys/publisher/materials — the materials mini-site (overviews, pitch decks, audience variants). See "Materials site," below.
  • @clossys/publisher/templates — the pack's default templates and the channel spec registry. See "Templates and channel specs," below.
  • @clossys/publisher/testing — a test-support check that a consumer's front-door surfaces (sign-in, boundary and error pages) hold the shared invariants. See "Front-door conformance," below.

The package has no root export. core is deliberately framework-agnostic; the web and document subpaths have optional React peers, while web also declares Designer's optional runtime peers directly so a public-registry consumer receives a complete, inspectable peer contract. Non-web renderers do not require them at runtime. record is pure and has no peer dependencies of its own.

The web condition changes only the implementation selected for server rendering, not the API. MarketingView keeps the same props and regional layout; its server target uses Designer's native details/summary FAQ while the ordinary target keeps Designer's React Aria FAQ. SiteFrame, AuthView, StatusView, GlobalErrorDocument, ErrorView, CaptureView, CollectionView, DocumentView, LegalView, the renderer functions, template helpers, error class, and all runtime export names are present in both targets.

Why publisher is one package, not two

Composition without a record is unprovable, and every time the publisher runs, the record runs — there is no publish that legitimately skips it. That argues for one install and one version, which one package with a ./record subpath delivers.

The measurement that originally argued for two separate packages is accommodated rather than overturned: the record shares no code with the composer and does not import it, so the two import surfaces stay genuinely separate under one version. Fusing the packaging was never the same as fusing the dependency graph, and only the second would have cost anything — see "record — the append-only publication ledger," below, for the half that proves it: a publication record is a DOCUMENT the composer never imports.

Scope: this package renders and validates. It does not compose.

Stated plainly because evaluating it and discovering this costs real time: there is no compose step here, and there will not be one. Nothing in this package takes an intent, a brief, or a content plan and selects a template to put it in. Every export is a render*, a resolve*, or a validate*.

The boundary is: a caller authors a SurfaceDocument — naming its template explicitly — and this package validates it, resolves its copy and assets against real approved registries, renders it for a channel, and reports what it did. Deciding which document to build, from what intent, is the consumer's job and stays there.

This is a scope decision, not a gap awaiting a contributor. Template selection is where product judgement lives: which page shape serves which audience at which moment is exactly the reasoning a shared package cannot hold for someone else, and a compose(intent, target) that guessed would be wrong in a way that is expensive to discover and impossible to override cleanly. Drawing the line at "you name the template, we guarantee everything after it" is what lets this package promise something real — validated content, resolved copy provenance, deterministic output — instead of promising judgement it does not have.

What that leaves the consumer owning: intent-to-template selection, and any catalog of their own templates worth selecting from. What it leaves this package owning: everything from a named template onward.

Making the set of selectable web templates extensible is a separate and genuinely open question — see the template-registry proposal in this repository's issues. That is about who may add a template, not about who picks one; extensibility does not imply composition.

SurfaceDocument and renderer boundary

SurfaceDocument is the canonical authored contract. It replaces the string-bearing ComposeDocument with CopyRef values for audience-facing slots and metadata: web titles/descriptions, email subjects/preheaders, image alt text, and slide notes. A binding is exactly one of copy, node, or assetId; node preserves an explicit consumer-provided interactive/rich UI node without pretending it is copy. Use resolveSurfaceDocument(surface, copyResolver) at render time: it validates the canonical document, resolves every required CopyRef against a real approved registry, returns renderer-facing data plus the full resolution provenance, and fails closed for invalid, missing, or unsupported node bindings. New consumer code authors SurfaceDocument; ComposeDocument is the renderer-facing shape produced by this package, not a consumer migration API.

Web and email templates use FlowLayoutSpec, which contains ordered keys and requiredness only. Print, slides, and image surfaces use CanvasLayoutSpec with frames and element kinds. This prevents flowed surfaces from carrying fictional canvas geometry.

Repeating-group bindings

A SurfaceDocument's bindings array accepts two shapes: a SurfaceSlotBinding (one slot, exactly one of copy/node/assetId, as above) or a SurfaceRepeatingSlotBinding — the same slot, bound to an ordered list of items instead of a single source. This closes part of issue #166: a template can commit a slot to holding N items (a capability grid, a stat band, a testimonial list) where N is a run-time fact the template's own layout cannot encode, since FlowLayoutSpec/CanvasLayoutSpec name a slot once, not "this slot, repeated."

A repeating binding still names one explicit slot the consumer already decided exists — it does not select a template or invent a slot, the same boundary every other binding in this package holds to (see "Scope," above). Each item in items independently obeys the identical exactly-one-of discipline a single binding does. For a one-value item, use copy, node, or assetId as before. For ordinary multi-field editorial content, use one named fields map instead; each field is exactly one copy or assetId binding. A field may never be a node, so structured copy cannot bypass the registry, voice checks, locale selection, or output provenance.

import type { SurfaceDocument } from "@clossys/publisher/core";

const acmeCapabilities: SurfaceDocument["bindings"][number] = {
  slot: "capabilities",
  items: [
    { copy: { id: "acme.capability.one" } },
    { copy: { id: "acme.capability.two" } },
    { assetId: "acme.capability.icon.three" },
  ],
};

Templates opt into structured items by declaring their accepted field names and requiredness in repeatingSlots. At render time, an unknown field, a missing required field, a malformed field map, or a legacy one-value item against a structured slot fails closed. A field map against a slot that did not declare fields fails closed too. This keeps the template—not a caller's ad hoc object—the authority for the repeating item's shape.

items may be an empty array. That is a deliberate choice, not an oversight: this package cannot tell "the consumer configured zero of these on purpose" (a legitimately-empty testimonial list) apart from "something upstream failed to populate this," so it validates an explicit items: [] as clean rather than guessing. See types.ts's SurfaceRepeatingSlotBinding doc comment for the fuller reasoning.

resolveSurfaceDocument resolves a repeating binding's items in order and returns them on ResolvedSurfaceDocument.groups — an array of { slot, items: [{ index, value?, node?, assetId?, fields? }, ...] } — rather than folding them into the legacy ComposeDocument.bindings shape, which has no way to carry more than one source per slot. groups is omitted entirely (not an empty array) on a document with no repeating binding, so an existing single-binding-only SurfaceDocument resolves identically to before this existed. A bad item — an unresolvable CopyRef, same as any single binding's — fails the whole resolveSurfaceDocument call with a message naming the specific item (bindings.N.items.M), the same fail-closed, all-or-nothing contract this function already holds for a single binding; it does not invent a second, partial-success mode just because the content is array-shaped. Per-item copy resolutions flow into the same resolutions/collectCopyProvenance provenance path a single binding's does, so a repeating-group slot shows up in manifest provenance per item, not just per slot — see output-manifest.ts. A structured field resolved from assetId instead carries registry-validated asset evidence into the target renderer; it has no copy provenance because it is not copy. At the public RenderWebOptions.groups boundary, items must retain the resolver's contiguous source order: item index is exactly its zero-based array position. This prevents a direct caller from silently reordering, duplicating, or sparsifying the authored group after resolution.

Structured repeating-item migration and planned semver boundary

Publisher 0.1.10 exposed the FAQ repeat as a legacy one-value/node-shaped contract. That shape cannot represent two separately governed editorial fields, and it is not compatible with the structured FAQ contract above. Migrate each FAQ item from a caller-authored node to explicit approved-copy fields:

// Before: legacy node-shaped FAQ item (do not carry this forward).
{ node: { question: "...", answer: "..." } }

// After: every audience-facing field is a CopyRef.
{
  fields: {
    question: { copy: ref("acme.faq.question") },
    answer: { copy: ref("acme.faq.answer") },
  },
}

This is a breaking contract correction, so Publisher source is now the planned 0.2.0 successor; a ^0.1.x range must not satisfy it. The source is neither qualified nor published: an exact-head candidate and public dependency verification remain required before any release action.

Choosing a shipped view — closed kinds, defineWebTemplate for the rest

Name a shipped template when its slots cover the page:

  • MarketingView — pre-auth marketing landing (hero, features, optional FAQ, CTA).
  • SectionedView — long public pages whose sections are exactly the closed nine kinds (hero, feature-grid, faq, ordered-step-sequence, status-list, stat-grid, pricing, testimonial, stat).
  • AuthView — one shell for every authentication step (sign-in, sign-up, password reset, verification): the page header and the form inside Designer's Card, rendered chrome-free inside a SiteFrame; any deprecated chrome prop selects the legacy page, which adds Designer's site header and site footer. The card holds only the form; notes (the alternate-step lines, such as "Forgot password?" or "No account? Join the waitlist") renders below it, before the footnote, and is always the site's copy. secondaryAction is the deprecated name of notes and renders in the same place; passing both throws. Optional noAccountPrompt and requestAccessLabel hold caller copy and a labeled link. The view has no access mode and renders whatever prompt, label or notes it is given, so invitation-only sign-in and activation pages omit both props and request-access/sign-up notes, and each site's page test should assert no request-access or sign-up text. Pass each notes line as one element: text plus a link in one fragment splits onto two lines. When the sign-in provider is unavailable, isDisabled keeps the form on screen inside a disabled <fieldset> with the typed values still shown; the explanation goes in the form's own submitError, and a retry link in notes stays enabled. Switching isDisabled remounts the form, so the site keeps the typed values in its own state. The content column uses the --ui-width-form-max form measure, and description is a required prop so every step decides on a supporting line. The form slot is filled with Designer's Form / TextField / Button. There is no mode prop, and the view does not call an auth provider. The view prints no developer text: a missing sign-in provider setting is reported once in the server log (for the Clerk adapter, createClerkSignInPage in @clossys/bouncer/providers/clerk/web/server names the missing settings there, never their values), and the page shows only the user-facing unavailable message. internalNote is deprecated: it is still accepted, with its earlier { label, message } shape, for one release and renders nothing; delete it from the call site. An auth page's footerSecondary holds a legal row only, never a locale switcher, because auth pages are single-locale. SignInForm fills the form slot of a sign-in page. The header slots are listed under Front-door shell.
  • ErrorView (deprecated; use StatusView, or AuthView when the state needs a card) — error shell, including the sign-in-boundary states: not authorized (403), pending, revoked, and provider unavailable (503). It takes the same props for each; the status, title, description, and recovery action are the caller's copy. A diagnostic reference goes inline in the description ("Something went wrong. Error: 8f2a91c0."), there is no details disclosure, and action holds one primary control: a secondary destination is a text link inside the description.
  • BoundaryView (deprecated; use StatusView, or AuthView when the state needs a card) — a boundary page: the page header block (status, title, description), the action in a Card and the notes below it. Inside a SiteFrame it renders that content only; passing any deprecated chrome prop (brand is optional) selects the legacy page, which adds Designer's SiteHeader (brand) and SiteFooter (footerSecondary, optional). It takes every ErrorViewProps key, so a site deletes its private copy of the header/error/footer shell. It also frames the sign-in-boundary states. It is not a built-in web template. See Boundary pages.
  • StatusView — a whole-page status (not found, server error, no access, expired link, unavailable, or a placeholder for a page that is not built yet) on the card-free layout: status as the one <h1> (a code or a short title), plus optional subtitle, action and notes. Chrome-free only. It replaces ErrorView and BoundaryView.
  • PackReviewView — the dev-only review index: the pack's strategy brief, brand kit (through BrandGuideView) and voice and copy, then a site's pages, their forced states, its exported artifacts and a lazy contact sheet, each page and export with a draft, delegated or approved badge. It uses the same frame as AuthView. See PackReviewView.

Rule of thumb for a whole-page state. A title, a subtitle and one action is StatusView (cardless). Content that needs a card to house it takes the card view for that content: AuthView (a form, or a signed-in state that shows the account and a switch-account control), CaptureView (a capture form) or DocumentView (a document). ErrorView and BoundaryView still render and are not removed, but new pages do not use them.

If a required band is not a slot on any shipped template and not one of the nine SectionedView kinds, do not flatten it into a one-item feature-grid or any other shipped kind — that produces a document that validates while the page is wrong. Register defineWebTemplate in the consumer instead; its build function maps resolved slots to Designer blocks. That registry path is the public custom-page extension.

Composing Designer blocks directly in an unregistered route file can work as a one-off, but it is a workaround: it bypasses the template registry, validateSurfaceDocument/resolveSurfaceDocument, and copy provenance for that page shape. Prefer defineWebTemplate + createWebRenderer so the page stays provable.

View vocabulary

Three words describe how a page uses a view:

  • Standard. A published Clossys view used as it ships, through its props, slots and copy references only, such as StatusView with a status, a subtitle and a link home.
  • Custom. A published view customized through its sanctioned extension points: extra slot content (a form in AuthView's form slot, a notes line), theme tokens, SectionedView section kinds and copy. It still conforms to the framework and stays inside the catalog contract. A thin wrapper that fixes some props is a custom use, not a new view.
  • Unique. A one-off view owned by a single consumer, for a page no published view can express. It is built from Clossys assets (defineWebTemplate, Designer blocks, Publisher's page layouts), checked with checkFrontDoor (and publisher-web-route-check for the route manifest), and exported from the consumer, not from a Clossys package. It is never hand-built in a route file.

All copy reaches a view as a CopyRef or as a string resolved through @clossys/writer; consumer code passes no literal copy in view props. The examples in this README use literal strings for brevity only.

Catalog direction. Every main-navigation page, the home page included, is to be one view, LandingView: a hero alone fills the screen; a hero with content below it shows a sliver of that content and a scroll hint. LandingView now ships as a sectioned page (documented below), and HeroView is the single-screen hero-only page (the view named LandingView before this release). MarketingView and SectionedView still ship as documented below; SectionedView is to stay as an internal engine rather than a catalog entry, and MarketingView folding into LandingView lands in a later release.

Pre-auth marketing pages — MarketingView first

For a pre-auth marketing landing page, use MarketingView (header, Hero with optional heroMedia and heroActions, feature grid, optional FAQ, CTA band, shell) and fill its slots. In the first viewport, ship exactly one primary CTA in heroActions; heroMedia must be the product surface or original art, not decorative stock imagery. See MarketingView below. When the page needs a Designer block that is not a MarketingView slot, import that named block in your page module. Do not flatten into SectionedView — that document assembler is not the pre-auth path.

SectionedViewDocument — Designer-independent long-page core

@clossys/publisher/core now owns the closed, data-only source model for a long public site page: SectionedViewDocument. It requires one or more ordered sections with unique lowercase fragment-safe ids and one of ten named kinds: hero, feature-grid, faq, ordered-step-sequence, status-list, stat-grid, pricing (a heading and plan items, each with a name, a price, features and an optional call to action), testimonial (quotes with an attributor name and optional role), stat (label and value items with an optional delta) or content-section (see below). Legacy kinds use the closed base/sunken/inverse ground vocabulary; canonical content sections omit ground for automatic cadence or author inverse; status values are the closed available/partial/planned readiness axis, with a separate not-offered disposition for deliberate non-capabilities. Every audience-facing label, heading, description, question, answer, ordinal, and status label is a CopyRef. There are no React nodes, render callbacks, router fields, locale overrides, classes, styles, arbitrary colours, or composition escape hatches.

This is a closed wire model at runtime as well as in TypeScript: structural objects use only enumerable own data properties (no inherited, symbol, hidden, or accessor fields), and every ordered section, item, and status-group array must be dense. A CopyRef has only its non-empty id, optional non-empty locale, and an optional plain interpolation-value record whose values are strings, numbers, or booleans. Malformed input is rejected before a custom resolver is called.

validateSectionedViewDocument reports malformed or unknown structure; resolveSectionedViewDocument resolves every CopyRef depth-first in authored order and returns its ordinary CopyResolution[]. Pass that list directly to collectCopyProvenance or existing output-manifest helpers—there is no second provenance format. A missing or empty resolution fails the entire document and names the exact authored path. An unknown section kind is refused at validate and resolve time and names that kind — there is no implicit remap to feature-grid or any other shipped kind. This core stage intentionally imports neither React nor Designer and does not render a web view. The grounded web renderer uses Designer 0.4.0's server-safe site-block API, including the separate not-offered disposition and its caller-localized labels.dispositions map, the non-hero eyebrow slot, the per-row detail slot, and the flat items alternative to StatusList's groups.

content-section and content icons

content-section is one general band with a heading, optional eyebrow, description and actions, at most one content block and one media object, and an optional footnote. This canonical contract is validated and resolved in core; web views and the content preview render its collection and media data. Published section kinds, including feature-grid, faq and ordered-step-sequence, retain their own contracts.

The frame has size: "standard" | "display" (default standard) and layout: "stacked" | "split-start" | "split-end" (default stacked). A split direction names the visual or block side; a split with neither is a header-only band. Only stacked statement bands centre their header; split bands never centre. Alignment derives from scale and layout. Hero sections accept size: "display" | "compact"; the unreleased hero compact boolean and content-section align, size: "compact" and mediaSide are refused. For existing drafts, omit align, replace compact content size with standard, and replace mediaSide with a split direction.

A block has one of three canonical types. items holds a flat list of SectionedViewItem objects; matrix holds exactly two id-bearing sides; prose holds ordered paragraphs of inline text and link spans. Items recipes expand into the same canonical axes through desugarRecipe. A recipe may omit an arrangement supplied by its fixed axes or defaults. An Items block without a recipe must name its arrangement. Explicit fixed-axis conflicts are refused with recipe-axis. SECTIONED_VIEW_CONTENT_CONTRACT exports the shared closed vocabulary and count table.

The runnable canonical example below combines these contracts with a local, approved Writer registry.

| Items arrangement | Counts and options | | --- | --- | | columns | 2 columns with 2/4/6 items, or 3 columns with 3/6 items; defaults to 3 columns | | rows | 2–8 items, 1 or 2 columns; 2 columns require an even count | | bento | 2–6 items; third, half, two-thirds and full spans fill 12-unit rows; omitted spans resolve to half | | stacked | 2–4 items | | order: "connected" | 3–5 items in columns or stacked; four without lanes emits the advisory steps-count warning | | reveal: "disclose" | Rows only, 4–6 items |

The prose recipe's configuration budget is 1–8 non-empty paragraphs, at most 24 inline spans, 6 links and 1,200 Unicode characters per paragraph. Its closed shape is { type: "prose", paragraphs }, where each paragraph is a dense, ordered array of { type: "text", text: CopyRef } or { type: "link", text: CopyRef, href } spans. Every visible span resolves through the existing Writer CopyRef path, retaining resolution provenance; missing, stale, pending or production-refused copy stops resolution with the same field-path refusal as other content recipes. Preserve spaces in the span copy: rendering joins spans without inserting separators. The character count includes visible link text and inline spaces; the limit is checked after copy resolution and again at the resolved rendering boundary. Paragraphs use the lg spacing token and ui-width-prose-max readable measure. Link hrefs use Publisher's shared sanctioned set: fragments, one-origin paths, HTTP(S) URLs without credentials, and explicit mailto links. Unknown fields, empty paragraphs, unsafe schemes, protocol-relative URLs, and over-budget data are refused at authored and resolved boundaries. Both web entries render one <p> per paragraph and inline <a> elements through the existing Frame, using Designer tokens. The Explain catalog includes the prose witness; no consumer JSX is needed. Prose is a catalog sample with no configurable axis values. Explain's shape axis retains grid-2, grid-3, bento and stack. The catalog contains 32 reader samples and 4 configuration examples, including prose among Explain's 6 samples.

The default axes are order: "none", reveal: "all", treatment: "plain" and scale: "item". Statement scale requires three columns and exactly three items. Recipes are features, statement, cards, topics, link-index, faq, steps and specimen-stack; Matrix's recipe is comparison. link-index fixes treatment to plate. steps defaults to columns and also accepts a stack; it takes optional lane definitions for two actors. The retired flow recipe is refused; migrate to steps with arrangement: "stacked". Items emphasis is refused; use scale instead. Recipes constrain optional slots as well as axes. The six unreleased raw block types feature-grid, card-grid, topic-list, ordered-step-sequence, faq and comparison are refused with their replacement recipe named. The retired module field is also refused; it has no alias.

Each item has an id and heading, with optional description, eyebrow, icon, meta, one specimen-part visual, href, bento span, lane id and badge. Item featured is refused; remove it from drafts and select a block treatment for the collection. The eyebrow resolves to at most CONTENT_ITEM_EYEBROW_MAX_CHARACTERS (32) characters. Row groups are { id, label, items }, where items contains ids referencing the flat list; every item must belong to exactly one group, and each group contains 2–4 item ids. Arrays contain only ordinary dense data items. Lanes are exactly two { id, label } objects, with known lane ids on every item in a connected block of three to five items, using columns or stacked arrangement. The first item uses the first defined lane, and both lanes appear.

For status-labelled lookup, use the Index status-list configuration with the existing topics recipe and per-item badges.

Matrix sides each have an id and heading, and either 2–4 points or one specimen panel. Labels are optional on both sides together; mixed label presence is refused. Without labels, each side heading is its semantic heading. An optional description supplies lead copy alongside points or a panel; description alone is refused. Points and a panel cannot be combined. Both sides share a primary form (points or panel), with equal point counts. featured names an existing side id. Disclosed items require a description on every item and cannot take href or visual. Recipe constraints are deeply immutable; the features recipe fixes item scale.

Media is exactly one of:

  • specimen: an accessible label and one panel, optionally an ordered key of 3–5 items targeting labeled header, part or footer slots. Key placement derives from the frame layout: at the end of a stacked band, below the panel in a split band; authored key side is refused, so remove it from drafts and let the layout place the key. A keyed figure label uses 6–25 words. Panels retain their closed fields, status rows, meter, table, schedule, shares and files parts. A status-rows part may specify mobileRows: 3; meter previous and previousText are authored together alongside the existing value, scaleMax and limit fields. The previous marker cannot exceed scaleMax.
  • record: an id, head, groups and caption, optionally columns and flag label. A group holds rows or an owner; the owner group is last. All visible copy, including owner initials, is a CopyRef. Multi-column records require a stacked frame.
  • scene: { type: "scene", label, relationLabel?, input }, where input is a closed converge, evidence-trail or approval-chain data object. Every visible input field uses CopyRefs except person initials. Structural ids, seed, formats and relationships remain data. Converge and approval scenes require a stacked frame. There is no authored sample field.

Item and panel badges are independent of meta. Their label is a CopyRef and their tone is one of neutral, success, warning, danger or info. Status tones use the same closed set; meter numbers remain finite and non-negative. Content icons use the sixteen CONTENT_ICON_NAMES, rather than authored nodes or arbitrary names.

Actions retain one or two { id, label, href, emphasis? } records, at most one primary, with primary, secondary, tertiary and quiet emphasis. Group size follows the frame independently of emphasis. A stacked band with a block or media takes no section actions; a header-only band with actions takes no footnote after them. A split takes actions in its text column. Hero actions retain their primary/secondary/quiet subset and effective primary rules. A footnote holds text, one { label, href } link, or both; an empty footnote is refused.

Item links remain all-or-none, at most six, with unique normalized sanctioned destinations. Statement items and recipes without a link slot refuse hrefs. Every CopyRef resolves through the caller's CopyResolver, retaining provenance and naming the exact path of unavailable copy. Optional fields stay absent when omitted, while canonical frame and block defaults are materialized in the resolved model. Warnings do not prevent resolution.

checkSectionVariety(document | documents[]) is an advisory page-rhythm lint over one or more documents read as one page. It returns warning findings only and never changes validation: neighbouring bands that repeat the same layout, block and visual; a split after a split on the same side or a third split in a row; a dense band right after a dense band; a specimen on an inverse ground or a second inverse body band; a second band of four or more cards; a second comparison; one block type repeated with the same treatment where that block offers treatment options (prose does not); and a second band holding a primary action (sectioned-view-variety-primary-actions): a page has one main call to action per run of bands; and a split whose block is linked and whose text column also holds actions (sectioned-view-variety-link-competing): two link systems compete for one click.

SectionedView is now the dedicated web renderer after that Designer floor is available. Resolve the CopyRef document first, retain its resolutions as the only publication provenance, and pass the resolved model directly:

Prose recipe example

This example uses a local approved Writer registry for the prose band and a leading hero. Its resolveCopy supplies both documents.

import {
  createCopyResolver,
  type CopyRegistry,
} from "@clossys/writer";
import {
  resolveSectionedViewDocument,
  type SectionedViewDocument,
} from "@clossys/publisher/core";
import { SectionedView } from "@clossys/publisher/web";

const registry = {
  id: "prose-example", locale: "en", revision: "1",
  source: { kind: "consumer", reference: "prose-example" },
  entries: [
    { id: "acme.home.heading", text: "Welcome", context: "Section example", status: "approved" },
    { id: "acme.home.description", text: "A section of approved copy.", context: "Section example", status: "approved" },
    { id: "prose.heading", text: "Reading guide", context: "Prose example", status: "approved" },
    { id: "prose.before", text: "Read the ", context: "Prose example", status: "approved" },
    { id: "prose.link", text: "writing guide", context: "Prose example", status: "approved" },
    { id: "prose.after", text: " before continuing.", context: "Prose example", status: "approved" },
    { id: "prose.second", text: "Keep source material beside the record.", context: "Prose example", status: "approved" },
  ],
} satisfies CopyRegistry;
const document = {
  id: "prose-example",
  sections: [{
    id: "reading", kind: "content-section", heading: { id: "prose.heading" },
    block: { type: "prose", paragraphs: [
      [
        { type: "text", text: { id: "prose.before" } },
        { type: "link", text: { id: "prose.link" }, href: "/guide" },
        { type: "text", text: { id: "prose.after" } },
      ],
      [{ type: "text", text: { id: "prose.second" } }],
    ] },
  }],
} satisfies SectionedViewDocument;
const resolveCopy = createCopyResolver(registry);
export const proseDocument = resolveSectionedViewDocument(document, resolveCopy);
const prosePage = <SectionedView document={proseDocument} />;

const resolved = resolveSectionedViewDocument({
  id: "acme-home",
  sections: [{
    id: "welcome",
    kind: "hero",
    ground: "base",
    heading: { id: "acme.home.heading" },
    description: { id: "acme.home.description" },
  }],
}, resolveCopy);

const page = <SectionedView document={resolved} />;
// resolved.resolutions feeds collectCopyProvenance/output-manifest helpers.

Optional fields the document may also carry

These slots are optional and additive: a document written without them validates and renders exactly as it did before they existed.

  • eyebrow on every section kind. hero always had one; feature-grid, faq, ordered-step-sequence, and status-list now carry the same optional CopyRef, so authored eyebrow copy is no longer dropped at conversion time.
  • actions on the hero section. An optional, non-empty list of { id, label, href }, where label is a CopyRef and href must be a fragment, a one-origin path, an http(s) URL, or a mailto: link. It stays data: there is no node, class, or handler slot, and the view renders underlined <a href> elements into the Designer Hero block's existing actions slot (not Button atoms — the wire model stays href-shaped).
  • media on the hero section. An optional { assetId, alt } pair: alt is a CopyRef (provenance-complete) and assetId resolves at render time through SectionedView's resolveAssetId, the same seam renderWebDocument uses for MarketingView's heroMedia. Presence switches Hero to the two-column layout (tablet:grid-cols-2). Product surface or original art only — not decorative stock.
  • stat-grid sections. A titled grid of metrics: each item carries label and value CopyRefs plus optional delta, closed trend (up | down | neutral, only with delta), and optional description. The view maps each row onto Designer Stat; do not flatten metrics into feature-grid.
  • layout on the faq section. stacked (the default) or split: the header group in the narrow text column and the rows in the wide column beside it, top-aligned. It renders the same band as a content-section with a split Items block using the faq recipe, so the two read alike on one page.
  • detail on a status-list item. An optional CopyRef carrying that row's own explanation, including the reasoning behind a not-offered answer. It renders as a second description of the same row, so the definition-list semantics stay intact.
const resolved = resolveSectionedViewDocument({
  id: "acme-trust",
  sections: [
    {
      id: "welcome",
      kind: "hero",
      ground: "base",
      heading: { id: "acme.trust.heading" },
      actions: [{ id: "contact", label: { id: "acme.trust.contact" }, href: "/contact" }],
    },
    {
      id: "posture",
      kind: "status-list",
      ground: "sunken",
      eyebrow: { id: "acme.trust.eyebrow" },
      heading: { id: "acme.trust.posture" },
      labels: {
        available: { id: "acme.status.available" },
        partial: { id: "acme.status.partial" },
        planned: { id: "acme.status.planned" },
        dispositions: { "not-offered": { id: "acme.status.not-offered" } },
      },
      groups: [{
        id: "core",
        heading: { id: "acme.trust.core" },
        items: [{
          id: "audit",
          label: { id: "acme.trust.audit.label" },
          detail: { id: "acme.trust.audit.detail" },
          disposition: "not-offered",
        }],
      }],
    },
  ],
}, resolveCopy);

At most one hero, and a status-list may go flat

Two contract rules relaxed additively, both keeping every previously-valid document valid and unchanged:

  • A hero section is now optional, and may sit anywhere. The document previously required exactly one hero section and required it to be first. It now permits zero or one — never more than one — in any position. A document with a single leading hero, the shape every document authored before this change already has, still validates and renders identically: SectionedView already assigned headingLevel={1} only to a hero at index 0 and headingLevel={2} to any other section, so a non-leading hero renders as an h2, the same fixed-outline discipline the view has always held. This unlocks a closing call-to-action band authored as its own section, and a page with no hero section at all.
  • A status-list section's groups is now optional, with a flat items alternative. Provide exactly one of groups (unchanged: one heading and one definition list per group) or items (new: the same row shape with no group at all) — the common shape for a short list with nothing to group. It renders through Designer 0.4.0's StatusList items prop as a single definition list with no group heading.
const resolved = resolveSectionedViewDocument({
  id: "acme-trust",
  sections: [
    {
      id: "core",
      kind: "status-list",
      ground: "base",
      heading: { id: "acme.trust.posture" },
      labels: {
        available: { id: "acme.status.available" },
        partial: { id: "acme.status.partial" },
        planned: { id: "acme.status.planned" },
        dispositions: { "not-offered": { id: "acme.status.not-offered" } },
      },
      items: [{ id: "audit", label: { id: "acme.trust.audit.label" }, disposition: "not-offered" }],
    },
    // A closing call-to-action band: a second hero, not first, rendered as h2.
    { id: "cta", kind: "hero", ground: "inverse", heading: { id: "acme.trust.cta" } },
  ],
}, resolveCopy);

Mounting part of a page: the landmark prop

SectionedView renders its own main landmark by default, which is right when the whole page is the document. A page that can express only some of its sections through this contract needs the other option, or its remaining content ends up outside the page's only main:

<main>
  <SectionedView document={resolved} landmark="none" />
  <ConsumerOwnedSection />
</main>

landmark="none" renders the same sections in a plain grouping element with no landmark role and no accessible name. Default behaviour is unchanged, and choosing it makes the surrounding page responsible for supplying exactly one main landmark containing this output.

Contract holds measured in issue #756

Three 0.2.1 refusals stay deliberate after the additive fixes above; they are not backlog slots waiting for a loosening pass.

  • Required labels on every status-list section. The section must carry exactly available, partial, planned, and dispositions.not-offered as CopyRefs. Designer StatusList renders human-readable readiness names from this caller-localized map, so a surface cannot mount a status list until it has merged reader-facing words for all four axes. Omit the section when the page has no readiness posture to report.
  • Required ordinal as a CopyRef on each ordered step. Ordinals participate in copy provenance and locale like every other audience field; a surface that paints step numbers visually still authors copy entries (often "1", "2") rather than relying on presentation-only counters.
  • Non-empty repeating arrays. Every items array, status-list groups array, group items array, and optional hero actions array must contain at least one entry when present — an explicit items: [] is refused (sectioned-view-items-shape / sectioned-view-status-items-shape). Unlike MarketingView's repeating bindings, this document has no separate "slot omitted" vs "slot empty" distinction at the section level: a section kind in the wire model is a commitment to render that block with at least one row. Express stated absence by omitting the section or by owning an empty state outside this contract (StructuredDocument, host markup).

The ordinary and react-server @clossys/publisher/web exports are aligned. The view owns section markup, source order, unique section ids, grounds, and the h1/h2/h3 outline; Designer owns visual tokens and block internals. Per #708, Publisher does not own locale selection, routing, or document-level html, lang, or dir attributes: the host application supplies those boundaries around this renderer.

section-header and article-body remain intentionally outside the closed SectionedView kinds: the former's action region and the latter's full structured-document rendering still need a grounded view integration to stay fully data-shaped and provenance-complete without node slots. Compose those Designer blocks in the consumer renderer or register defineWebTemplate for the page band instead of flattening into feature-grid or stat-grid.

On its own, this is a repeating-group binding primitive only — it says nothing about which template actually consumes it. web's MarketingView template (below) is that consumer, closing the second and final half of issue #166.

MarketingView — the flowed marketing template

web's template registry (listWebTemplateNames()) knows three names: AuthView, ErrorView, and MarketingView — an ordinary flowed page with a persistent header/footer, a hero, a feature grid, an optional FAQ list, and a closing call-to-action band. Like AuthView/ErrorView, it is one more explicit, nameable SurfaceDocument.template value, not a mechanism that picks one — see "Scope," above: this package still does not compose.

MarketingView's fixed slot set: brand and heroHeading and ctaHeading are required flowed text slots; heroEyebrow, heroDescription, heroActions, heroMedia (an asset slot), featuresHeading, featuresDescription, faqHeading, faqDescription, ctaDescription, ctaAction, and footerSecondary are optional flowed slots; features (required) and faq (optional) are repeating slots, each bound via a SurfaceRepeatingSlotBinding and rendered through @clossys/designer's FeatureGrid/Faq blocks respectively. An empty features group with no featuresHeading or featuresDescription omits the grid. An empty faq group (items: []) still renders its section with zero entries; a faq binding that was never authored at all omits the whole FAQ section instead, which is a different, equally valid outcome (see MarketingView's own faq prop doc comment).

faq declares two required structured fields: question and answer. Each resolves through the normal CopyRef path, so its locale, approved voice, and provenance stay visible alongside the rest of the page. A FAQ item authored as a caller-owned node, as a legacy single value, or with an unknown/missing field is refused rather than bypassing editorial governance.

import type { SurfaceDocument } from "@clossys/publisher/core";
import { resolveSurfaceDocument } from "@clossys/publisher/core";
import { renderWebDocument } from "@clossys/publisher/web";

const ref = (id: string) => ({ id });

const acmeMarketingHome: SurfaceDocument = {
  id: "acme.marketing.home",
  channel: "web",
  template: "MarketingView",
  meta: { channel: "web", title: ref("acme.brand"), description: ref("acme.hero.description") },
  bindings: [
    { slot: "brand", copy: ref("acme.brand") },
    { slot: "heroHeading", copy: ref("acme.hero.heading") },
    { slot: "heroDescription", copy: ref("acme.hero.description") },
    { slot: "ctaHeading", copy: ref("acme.cta.heading") },
    // Required repeating slot; an explicitly empty grid is valid.
    { slot: "features", items: [] },
    // A repeating slot — one CopyRef per placeholder feature, in order.
    {
      slot: "faq",
      items: [
        {
          fields: {
            question: { copy: ref("acme.faq.one.question") },
            answer: { copy: ref("acme.faq.one.answer") },
          },
        },
      ],
    },
  ],
};

const resolved = resolveSurfaceDocument(acmeMarketingHome, myCopyResolver);
const { element, head } = renderWebDocument(resolved.document, {
  groups: resolved.groups, // carries the structured FAQ fields
});

resolved.groups is exactly ResolvedSurfaceDocument.groups — pass it straight through as RenderWebOptions.groups; renderWebDocument maps each declared repeating slot's resolved items onto MarketingView's features/faq props in authored order. Passing a group for a slot MarketingView does not declare as repeating, or omitting the required features group entirely, both fail closed with RenderError("resolution-failed", ...) — the same error contract a missing/unknown single-slot binding already produces for AuthView/ ErrorView.

Rendered directly as a component, MarketingView also takes the header slots HeroView's banner uses. They are props, not template slots, so a SurfaceDocument cannot bind them:

  • headerAction (optional): the site's call to action in the banner, passed to Designer SiteHeader's actions.
  • secondaryAction (optional): a second call to action, just before headerAction.
  • nav (optional): the primary navigation, beside the brand.
  • ground ("base", "inverse" or "transparent", default "base"): the plate of both the header and the footer. "transparent" matches HeroView. The section bands keep their own *Ground props.

Each slot is absent from the markup when omitted, so a page that passes none renders exactly as before.

import { MarketingView } from "@clossys/publisher/web";

export function AboutPage() {
  return (
    <MarketingView
      brand="Example Studio"
      headerAction={<a href="/contact">Contact</a>}
      ground="transparent"
      heroHeading="About the studio"
      features={[]}
      ctaHeading="Start a project"
    />
  );
}

CaptureView, DocumentView, and CollectionView — fixed publisher page shells

These exports are direct, server-safe page shells rather than new SurfaceDocument.template registrations. They deliberately do not select content, load a CMS, own a router, or add client state.

CaptureView provides one heading, a consumer-owned form inside Designer's Card and an optional notes block below the card, as the shared page layout; the deprecated chrome props add a site header and a footer. secondaryAction stays inside the card, under the form or the confirmation. The header slots are listed under Front-door shell. The consumer owns form fields, submission, validation, and network effects. On a failed client-side submission, pass both errorSummary and errorSummaryId, focus that id, and keep the summary before the form; the view makes it a focusable role="alert". On success, pass submitted: it replaces the form in the same position in a polite live region. CaptureView intentionally does not choose a form library, add spam handling, or model submission state. formLabel is the accessible name of the region that holds the form or the confirmation, and defaults to "Capture form".

DocumentView accepts a StructuredDocument and an approved-copy resolver, then calls renderStructuredDocument itself. Inside a SiteFrame it renders the page layout shared with CaptureView and AuthView: the document title and optional summary as the header, the effective date and the article in the card, and the optional action as the notes line below it. A caller cannot supply a pre-rendered article node or skip heading and in-document-fragment validation on this path. The document title becomes the page h1; optional summary and effective-date labels remain CopyRefs. An effective date is { dateTime, text }, so its visible approved copy is paired with a semantic time value; dateTime must be a real ISO date or date-time, not arbitrary display text. Invalid document structure, unresolved fragments, missing copy, and malformed effective-date metadata fail closed with RenderError.

CollectionView supplies an accessible collection index: each non-empty entry has a unique linked title, a semantic time, optional summary and tag list, and a navigation landmark for consumer-owned pagination. Its required empty state prevents an empty index from silently becoming a blank region. Entries use a deliberately small closed shape (id, href, string title, date, optional string summary/tags); its raw props are consumer-owned view data and do not themselves carry CopyRef provenance. Consumers that need editorial provenance resolve structured fields before constructing these strings, while Publisher's structured renderer continues to retain the underlying field-level copy/asset evidence. Pagination and empty-state actions are likewise { href, label } data, not node escape hatches. Route loading, taxonomy, and paging state remain outside Publisher. If a router replaces collection items in place, it must move focus to focusTargetId (the focusable PageHeader region that contains the view's h1); ordinary links retain normal browser route focus handling. Entry, empty-state, and pagination links accept only a fragment, a single-root-relative path (never //), http(s), or non-empty mailto:; script, data, file, and protocol-relative URLs fail closed. entriesLabel (default "Collection entries"), tagsLabel (default "Tags"), and paginationLabel (default "Collection pagination") name the entry list, each entry's tag list, and the pagination landmark.

BrandGuideView names its sections with lockupLabel (default "Lockup"), downloadsLabel (default "Downloads"), colorLabel (default "Color"), typeLabel (default "Type"), specimenLabel (default "Type specimen"), and factsLabel (default "Strategy facts"). lockupSvg is optional: when it is omitted the lockup section is absent. specimen ({ text, faces }, optional) adds a type specimen that sets text in each face's font family; a face value that is not a plain family list (letters, digits, spaces, commas, quotes, dots, hyphens and underscores) keeps the text and drops the style. emptyLabel (optional) is shown in place of a list with no entries. With embedded, the guide renders inside another page's main: a div instead of a main, the title as an h3 instead of a PageHeader, and each section named by a visible h4 through aria-labelledby instead of an aria-label. PackReviewView renders its brand kit this way. SystemAuditView names its gallery link and sections with galleryLabel (default "Preview gallery"), brandSectionLabel (default "Brand file"), and contrastLabel (default "Contrast"), and its coverage status line with coverageDescription (default "Brand file coverage passed." / "Brand file coverage failed." from brandOk).

There is intentionally no EntryView. A document-backed entry page uses DocumentView, with its optional action linking back to the collection, rather than duplicating the validated document page contract. This is the narrow disposition for entry pages; it does not add CMS, parser, or taxonomy behavior. A future Designer-block integration is separately staged and is not part of these publisher shells.

HeroView

HeroView, exported from @clossys/publisher/web, is a single-screen landing page: a transparent banner, one hero centred both ways, and a transparent legal footer, over an optional backdrop. It is server-safe (it imports only Designer's /shell/server, /atoms/server and /blocks/server barrels) and ships no wording of its own: every visible word comes from a prop.

import { HeroView } from "@clossys/publisher/web";

declare const brand: React.ReactNode; // the caller's brand mark, for example a Designer `Brandmark`

export function HomePage() {
  return (
    <HeroView
      brand={brand}
      headerAction={<a href="/start">Start</a>}
      heading="A page that says one thing"
      description="One supporting line."
      legal={{ entity: "Example Co", links: [{ label: "Privacy", href: "/privacy" }] }}
    />
  );
}

Props, in addition to the standard div attributes (minus children) and style:

  • brand: the identity slot in the banner.
  • headerAction (optional): the page's call to action, rendered in the banner.
  • eyebrow, description (optional): the lines above and below the heading.
  • heading: the page's only <h1>.
  • heroAction (optional): a call to action inside the hero. It is absent by default, so the call to action is header-only unless you supply one.
  • media (optional): rendered after the description.
  • align ("center" or "start", default "center"): aligns the hero's content and changes only that wrapper's classes.
  • backdrop (optional): rendered first, absolutely positioned behind the rest, with aria-hidden="true" and pointer-events-none. It is absent from the markup when not supplied.
  • legal: the props of Designer's SiteFooter.Legal, passed straight through.
  • ground (HeroViewGround: "base" or "inverse", default "base"): the page's ground. Unlike the ground on the other views, which sets only the header and footer plate, this one sets the ground of the whole page. "inverse" paints bg-surface-inverse on the page root and gives every text role the on-inverse ink from Designer's SECTION_GROUND_CLASSES: text-ink-on-inverse on the root (which the banner inherits) and the heading, text-ink-on-inverse-muted on the eyebrow and the description. The header and footer get Designer's ground="transparent-inverse", so they stay plate-less over the backdrop and the footer's legal row takes text-ink-on-inverse-muted.
  • footer (HeroViewFooter: "transparent" or "surface", default "transparent"): the footer's plate. "surface" gives the footer Designer's ground="base" plate (bg-surface-raised with the base ink) across the full width, with a hairline on its top edge only, stacked above the backdrop. The banner and the hero are unchanged, on either ground.
import { HeroView } from "@clossys/publisher/web";

declare const brand: React.ReactNode; // the caller's brand mark, for example a Designer `Brandmark`
declare const backdrop: React.ReactNode; // a dark visual, for example a scrimmed image
declare const Link: React.FC<{ href: string; buttonVariant: "primary"; children: React.ReactNode }>; // Designer's `Link`, from its `/atoms` barrel

export function DarkHomePage() {
  return (
    <HeroView
      brand={brand}
      headerAction={
        <Link href="/start" buttonVariant="primary">
          Start
        </Link>
      }
      heading="A page that says one thing"
      backdrop={backdrop}
      ground="inverse"
      footer="surface"
      legal={{ entity: "Example Co", links: [{ label: "Privacy", href: "/privacy" }] }}
    />
  );
}

What it guarantees: one banner, one main and one contentinfo landmark; one <h1>; a backdrop that is aria-hidden and takes no pointer events; a header, and unless footer is "surface" a footer, with a transparent Designer ground, so they carry no background, border or width cap and run the full viewport width; and text that comes only from props. These hold for every ground and footer, and with both left at their defaults the markup is the same as before they existed.

What it does not do: check the contrast of the page ink over your backdrop (t