Definition to expansion

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:

A complete component definition lzr
<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.json order (a plugin's plugin.json may name the directory; otherwise _components/ or components/ 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.

DeclarationBehavior
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 wordsThe 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 siteStill passed through and still substitutes placeholders. Declaring an attribute adds a default and empty-when-absent cleanup; it is not a filter.
Kebab-case namesAlias 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.

Call forms, from this site's pages lzr
<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.

PlaceholderExpands toReal 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> tagThe 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> whose key=/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:

Canary A — set vs attribute, and what it renders lzr
<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.

PatternHow it worksComponent
Page shell with forwarded attributesThe 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 layoutNavigation 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 attributeA 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 frameThe 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 primitiveDefine 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:

Component verification commands shell
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.