Authoring components
A component is an ordinary .html file whose root tag names a new element. Calls to that element expand at build time with full provenance. This page is the authoring contract: definition grammar, attribute passing, the slot, scope precedence, and the stock packs — every behavior shown here was verified against the expansion engine (internal/lzr/component/) or a live render of this site's own components.
Definition files
This site's components live in src/_components/ — one file per component, 33 in total. Here is one of them, complete:
<docs-callout
title="string"
tone="string|default:'green'"
>
<aside class="docs-callout docs-callout--{{ tone }}">
<strong>{{ title }}</strong>
<slot></slot>
</aside>
</docs-callout>
src://_components/docs-callout.html
- The root tag is the identity. The component's name comes from the definition's root tag, not the file name. The corpus names files after their component as a convention, but the registry is built by parsing tags.
- Attributes on the root tag are the declared contract (grammar below). Everything inside the root tag is the template.
- One file may define several components. Wrap multiple definitions in a
<components>...</components>element; without the wrapper, every top-level tag in the file is read as a definition. - Discovery is a merge chain. The registry starts from the built-in layout pack, then adds each plugin's component directory in
config.jsonorder (a plugin'splugin.jsonmay name the directory; otherwise_components/orcomponents/is auto-detected), then the directories passed to the renderer — this site's page recipes pass--components src/_components. Later definitions replace earlier ones of the same name, so a project file overrides a pack or plugin component just by reusing its name.
Declaring attributes
Each attribute on the definition's root tag declares one input as name="type|default:'value'". Both halves are optional in practice, and the type word changes only how the default is treated:
Attribute declaration behavior
Verified by live expansion of scratch definitions against the engine.
| Declaration | Behavior |
|---|---|
title="string" | Declared, no default. If the call omits it, its placeholders render as empty text. |
tone="string|default:'green'" | Missing at the call site, the default is inserted as literal text. Any type word other than the four below behaves like string. |
count="int|default:'3'", and the array, bool, float type words | The default is treated as an expression, not text: a missing flag="bool|default:'true'" arrives as {{ true }} and drives an <if> as a real boolean. Type words do not validate what callers pass — they only type the default. |
| Undeclared attribute at the call site | Still passed through and still substitutes placeholders. Declaring an attribute adds a default and empty-when-absent cleanup; it is not a filter. |
| Kebab-case names | Alias to camelCase inside the template: a caller's img-src="..." fills both {{ img-src }} and {{ imgSrc }}. |
Calling a component
There is exactly one call form: use the component's name as a custom element, self-closing or with a body.
<status-pill status="green" /> <docs-callout title="Think in ownership, not location" tone="blue"> <p>Body content becomes the slot.</p> </docs-callout>
A tag whose name is not in the registry — including a literal <component> tag — is not a call; it passes through to the output as plain HTML. There is no name-indirection form. Components nest freely: a template may call other components (this site's test-page calls brand-header), and generated output is re-expanded up to a depth cap of 32. Every expansion records provenance — the components page shows the data-hm-from attribute and the ownership model that provenance feeds.
Placeholder vocabulary
Inside a definition template, these placeholders consume the call:
Template placeholders
Every row verified by expanding real or scratch calls with the engine used by this site's build.
| Placeholder | Expands to | Real usage |
|---|---|---|
{{ name }} / {{ attr.name }} | The attribute's value. Static call values are inserted as text; a dynamic call value (title="{{ post.title }}") is inserted as its expression — including into larger expressions, so {{ tone }} works inside a class attribute and {{ detail }} works inside an <if>. | every component in src/_components/ |
{{ attr_expr.name }} | The raw expression text, for splicing into a bigger expression of your own. | stock bootstrap-carousel: items="{{ attr_expr.slides }}" |
{name} | Single-brace form for interpolating into protocol paths, e.g. {{ data://{file}.json }}. | protocol-path interpolation |
{{ slot }} or a <slot> tag | The call body (next section). | src://_components/feature-card.html |
{{ attrs }} | All pass-through attributes from the call site, HTML-escaped. Only id, class, style, data-*, and aria-* pass this whitelist; anything else (e.g. href) is dropped. | src://_components/test-page.html |
{{ restAttrs }} | Same as {{ attrs }} minus class — pairs with {{ class:... }} so the class is not emitted twice. | stock row, column |
{{ class:base classes }} | A complete class="..." attribute merging the template's base classes with the caller's class; emits nothing when both are empty. | stock container, row |
{{ style:property:attrName }} | ;property:value appended to an inline style when the attribute is non-empty; nothing otherwise. | style declarations from attributes |
{{ scope | helperName }} | A pack-provided helper computed from all call attributes. | stock column: {{ scope|mkColumnClass }} |
The class-merge and helper rows render on this site's homepage right now: src://index.html writes <row class=justify-content-center><column width=12 width-lg=7>, and the built page contains class="row justify-content-center" and class="col-12 col-lg-7".
The slot: one body per call
Whatever sits between a call's open and close tags is the slot body. Inside the definition, <slot></slot>, <slot/>, and a literal {{ slot }} placeholder are equivalent positions for it. A self-closing call has an empty body.
Slot content belongs to the calling page, not to the component — it may use the page's bindings, loops, and conditions, and it keeps call-site ownership in the source map. That ownership boundary is exactly where the scope law below applies.
Scope precedence: the collision law
A component attribute name can collide with a page binding of the same name. Which one a reference resolves to is decided by where the reference sits:
- Inside the component's own template, the declared attribute wins. The template owns the attributes it declares.
- Inside slot content, precedence is positional (ordinary linear shadowing): the attribute value stays visible until the page's own
<set>re-binds the name — after that, the set value wins. Slot content belongs to the calling page, which may legitimately bind a colliding name. - Loop re-bindings shadow loop-locally. A
<foreach>or<for>whosekey=/index=names the attribute shadows it inside the loop body only; reads before and after the loop still see the attribute, and the loop variable does not leak.
This law was settled by engine fix fbf72409; its authoritative prose lives in the templating substrate at src://docs/lzr-templating.md, and this page renders it beside its live proofs. The canary fixtures under src/scope-audit/ exercise each clause in isolation:
<scope-audit-shell mode="attr-value" label="A">
<set key="mode" value="{{ 'set-value' }}" />
<if if="{{ mode == 'set-value' }}"><p id="a-set-wins">slot set wins</p></if>
<else><p id="a-attr-wins">component attr wins</p></else>
</scope-audit-shell>
renders: data-shell-mode="attr-value" (attribute wins in the template)
id="a-set-wins" present (set wins in slot content)
src://scope-audit/a-set-vs-attr.html src://scope-audit/d-foreach-shadow.html
Canary D proves the loop clause the same way: the shell keeps data-shell-mode="attr-D" and the pre-loop read stays attr-D, while the <foreach... key=mode> rows render each item's own value. The scope map makes the shadow machine-readable — hypermake lzr scope-map on canary A reports the <set> as kind=shadow shadows=bind-1 against the component-attribute binding. The same rule renders on controls/branching.html, locked by the site-branching-truth graph canary.
Composition patterns from this corpus
Patterns in src/_components/
Each row names the component that carries the pattern on this site.
| Pattern | How it works | Component |
|---|---|---|
| Page shell with forwarded attributes | The outermost component owns the document (<html>, head, footer) and forwards its own inputs onward: <brand-header section="{{ section }}" mode="{{ mode }}">. | src://_components/test-page.html |
| Chrome-plus-slot layout | Navigation and article chrome live in the template; the page pours prose into the slot. This page is inside it right now. | src://_components/docs-layout.html |
| Variant classes from a tone attribute | A class suffix carries the variant: docs-callout--{{ tone }}, with the neutral default declared as tone="string|default:'green'". | src://_components/docs-callout.html, feature-card, metric-card, protocol-chip, tape-mini-card |
Optional detail behind an <if> | An empty-string default plus <if if="{{ detail }}"> renders the extra element only when the caller provides it. | src://_components/metric-card.html |
| Caller-owned content in a styled frame | The component owns the frame and header; the caller keeps ownership of the <table> it passes as slot content. | src://_components/evidence-table.html |
| Overriding a pack primitive | Define a component with a pack component's name and the project definition wins the registry merge. | merge rule, verified by live expansion |
Stock packs
Every render starts from a built-in layout pack, which is why <row> and <column> work on this site without a definition in src/_components/: document shells page, page-fullwidth, secure-page; class-merged wrappers container, container-fluid, row; column with breakpoint attributes (width, width-sm... width-4k) compiled to col-* classes by the mkColumnClass helper; plus bootstrap-carousel, lazy-fluid-img, and fluid-img.
An email pack ships as bundled helpers rather than definitions: when a components path matches email/components, the registry gains width/margin placeholder helpers, email-* attribute normalization, and an email-style-import element that inlines a collected style capsule. The email element definitions themselves are project components; this site does not use them.
Verify it yourself
Every claim on this page has a graph affordance behind it:
hypermake lzr scope-map src/scope-audit/a-set-vs-attr.html hypermake affected-by src/_components/docs-callout.html hypermake explain rendered/public/docs/component-authoring.html
The scope map prints the shadow bindings behind the collision law; affected-by lists every page a component edit would rebuild; explain shows this page's own recipe, including the --components src/_components registry argument every page render passes.