Chapter 56: Metadata: HTML as Application Protocol
The metadata protocol is how Kitsune components describe meaning in markup.
The protocol was introduced in Chapter 22 (architectural argument) and Chapter 42 (educational implementation). This chapter takes the protocol seriously as a production surface — the full attribute vocabulary, the validation patterns, the patterns for AI-generated UI compatibility, the production tooling.
The protocol’s central premise: the document carries the application’s meaning. The metadata attributes on elements declare what the application is doing without depending on framework-specific component models. Server-rendered HTML, Lit-authored components, React-rendered DOM, AI-generated markup — all of them can speak the protocol because the protocol is just attributes on HTML.
The Full Vocabulary
Section titled “The Full Vocabulary”Kitsune’s metadata protocol uses a consistent attribute structure. For custom elements with the kit- prefix, the attributes are bare (meta-event, meta-command, meta-intent). For plain DOM elements (or for elements without the kit- prefix), the attributes use the data-meta-* form. The metadata-boundary listener accepts both.
Action declaration:
meta-event="event.name"— declares that activating this element represents a runtime event. The boundary listener emits the event when the element is interacted with.meta-command="command.name"— declares that activating this element dispatches a command. The boundary listener dispatches the command.
An element can declare both an event and a command. Both fire when the element is activated, in event-first-then-command order.
Context decoration:
meta-intent="primary-action" | "secondary-action" | "destructive" | ...— describes the semantic role of the action. The design system can use it for styling; analytics can use it for cohort analysis.meta-entity-type="..."andmeta-entity-id="..."— override the inherited entity context for this specific element. Useful when an element acts on a different entity than its surrounding boundary.
Payload props:
meta-prop-{name}="value"— declares a property to include in the event/command’s payload. The name is camelCased (meta-prop-target-id→props.targetId).
Behavior modifiers:
meta-prop-prevent-default="true"— for form submits, tells the boundary listener to callevent.preventDefault()on the native submit. The form’s native submission is suppressed; the runtime event still fires.
Boundary attributes (collected by the context-walk):
surface="...",feature="...",entity-type="...",entity-id="...",route="...",mode="...",private— all collected by the metadata boundary’s context-walk algorithm.
The vocabulary is small enough to memorize. A team adopting Kitsune learns the protocol in an hour.
Complete Examples
Section titled “Complete Examples”A handful of realistic examples showing the protocol in use.
A simple action button:
<kit-button meta-event="profile.saved">Save</kit-button>The button fires profile.saved when activated. Modules subscribed receive the event with the surrounding boundary’s context.
A button with rich metadata:
<kit-button meta-event="checkout.started" meta-intent="primary-action" meta-prop-location="hero" meta-prop-experiment-arm="b"> Start checkout</kit-button>The button fires checkout.started. The event’s payload includes location: "hero" and experimentArm: "b". The intent helps analytics classify the action as a primary CTA.
A command-dispatching button:
<kit-button meta-command="dialog.open" meta-prop-target="help-dialog"> Help</kit-button>The button dispatches dialog.open with payload { target: "help-dialog" }. The dialog module handles the command and opens the matching dialog.
A form with declared event:
<form data-meta-event="profile.save_requested" data-meta-prop-prevent-default="true" method="POST" action="/api/profile"> <kit-text-field name="displayName"></kit-text-field> <kit-button type="submit">Save</kit-button></form>The form fires profile.save_requested when submitted. The prevent-default flag tells the boundary listener to suppress the native submit (the runtime handles the save). The form’s method and action still apply if JavaScript fails to load — the no-JS path submits normally.
A composed event with both event and command:
<kit-button meta-event="filter.applied" meta-command="search.run" meta-prop-filter-name="status" meta-prop-filter-value="active"> Apply</kit-button>The button fires both. The filter.applied event is a fact (the user applied a filter); subscribers like analytics track it. The search.run command is a request (re-run the search); its handler does the work. Both flow through the runtime’s diagnostic stream in order.
Custom Element Metadata
Section titled “Custom Element Metadata”Custom elements can declare metadata on themselves (not just on their slotted content). The custom element’s class can decorate its host element with metadata attributes that the surrounding boundary listener picks up:
class KitToggle extends LitElement { @property({ type: Boolean, reflect: true }) checked = false @property({ type: String }) eventName = ''
connectedCallback() { super.connectedCallback() if (this.eventName) { this.setAttribute('meta-event', this.eventName) } }
render() { return html` <input type="checkbox" .checked=${this.checked} @change=${this.handleChange} /> ` }
private handleChange(event: Event) { this.checked = (event.target as HTMLInputElement).checked this.setAttribute('meta-prop-checked', String(this.checked)) }}The component’s API includes an event-name attribute. The component reflects it onto its host as meta-event. The surrounding boundary listener observes the change and fires the event. The component is participating in the protocol without imposing it on its consumers.
This is the pattern for components that emit semantic events. The Kit library’s components all support optional meta-event and meta-command attributes for this purpose.
The Boundary Listener’s Behavior
Section titled “The Boundary Listener’s Behavior”The metadata-boundary listener handles several DOM events:
click — most interactive elements. The listener walks up from the target to find the nearest metadata-bearing element.
submit — forms. The listener reads the form’s metadata, collects context, and includes FormData in the payload.
change — form controls that fire change events. Some applications use change events for in-progress UI feedback; the listener can be configured to observe them.
input — form controls during typing. Usually too noisy for the default listener, but available for applications that need it.
meta:event (custom event) — for programmatic emissions. A component can dispatch new CustomEvent('meta:event', { detail: { type, payload }, bubbles: true, composed: true }) and the listener observes it. This is the escape hatch for events not tied to a native interaction.
meta:command (custom event) — same as above but for commands.
The listener is configured per-shell. Most applications use the defaults; advanced applications can extend the listener to observe additional events.
Validation Patterns
Section titled “Validation Patterns”For applications shipping AI-generated UI (Chapter 73 and Chapter 74), the metadata protocol’s vocabulary needs to be validated. Kitsune provides a schema-driven validator:
import { createMetadataValidator } from '@kitsune/core'
const validator = createMetadataValidator({ allowedEvents: new Set([ 'profile.saved', 'profile.deleted', 'checkout.started', 'checkout.completed', // ... the application's known event surface ]), allowedCommands: new Set([ 'notification.show', 'dialog.open', 'dialog.close', // ... the application's known command surface ]), allowedComponents: new Set([ 'kit-button', 'kit-dialog', 'kit-text-field', 'kit-field', 'kit-boundary', // ... the application's known component set ]), allowedIntents: new Set(['primary-action', 'secondary-action', 'destructive', 'navigation'])})
const result = validator.validate(generatedMarkup)if (!result.ok) { console.warn('Generated markup rejected', result.errors) return renderFallback()}The validator walks the markup, checks each element against the allowed component list, checks each meta-event and meta-command against the allowed-events list, checks intent values against the allowed-intents list. Anything outside the schema is rejected with a specific error.
The validator is the architecture’s defense against malicious or malformed generation. The metadata protocol’s small vocabulary makes the validation tractable.
Tooling Surface
Section titled “Tooling Surface”The protocol enables several tooling integrations:
Tests can assert metadata. expect(button).toHaveAttribute('meta-event', 'profile.saved'). The assertions don’t depend on the framework that rendered the button.
Linters can enforce conventions. A custom lint rule can require that every interactive element declares either a meta-event, a meta-command, or an explicit meta-untracked opt-out. The rule catches missing analytics coverage at PR time.
Documentation can extract the protocol. A static analysis pass over the codebase can produce a list of every meta-event value the application uses. The list becomes the analytics schema, the audit schema, and the integration documentation in one artifact.
Type generation. For TypeScript codebases, the application’s allowed-event set can generate a typed event vocabulary that the runtime uses for stronger inference. runtime.emit({ type: 'profile.saved', ... }) becomes type-checked against the application’s known events.
The tooling compounds. A team building on the protocol gets better tooling than the same team using ad-hoc analytics calls. The investment in the protocol is the investment in the tooling that follows.
Bridge to Events and Commands
Section titled “Bridge to Events and Commands”The next chapter (Chapter 57) takes the events-and-commands split seriously — the architecture’s CQRS-flavored distinction between facts and requests, the patterns for each, the production-grade error handling and tracing.
The metadata declares what an element means. The events-and-commands system is where the meaning gets handled. Together they’re the architecture’s central abstraction.
Exercise: Define Your Event Vocabulary
Section titled “Exercise: Define Your Event Vocabulary”Take an application you work on (or a Kitsune-architected application you’ve built through the book’s exercises). Enumerate its complete event and command vocabulary.
For each event:
- The name (e.g.,
profile.saved,checkout.started). - Where it’s declared in the markup.
- Which modules observe it.
- Whether it’s private or public (visible to analytics).
For each command:
- The name.
- Where it’s dispatched.
- Which module handles it.
- What its return value is.
Then build a schema for the application:
const APP_SCHEMA = { events: { 'profile.saved': { description: 'User saved their profile', private: false }, 'payment.attempted': { description: 'User attempted a payment', private: true }, // ... etc }, commands: { 'notification.show': { description: 'Show a toast notification', handler: 'notifications-module' }, // ... etc }}Use the schema to:
- Validate AI-generated markup (or hand-written markup) against it.
- Generate documentation listing every event and command.
- Generate TypeScript types for the application’s events.
- Lint the codebase for missing or inconsistent metadata.
Reflect on:
- How long was your application’s event list? (Probably 20-100 events for a medium-sized application.)
- How many of the events are new — added during this exercise — versus existing (already in the codebase)?
- Could you ship the schema as the application’s public behavior surface? (Yes — the schema is what external integrators would consume.)
- How does this compare to documenting an application’s REST API endpoints? (Same shape, different surface — the metadata schema is the application’s interaction API.)
The exercise produces a useful artifact (the application’s event and command vocabulary) and demonstrates the protocol’s role as a first-class architectural surface. The protocol isn’t ceremony; it’s the application’s interaction model, documented and enforced.