Skip to content

Chapter 59: Application 1: Settings Panel

The first application walkthrough is the simplest case — a user settings panel where the user updates their display name, language, theme, and notification preferences.

The application exercises the architecture’s basic loop. Forms, fields, validation, the metadata protocol, capability modules. Every piece is something the previous chapters built. This chapter assembles the pieces into a working application.

A Settings page with three sections:

  1. Profile — display name and email.
  2. Preferences — language, theme, notifications opt-in.
  3. Security — change password.

Each section has its own form. Saving any section emits a section-specific event. The architecture handles the routing of side effects (analytics, audit, notifications) consistently across all three.

The application’s HTML, in full:

<kit-shell name="settings-app">
<main>
<h1>Settings</h1>
<kit-boundary surface="settings-page" feature="account">
<kit-boundary surface="profile-section" entity-type="profile" entity-id="me">
<h2>Profile</h2>
<form data-meta-event="profile.save_requested" data-meta-prop-prevent-default="true">
<kit-field label="Display name" required>
<kit-text-field name="displayName" required></kit-text-field>
</kit-field>
<kit-field label="Email" required>
<kit-text-field name="email" type="email" required></kit-text-field>
</kit-field>
<kit-button type="submit" variant="primary">Save profile</kit-button>
</form>
</kit-boundary>
<kit-boundary surface="preferences-section">
<h2>Preferences</h2>
<form data-meta-event="preferences.save_requested" data-meta-prop-prevent-default="true">
<kit-field label="Language">
<kit-select name="language">
<option value="en">English</option>
<option value="es">Español</option>
<option value="ja">日本語</option>
</kit-select>
</kit-field>
<kit-field label="Theme">
<kit-select name="theme">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</kit-select>
</kit-field>
<kit-field>
<kit-checkbox name="emailNotifications" value="yes">Send me product updates</kit-checkbox>
</kit-field>
<kit-button type="submit" variant="primary">Save preferences</kit-button>
</form>
</kit-boundary>
<kit-boundary surface="security-section" private>
<h2>Security</h2>
<form data-meta-event="password.change_requested" data-meta-prop-prevent-default="true">
<kit-field label="Current password" required>
<kit-text-field name="currentPassword" type="password" required></kit-text-field>
</kit-field>
<kit-field label="New password" required>
<kit-text-field name="newPassword" type="password" required minlength="8"></kit-text-field>
</kit-field>
<kit-button type="submit" variant="primary">Change password</kit-button>
</form>
</kit-boundary>
</kit-boundary>
</main>
</kit-shell>

The structure is straightforward. Three sections, each in its own <kit-boundary> for context attribution. The security section is marked private so the password values don’t flow to analytics. Each form has a meta-event declaring its intent and prevent-default so the runtime handles submission.

The application installs a small set of capability modules.

Save orchestrator — observes the three save-request events, calls the repository, emits the corresponding success or failure event.

const settingsOrchestrator = defineKitModule({
name: 'settings-orchestrator',
dependsOn: ['profile-repository'],
events: {
'profile.save_requested': async (event) => {
const repo = runtime.inject(PROFILE_REPOSITORY)!
try {
const saved = await repo.save(event.payload.data)
runtime.emit({ type: 'profile.saved', context: event.context, payload: saved })
runtime.command({ type: 'notification.show', payload: { message: 'Profile saved' } })
} catch (error) {
runtime.emit({ type: 'profile.save_failed', context: event.context, payload: { error: String(error) } })
runtime.command({ type: 'notification.show', payload: { message: 'Could not save profile', variant: 'error' } })
}
},
'preferences.save_requested': async (event) => { /* similar */ },
'password.change_requested': async (event) => { /* similar */ }
}
})

Analytics module — observes saved events (not the requested events, which are intermediate), tracks them to the analytics provider. The private boundary marker on the security section prevents the password’s values from being captured.

Audit module — records audit entries for the profile save and password change. Preferences changes are not audit-worthy and aren’t recorded.

Notifications module — handles notification.show commands. The orchestrator dispatches a notification on success or failure.

Storage module — handles draft saving. As the user types in any field, the storage module persists a draft to localStorage. On profile.saved, the storage module clears the draft.

The modules are small. Each one has a focused responsibility. The architecture composes them into the application’s behavior.

A user updates their display name and clicks Save profile. The diagnostic trace:

[t=0] event: form.field_changed { field: 'displayName' }
- storage module observes; persists draft
[t=12s] event: profile.save_requested
context: { surface: 'profile-section', feature: 'account',
entity: { type: 'profile', id: 'me' } }
payload: { data: { displayName: 'Jeremy', email: '...' } }
- settings-orchestrator observes
[t=12s+200ms] event: profile.saved
- analytics tracks
- audit records
- storage clears draft
- notification command dispatches
[t=12s+202ms] command: notification.show { message: 'Profile saved' }
- notifications module shows the toast

The flow is two-tiered. The form fires profile.save_requested; the orchestrator handles the actual save through a command; the result fires profile.saved. The two events bookend the orchestrator’s work. The downstream modules subscribe to the success event, not the request event.

The settings panel is a good first walkthrough because it exercises several patterns clearly:

The capability/component split. The forms declare their intent; the modules handle consequences. No component imports analytics, audit, storage, or notifications.

The private boundary. The security section’s private attribute prevents the password’s value from being captured by the analytics module. The architecture’s privacy posture is encoded in markup.

The request/result event pattern. profile.save_requested is the user’s intent; profile.saved is the result. The orchestrator is the bridge. Other modules subscribe to whichever they care about.

The progressive enhancement story. The form’s action attribute (if added) makes it submit natively if JavaScript fails. The prevent-default flag suppresses the native submit when JavaScript is available. Both paths work.

The boundary inheritance. The outer boundary supplies feature: 'account'. The inner boundaries add surface and entity context. Each event’s context flows from the markup structure.

A representative integration test:

test('saving the profile section triggers the right modules', async () => {
const { shell, runtime, tracked, recorded } = await createTestShell({
modules: [
profileRepositoryMock(),
settingsOrchestrator,
analyticsModule({ provider: trackingMock }),
auditModule({ record: auditMock })
],
template: profileSectionTemplate
})
setText(shell, 'displayName', 'Jeremy')
click(shell, 'button[type=submit]')
await waitForEvent(runtime, 'profile.saved')
expect(tracked).toHaveLength(1)
expect(recorded).toHaveLength(1)
await shell.stop()
})
test('the security section is private — password not in analytics', async () => {
const { shell, runtime, tracked } = await createTestShell({
modules: [settingsOrchestrator, analyticsModule({ provider: trackingMock })],
template: securitySectionTemplate
})
setText(shell, 'newPassword', 'super-secret-value')
click(shell, 'button[type=submit]')
await waitForEvent(runtime, 'password.changed')
expect(JSON.stringify(tracked[0])).not.toContain('super-secret-value')
await shell.stop()
})

The tests are direct. The architecture’s separation of concerns shows up in the test structure — each module’s behavior is verifiable independently, and the integration tests verify the composition.

A few things the settings panel doesn’t exercise that later applications will:

Async data loading. The forms in the settings panel use values that are server-rendered or empty. The next application (the profile editor) loads the user’s data on mount.

List management. No multi-item interactions. The admin table application handles lists.

Real-time collaboration. No multi-user state.

AI generation. No generated UI. The mini prompt workbench in Chapter 63 is the AI-flavored application.

Each application in the next chapters introduces specific patterns. By the end of Chapter 63, the reader has seen the architecture handle five distinct shapes.

Chapter 60 takes the next application — a profile editor with async data loading, the repository pattern, and adapter mapping. The settings panel’s static-form pattern extends naturally to async data; the architecture’s data layer (Chapter 43) is what makes the extension straightforward.

Implement the settings panel from this chapter. Use @kitsune/core and @kitsune/components.

The complete application:

  1. The markup from the chapter.
  2. The orchestrator module that handles the three save-request events.
  3. Capability modules: analytics, audit, notifications, storage (drafts).
  4. A repository mock for the profile and preferences data.

Then verify the architecture:

  1. Submit the profile form. Trace the events.
  2. Submit the preferences form. Trace the events.
  3. Submit the security form. Verify the password value never appears in the analytics trace.
  4. Disable JavaScript. Submit a form. Verify the native submit still works (with appropriate action attribute).
  5. Use the debug overlay. Walk through the flow visually.

The settings panel is the simplest application that exercises the full architecture. The patterns it establishes — form-as-event, orchestrator-as-bridge, modules-as-consequences — repeat across every application Kitsune supports.