Components & islands
In this chapter you add two interactions to the product page — a size-guide popover and a "save
for later" button — written as one TSX component. You'll see the compiler decide, per
interaction, what actually ships to the browser, and you'll test both behaviors from strings.
Step state: site/tutorial/steps/02-islands/.
Write the component#
Kovo ranks every interaction on a fixed ladder and uses the lowest layer that works. L0 is
platform behavior — popovers, dialogs, <details> — costing zero JavaScript. L1 is a pure
client island whose handler module loads on first interaction. You don't pick the layer; the
compiler does. You write TSX:
export const ProductActions = component({
state: (): ProductActionsState => ({ saved: 0 }),
render: (_queries: Record<string, never>, state: ProductActionsState) => (
<product-actions>
<button type="button" onClick={() => document.getElementById('size-guide')!.togglePopover()}>
Size guide
</button>
<div id="size-guide" popover="auto">
<p>Kettle height 24cm, base diameter 12cm.</p>
</div>
<button
type="button"
style={productActionStyles.saveButton}
onClick={() => {
state.saved += 1;
}}
>
Save for later
</button>
</product-actions>
),
});Three things to notice in what you wrote — and didn't:
- No stamps. You never hand-write
kovo-c,kovo-state, or binding attributes; the compiler derives them. Hand-writing one is a lint warning, and one that disagrees with the typed expression it wraps is a compile error. stateis a typed, serializable fact. TheJsonValueconstraint makes unserializable state a compile error: island state lives in the document, not in a JavaScript heap, so it has to survive serialization.- Two closures, two fates. The compiler proves your size-guide closure equivalent to a
platform invoker and emits
popovertargetattributes instead of JavaScript. Your save closure becomes a named export in a per-component client module that loads on first click. To see what got emitted, Compiler internals shows real captured output; the served page below tells you what you need here.
Render it into the page#
The app renders the authored TSX component into the product page. The compiler may emit transient generated artifacts for inspection during a build, but those are outputs to verify, not source you author, commit, or import by hand:
export const productRoute = route('/products/:id', {
access: publicAccess('tutorial storefront browsing'),
params: s.object({ id: s.string() }),
page({ params }) {
const product = catalog.find((item) => item.id === params.id);
if (!product) return notFound();
return (
<html>
<head>
<title>{product.name} · Kovo Shop</title>
</head>
<body>
<main>
<h1>{product.name}</h1>
<p>{formatPrice(product.unitPrice)}</p>
<ProductActions />
<a href="/">Back to the shop</a>
</main>
</body>
</html>
);
},
});The step's first test reads the served HTML the way you'd read it in the Elements panel: the popover wired as plain attributes, the handler as a full URL plus a named export, and the island's state right there in the markup. Names are load-bearing, so minification can't mangle them:
it('serves the island as self-describing attributes, zero eager JS', async () => {
const response = await renderProductRoute('p1');
const html = bodyText(response.body);
// L0: the size-guide closure was proven equivalent to a platform invoker
// and lowered to attributes — no JavaScript ships for it.
expect(html).toContain('popovertarget="size-guide"');
expect(html).toContain('popovertargetaction="toggle"');
// Island state is serialized in the markup, not hidden in a JS heap.
expect(html).toContain('kovo-state="{"saved":0}"');
});Someone who has never seen this codebase can answer "what does this button do?" from devtools alone — the answer is an attribute, not a stack trace through framework internals.
Run the handler without a browser#
Handlers are named exports with the signature (event, ctx). The loader's job — delegate the
event, import the module, invoke the export, persist state — is mechanical, so the test does
exactly what the loader does, against the real emitted handler module:
it('runs the named handler export against island state without a browser', async () => {
const root = mkdtempSync(join(tmpdir(), 'kovo-tutorial-compile-'));
try {
const { lowered } = compileProductActions(root);
const element = new FakeElement({
'kovo-state': attributeFrom(lowered, 'kovo-state'),
'on:click': attributeFrom(lowered, 'on:click'),
});
const importedUrls: string[] = [];
const importModule = async (url: string) => {
importedUrls.push(url);
return {
ProductActions$button_click(_event: unknown, context: { state: { saved: number } }) {
context.state.saved += 1;
},
};
};
await dispatchTutorialEvent({ target: element, type: 'click' }, importModule);
await dispatchTutorialEvent({ target: element, type: 'click' }, importModule);
expect(importedUrls[0]).toContain('/c/__v/');
expect(importedUrls[0]).toContain('/tutorial/steps/02-islands/');
expect(element.getAttribute('kovo-state')).toBe('{"saved":2}');
} finally {
rmSync(root, { force: true, recursive: true });
}
});Two clicks, state 0 → 2, persisted back into the kovo-state attribute. Note what loaded when:
nothing at page load, the module on first interaction. Zero JS before interaction is the default
the markup declares, not an optimization you turn on.
Read the compiler's diagnostics#
The step's last test compiles the authored source and pins the diagnostics: the anonymous save closure earns a naming nudge, and the popover substitution is recorded:
it('checks the authored TSX through the public kovo compile command', () => {
const root = mkdtempSync(join(tmpdir(), 'kovo-tutorial-compile-'));
try {
const { lowered, output } = compileProductActions(root);
expect(output).toContain(
'WARN KV210 file="site/tutorial/steps/02-islands/src/components/product-actions.tsx"',
);
expect(output).toContain('SUMMARY artifacts=2 diagnostics=1');
expect(lowered).toContain('popovertarget="size-guide"');
expect(lowered).toContain('popovertargetaction="toggle"');
expect(lowered).toMatch(
/on:click="\/c\/__v\/[0-9a-f][0-9a-f-]*\/(?:site\/)?tutorial\/steps\/02-islands\/src\/components\/product-actions\.client\.js#ProductActions\$button_click"/,
);
} finally {
rmSync(root, { force: true, recursive: true });
}
});Every Kovo diagnostic shows what would have been generated, why it can't be, and the fix menu. The reading kovo check guide tours the diagnostic registry.
Next#
You now have a free popover, a lazy island, and tests proving both from strings. Next: real data — queries, and the bindings the compiler derives from them.
Spec & diagnostics
Interaction ladder and lowest-layer rule: SPEC §7. Compiler-derived stamps: SPEC §4.1, §4.8;
hand-written stamp is KV223, a stamp that disagrees with its typed expression is KV222.
Serializable island state: SPEC §4.1. Popover lowering and naming nudge: SPEC §5.2 rule 4,
KV210. Generated lowered IR is a verification artifact, not app-authored source: Constitution
#3. Legible served HTML and load-bearing names: SPEC §4.2, rules/v1-acceptance.md, Constitution
#1. Handler signature and lazy load: SPEC §4.3, §4.4.