Saved responses, progress, and other committed work belong to Sources. Device preferences and draft recovery are disposable: clearing them does not remove saved work.
The host stores Sources on disk or in a browser's IndexedDB repository; ordinary Views admit and read them in either environment.
Saving a response, progress, or an attachment requires an admitted Actor and a writable Home. Use the local account setup for development, then configure credentials and persistent writable storage for shared hosting. A browser publication provides a local Actor and writable Home on one device without sign-in; its saved files do not synchronize to a server. An ordinary static export does not provide a writable Source host. Disposable drafts and device settings are separate from committed work.
The Home directory's canonical index Source owns its privacy claim with
access: { type: home, owner: /canonical/actor.id }. The Actor Source itself does
not carry a home field. State writers resolve the current claim through the
installed access policy, so a missing or Home-free Actor cannot create personal
State accidentally.
Install Completion in the project root:
bun add @pathmx/completion@beta
Create plugins/personal.plugin.ts:
import { createInputPlugin } from "@pathmx/core/plugins"
import { createCompletionPlugin } from "@pathmx/completion"
const input = createInputPlugin()
const completion = createCompletionPlugin({
storage: "actor",
derived: [input.completion],
})
export default [input.plugin, completion.plugin]
Use the normal Input controls and Completion components. Configure a browser publication with a local Actor and writable Home. The publication runs the same Source Actions and reads as a server, without a sign-in or Action server. Saved work stays in that browser and works offline after installation; it does not synchronize across devices.
The host owns the repository name, local Actor, and Home. Keep those stable across
publication updates. Domain Plugins do not select a separate browser persistence
mode. Source-shared Completion uses storage: "source" to edit authored tasks;
storage: "actor" keeps each reader's progress in their Home.
A browser publication downloads its Sources and selected runtime Plugins. Ordinary static export remains suitable for reading and disposable browser interactions; committed personal work requires a host that can write Sources.
In a browser publication, include:
<x-browser-files-tools />
The controls export, import, or reset the configured personal folder. The archive preserves Markdown Sources and binary files, excludes published files, and belongs to that named browser repository. Import validates its folder and file entries before atomically replacing saved files; other tabs refresh from the new Sources. Archives are limited to 64 MB and 10,000 files. Export a copy before resetting or replacing your work. Browser storage is not a remote backup.
Draft recovery and device preferences are separate from this archive. Clearing those disposable records does not erase a submitted response or completed task.
Install @pathmx/ui@beta and its React peer dependencies:
bun add @pathmx/ui@beta react@^19 react-dom@^19
Create plugins/settings.plugin.ts:
import { createSettingsPlugin } from "@pathmx/core/settings"
import SettingsUIPlugin from "@pathmx/ui/settings"
export default [
createSettingsPlugin({ namespace: "my-site" }).plugin,
SettingsUIPlugin(),
]
Add <x-settings-ui-trigger /> to a page or layout. The UI reads setting schemas
supplied by installed Plugins, including Theme and Player. A root index Source is
required. Use a stable site namespace to keep independent sites' overrides separate.
These lightweight device overrides work in ordinary static exports. They survive reloads when browser storage permits; if persistence fails, the current document retains the preference in memory. Resetting or losing them restores authored/default preferences. Account-level preferences should be modeled as Sources.
Use the Settings setup above. Run bun add zod, then create
plugins/reading/index.plugin.ts:
import { definePlugin } from "@pathmx/core"
import { z } from "zod"
export default definePlugin({
id: "reading",
schema: {
settings: {
personal: z.object({ compact: z.boolean().default(false) }),
},
},
async documentAssets(ctx) {
await ctx.addBrowserBundle(new URL("./client.ts", import.meta.url), "reading-settings")
},
})
Create plugins/reading/client.ts:
import { browserSettings } from "@pathmx/core/settings/browser"
import { mountPathMXBehavior } from "@pathmx/core/controls/browser"
mountPathMXBehavior("[data-reading-preferences]", (root, { signal }) => {
const settings = browserSettings()
const definition = settings?.definition("reading")
if (!settings || !definition) return
const refresh = () => {
const current = settings.read(definition)
const input = root.querySelector<HTMLInputElement>("input")
const sample = root.querySelector<HTMLElement>("[data-reading-sample]")
if (input) input.checked = current.value.compact === true
if (sample) sample.style.lineHeight = current.value.compact ? "1.4" : "1.9"
}
root.addEventListener("change", (event) => {
if (!(event.target instanceof HTMLInputElement)) return
const current = settings.read(definition)
settings.set(definition, { compact: event.target.checked }, { revision: current.revision })
refresh()
}, { signal })
return { update: refresh, dispose: settings.subscribe(definition, refresh) }
})
Create paths/preferences.page.md:
# Reading preferences
<div data-reading-preferences>
<label><input type="checkbox" /> Compact line spacing</label>
<p data-reading-sample>
Give each idea room to breathe.<br />
Compare these lines at both settings.<br />
Choose the spacing you prefer.
</p>
</div>
Run bun run dev, open /preferences.page, and switch Compact line spacing on.
The three sample lines move closer together. Reload: the checked preference and
spacing return when browser storage is available. Open another tab on that page
and change the preference to see subscribed readers update. Losing disposable
storage restores the default spacing; no submitted work is stored here.
browserSettings() returns undefined without Settings. A stale revision is
rejected rather than silently replacing another tab's change. The browser module
applies the setting; declaring a schema alone does not change the interface.
A setting that changes layout should be correct before the page appears. List
those fields under document in plugins/reading/index.plugin.ts:
schema: {
settings: {
personal: z.object({ compact: z.boolean().default(false) }),
document: ["compact"],
},
},
Settings now writes the saved value, or the default, to <html> before the body
renders: <html data-reading-compact="true">. Style from that attribute in a
stylesheet attached to the page or its layout:
:root[data-reading-compact="true"] [data-reading-sample] { line-height: 1.4; }
Remove the line-height assignment from client.ts; the checkbox still saves the
preference. Reload with Compact line spacing on: the sample lines are close
together in the first frame. The attribute is data-<plugin>-<field>, with
camelCase fields in kebab case. Only boolean and string-enum fields can be listed.
A saved value outside the schema shows the default. Changes in this tab or
another tab update the attribute immediately, and exported static pages behave
the same way.
A Markdown component can change a setting without a browser module:
on("button", "click", async () => {
const { compact } = await settings.get("reading")
await settings.set("reading", { compact: !compact })
})
Start with the local Ada account.
Create plugins/bookmarks.plugin.ts with the following definition.
defineActorState from @pathmx/core/state describes one domain's readable
State data and creates an isolated index for each Plugin instance:
import { defineComponent, definePlugin, moduleRef } from "@pathmx/core"
import { defineActorState } from "@pathmx/core/state"
const Bookmarks = defineActorState({
plugin: "bookmarks",
read(source, app) {
if (typeof source.data.saved !== "boolean") {
return app.invalid(source, "Bookmark State requires a saved boolean.")
}
return { saved: source.data.saved }
},
})
export default function BookmarksPlugin() {
const states = Bookmarks.create()
return definePlugin({
id: "bookmarks",
requires: ["state"],
components: [
defineComponent({
tag: "x-bookmarks-status",
load({ page, view }) {
const record = view.principal.type === "actor"
? states.resolve(view.principal.actor, page.id)
: undefined
return record?.data.saved === true
},
render: moduleRef(() => import("./bookmarks.render.ts")),
}),
],
source(transition, app) {
return {
targets: states.source(transition, app).map(record => ({
type: "actor" as const,
actors: [record.actorId],
})),
}
},
})
}
The loader reads State on the host. Create plugins/bookmarks.render.ts for the
portable renderer:
export default function renderBookmark({ data: saved }: { data: boolean }) {
return saved ? "<span>Bookmarked</span>" : ""
}
Create paths/ada/state/bookmarks/lesson.page.state.md:
---
plugin: bookmarks
for: /lesson.page
saved: true
---
Create paths/lesson.page.md with # Read closely and <x-bookmarks-status />.
Run bun run dev, sign in as Ada, and open /lesson.page: it shows Bookmarked.
Change saved to false in the State file and save: the label disappears.
Restore true and reload to verify the record is read again. Another Actor
resolves their own record. This example reads authored bookmark State; use the
Action contract below when a control should create or update that State.
State requires a canonical for Source id and belongs under the owner's Home.
Duplicate records for one Actor, domain, and origin are invalid.
The store exposes resolve(actor, origin), get(stateId), and records() for
trusted Plugin code. Its write(tx, { actorHome, origin, current, data }) and
delete(tx, record) methods participate in a Source transaction; declare the
corresponding create/update/delete effects in an Action.
They do not replace the Action's authorization or target validation. Feed Source
transitions into states.source so its index follows edits, writes, and deletion.