15 KiB
Blocks
A block is a named composition of canon components performing a page
function — a sticky site header, a hero, a pricing section, an app shell.
It sits ABOVE the UIX layers and OUTSIDE the component canon, at
src/uix/blocks/ (alias $blocks). It consumes the framework; the framework
never references it. The live inventory is the src/uix/blocks/ tree — never
a list in a doc.
Blocks exist so that assembly knowledge does not dilute the canon: the catalog's value is contract density (events, ARIA, keyboard, tokens other layers consume); a block's value is correct assembly — layout, landmarks, heading hierarchy, responsive behavior, content slots. A block brings no new primitives; it brings the right way to put existing ones together.
The tier map, for orientation:
| Tier | Value | Contract | Enters through |
|---|---|---|---|
Canon (src/uix/) |
contract density others consume | morfo + acceptance matrix | the 9-phase route (building-a-component.md) |
Packs (src/packs/) |
parameterized leaf decoration | P contract (packs.md) |
packs:check |
Blocks (src/uix/blocks/) |
page-function composition | B contract (below) | blocks:check |
The admission rule (canon vs block)
Canon = anything with contract surface — a real event, a state machine, a
data-*attribute CSS needs to select, a widget a11y obligation. It is built FIRST as a canonical component through the 9-phase route; the block then composes it.Block = composition only. If building a block reveals a missing behavior, that is a gap to flag and promote — never a privilege to grow inside the block.
What a block DOES own semantically: landmarks and document structure
(header/nav/main/aside/footer, heading hierarchy, skip links, region
labels). Components cannot know page context; blocks can. That is their only
a11y surface of their own.
A block MAY hold local view-state (the active tab, a monthly/yearly toggle) through the public props/events of the components it composes. What it may NOT do is materialize that state with DOM/CSS of its own that would need contract — that is the admission rule firing.
Coordination: a block owns its section's state, shape and words
Decision of 2026-07-31, from the tier's own evidence: «if in the end it is a list of components… the block is coordination, state, and data too».
Composition alone is not a floor high enough. When the state of a section lives
OUTSIDE the block, every consumer re-derives it at the point of use — each
disabled is its own expression — and the result is holes: an action that
cannot be taken and nothing on screen saying why. That is not a block; it is a
grid with instructions.
So a block that has a real state — one where something can be blocked, in flight, or done — owns three things:
- The state of its section, as ONE derived machine, exhaustive by
construction. Its parts READ it (through context); none recomputes it and
none invents a
disabledof its own. Exhaustive means aRecord<State, …>that forces a sentence for every blocked state — a mute state becomes impossible to add, instead of merely discouraged. - The default shape of its data — the schema the section asks for. A contact section that cannot say what a contact form asks is a grid, not a block. The app replaces it by passing its own.
- The words of the states it owns, as idlangrefs through the translator
(
#?blocks.<block>.<key>|English fallback) — the same door the canon uses for its owntexts:. Register nothing and the English fallback shows; an app takes the words over by registering theblocksnamespace in its langs schema.
This does not apply to every block. Most are layout: hero, cta,
feature-grid, feature-split, stats-band, testimonials, faq,
content-section place content and own nothing. There is nothing to
coordinate there, and inventing state for them would be the opposite mistake.
The rule fires where a section can be blocked or in flight — contact is the
worked example (src/uix/blocks/contact/state.ts).
The admission rule still governs: coordination is derived state in TypeScript,
not new DOM or CSS. The moment a block wants a data-* attribute for CSS to
select, or an event with a semantic family, that is canon surface and it is
promoted — not grown inside the block.
Hard boundaries
- Dependency direction is one-way:
src/uix/blocks/*may import$uix,$adom, the public arts and$libs/forms(the door tocreateForm, which the canonFormdoes not re-export and soma consumes the same way — sanctioned 2026-08-05, when the audit found the contract and the code disagreeing aboutcontact); nothing in the canon (src/uix/{morfo,soma,sema,eidos,active-uix,langs}) nor insrc/{arts,libs,packs}may import fromsrc/uix/blocks/. Deleting the tier must leavenpm run checkgreen — blocks is a tier undersrc/uix/, not a fifth layer. - No morfo, no matrix: a block declares no morfo, ships no sema pack,
and takes no row in
component:audit. Its quality floor is the B contract below, guarded byblocks:check. - Layout-components-first: layout is composed from the canon layout
components (
Container / Section / Stack / Flex / Grid / AutoGrid / Wrap / Group / Separator / AspectRatio / Surface) and their props. A block ships no.cssfile; a scoped<style>is the justified exception, never the pattern. - Blocks are removable by construction — an app that never imports a block pays zero bytes for it.
The B contract (the block quality floor)
Guarded mechanically by npm run blocks:check (self-testing: the guard
asserts its own detectors against inline fixtures on every run).
| B | Obligation |
|---|---|
| B-1 | No morfo, no sema pack, no audit row. Behavior with contract surface is promoted to canon BEFORE the block composes it. |
| B-2 | Every interactive element is a canon eidos component (Button, Link, Field, …). Raw interactive natives (button/input/select/textarea/a) are an error. (Single exception: the already-rendered HTML that Prose receives — that content belongs to the app.) |
| B-3 | Composed components are consumed AS-IS through their public props (variant/size/color/…). Re-styling their internals from the block (CSS or style=) is forbidden. Colors are always roles/tokens via props. |
| B-4 | One-way imports (hard boundary 1). Deleting the tier leaves check green. Blocks do not import each other (see B-10). |
| B-5 | Content enters by composition (children/snippets) — never root={tree} data-tree props (items={...} only where the composed canon component is already data-driven). A coordinating block MAY ship the default shape of the data it coordinates (a schema), which the app replaces by passing its own — a shape is not content. |
| B-6 | Responsive via the framework's mechanisms (responsive props of the layout components, canonical breakpoints). No matchMedia/listeners of its own — needing to observe something is the admission rule firing. |
| B-7 | Everything the APP says arrives as children/props — a block writes none of it. Amended 2026-07-31: a block DOES own the words of the states it coordinates (and only those), as idlangrefs with an English fallback resolved through the translator (#?blocks.<block>.<key>|…). The reason a state names itself is the same reason it exists: the record that blocks the action writes the sentence, so it cannot go silent. |
| B-8 | Correct landmarks: sectioning element + aria-label/aria-labelledby where landmarks repeat; heading hierarchy coherent and documented in the block README (which level it emits, how to adjust). |
| B-9 | Every block ships README.md (Function · Composition map — which canon components, which props — · Decisions · Gaps-with-disposition) and a live demo page under web/routes/blocks/{kebab}/, built on the shared demo shell (web/routes/blocks/_lib/BlockDemo.svelte): the block is shown FULL-BLEED on the page — never inside a padded frame or a scroll box, which would change what it does — with the device widths served by its own preview route. Anatomy in src/uix/blocks/README.md. |
| B-10 | A block does not import another block. Shared structure is either a canon component or a conscious duplication (recorded in Gaps). Declared exception: the shells (app-shell, docs-shell) compose F1 pieces and blocks by design — allow-listed in blocks:check. |
| B-11 | Motion only through the composed components' motion props/presets or Cascade for entrance choreography. No @keyframes/transitions of its own. |
Conventions
- API: compound component with nested parts (
<SiteHeader>/<SiteHeader.Nav>/<SiteHeader.Actions>); content always via children. - Demos:
web/routes/blocks/{kebab}/+page.svelte, indexed by the gallery atweb/routes/blocks/. - Services: a block wires theme, transport and submits through
handlers/props from the app — it consumes no
uix.prefs, no storage, no http. Two services are sanctioned, and only for the block's own state words (B-7): the translator (ActiveEidos.require().langs), which resolves them, and — since 2026-08-06 — the announcer (uix.announce), which delivers them to assistive tech. The second entered for the same reason as the first: a block that OWNS the words of its states has to be able to say them, or owning them is half a job. Measured case:Contact.Reasonrendered the sentence that explains a blocked send and nobody who could not see it ever received it. The alternative — a live region of the block's own — re-implementsAnnounce, the system's live-region pair, and puts a second announcer on the page. Anything beyond these two is the app's, or the admission rule firing.
The promotion path (lived)
The tier was bootstrapped the way the admission rule prescribes: the eight
behaviors the first blocks needed — sticky (stuck-state affix),
anchor-nav (scrollspy TOC), empty-state, result, callout, prose,
nav-tree (data-driven navigation tree), sidebar — were identified as
contract surface and routed to the canon first, each through the full
9-phase route; the blocks compose them. Any
future block-shaped need with contract surface follows the same path. The
execution record (phases, per-item fiches, signed D-BLK decisions) lives in
process/PLAN-blocks.md — process, not
doctrine; this document is the standing truth.