@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
Maintainers
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/publisherThis 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.jsonThe 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:
- Unrecorded release — something reached an audience with no matching immutable publication record.
- 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-checkcompares against. - judge —
assessVerifiedPublicationRate()for the charter metric;publisher-record-checkfor drift, append-only violations, and join-key gaps on declared ledger entries. - act — append an immutable
PublicationEntrywhen something ships; remediate drift findings and missing join keys; retract or supersede entries when a release is withdrawn. - learn — repeated
indeterminateorviolatedassessments, 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 ispublisher-rate-check.@clossys/publisher/core— canonicalSurfaceDocumentcontract, 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-modelSectionedViewrenderer. Under React'sreact-serverexport 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,useAnalyticsAllowedanduseConsentStatus. Underreact-serverit 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 thedevelopmentcondition; every other condition, andreact-server, resolves a module that throws at import.@clossys/publisher/consent-copy— server-only consent copy resolution,resolveConsentCopy. Under thebrowsercondition 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 underclossys/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'sCard, rendered chrome-free inside aSiteFrame; 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.secondaryActionis the deprecated name ofnotesand renders in the same place; passing both throws. OptionalnoAccountPromptandrequestAccessLabelhold 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 eachnotesline as one element: text plus a link in one fragment splits onto two lines. When the sign-in provider is unavailable,isDisabledkeeps the form on screen inside a disabled<fieldset>with the typed values still shown; the explanation goes in the form's ownsubmitError, and a retry link innotesstays enabled. SwitchingisDisabledremounts the form, so the site keeps the typed values in its own state. The content column uses the--ui-width-form-maxform measure, anddescriptionis a required prop so every step decides on a supporting line. The form slot is filled with Designer'sForm/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,createClerkSignInPagein@clossys/bouncer/providers/clerk/web/servernames the missing settings there, never their values), and the page shows only the user-facing unavailable message.internalNoteis 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'sfooterSecondaryholds a legal row only, never a locale switcher, because auth pages are single-locale.SignInFormfills the form slot of a sign-in page. The header slots are listed under Front-door shell.ErrorView(deprecated; useStatusView, orAuthViewwhen 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, andactionholds one primary control: a secondary destination is a text link inside the description.BoundaryView(deprecated; useStatusView, orAuthViewwhen the state needs a card) — a boundary page: the page header block (status,title,description), theactionin aCardand thenotesbelow it. Inside aSiteFrameit renders that content only; passing any deprecated chrome prop (brandis optional) selects the legacy page, which adds Designer'sSiteHeader(brand) andSiteFooter(footerSecondary, optional). It takes everyErrorViewPropskey, 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:statusas the one<h1>(a code or a short title), plus optionalsubtitle,actionandnotes. Chrome-free only. It replacesErrorViewandBoundaryView.PackReviewView— the dev-only review index: the pack's strategy brief, brand kit (throughBrandGuideView) 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 adraft,delegatedorapprovedbadge. It uses the same frame asAuthView. SeePackReviewView.
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
StatusViewwith 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,SectionedViewsection 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 withcheckFrontDoor(andpublisher-web-route-checkfor 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 onepanel, optionally an orderedkeyof 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 keysideis 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 specifymobileRows: 3; meterpreviousandpreviousTextare authored together alongside the existingvalue,scaleMaxandlimitfields. The previous marker cannot exceedscaleMax.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 }, whereinputis a closedconverge,evidence-trailorapproval-chaindata 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.
eyebrowon every section kind.heroalways had one;feature-grid,faq,ordered-step-sequence, andstatus-listnow carry the same optionalCopyRef, so authored eyebrow copy is no longer dropped at conversion time.actionson the hero section. An optional, non-empty list of{ id, label, href }, wherelabelis aCopyRefandhrefmust be a fragment, a one-origin path, anhttp(s)URL, or amailto:link. It stays data: there is no node, class, or handler slot, and the view renders underlined<a href>elements into the DesignerHeroblock's existingactionsslot (notButtonatoms — the wire model stays href-shaped).mediaon the hero section. An optional{ assetId, alt }pair:altis aCopyRef(provenance-complete) andassetIdresolves at render time throughSectionedView'sresolveAssetId, the same seamrenderWebDocumentuses forMarketingView'sheroMedia. Presence switchesHeroto the two-column layout (tablet:grid-cols-2). Product surface or original art only — not decorative stock.stat-gridsections. A titled grid of metrics: each item carrieslabelandvalueCopyRefs plus optionaldelta, closedtrend(up|down|neutral, only withdelta), and optionaldescription. The view maps each row onto DesignerStat; do not flatten metrics intofeature-grid.layouton the faq section.stacked(the default) orsplit: 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 acontent-sectionwith a split Items block using thefaqrecipe, so the two read alike on one page.detailon a status-list item. An optionalCopyRefcarrying that row's own explanation, including the reasoning behind anot-offeredanswer. 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
herosection 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:SectionedViewalready assignedheadingLevel={1}only to a hero at index 0 andheadingLevel={2}to any other section, so a non-leading hero renders as anh2, 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-listsection'sgroupsis now optional, with a flatitemsalternative. Provide exactly one ofgroups(unchanged: one heading and one definition list per group) oritems(new: the same row shape with no group at all) — the common shape for a short list with nothing to group. It renders through Designer0.4.0'sStatusListitemsprop 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
labelson everystatus-listsection. The section must carry exactlyavailable,partial,planned, anddispositions.not-offeredasCopyRefs. DesignerStatusListrenders 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
ordinalas aCopyRefon 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
itemsarray, status-listgroupsarray, groupitemsarray, and optional heroactionsarray must contain at least one entry when present — an explicititems: []is refused (sectioned-view-items-shape/sectioned-view-status-items-shape). UnlikeMarketingView'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 DesignerSiteHeader'sactions.secondaryAction(optional): a second call to action, just beforeheaderAction.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"matchesHeroView. The section bands keep their own*Groundprops.
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, witharia-hidden="true"andpointer-events-none. It is absent from the markup when not supplied.legal: the props of Designer'sSiteFooter.Legal, passed straight through.ground(HeroViewGround:"base"or"inverse", default"base"): the page's ground. Unlike thegroundon the other views, which sets only the header and footer plate, this one sets the ground of the whole page."inverse"paintsbg-surface-inverseon the page root and gives every text role the on-inverse ink from Designer'sSECTION_GROUND_CLASSES:text-ink-on-inverseon the root (which the banner inherits) and the heading,text-ink-on-inverse-mutedon the eyebrow and the description. The header and footer get Designer'sground="transparent-inverse", so they stay plate-less over the backdrop and the footer's legal row takestext-ink-on-inverse-muted.footer(HeroViewFooter:"transparent"or"surface", default"transparent"): the footer's plate."surface"gives the footer Designer'sground="base"plate (bg-surface-raisedwith 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 eitherground.
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
