HomeDeveloper docspreferences: Persistent personal density preference
Examples by kind 6 min read

preferences: Persistent personal density preference

Save a personal compact/comfortable preference with compare-and-swap revisions, and make a concurrent conflict visible.

What you will build

Save a personal compact/comfortable preference with compare-and-swap revisions, and make a concurrent conflict visible.

Surface: preferences · Mode: augment · Gravewright 0.3.0 / SDK 1.x.

When it mounts and who can use it

Open table preferences, choose a density and click Save. storage.user is scoped to this module, table and signed-in user. It requires no GM role and survives module deactivation. The setting controls this example panel only.

The owner installs the local ZIP under Modules → Install module → Install from ZIP. The GM then activates the exact version in the table extension list. This example requests no extra permissions or global handle; all reads use the mount’s supplied api. GMs and players receive only server-authorized data. Streamers do not receive this authenticated mount.

Download the complete package

Download source ZIP · manifest.json · main.mjs · styles.css · LICENSE.txt

Save all four files in the same folder. ZIP the folder contents, with manifest.json directly at the archive root. Do not include a wrapper folder, local state or private keys. Local upload is the owner’s trust decision; for catalog distribution, use the signing workflow in the module reference.

text
preferences/
  manifest.json
  main.mjs
  styles.css
  LICENSE.txt

manifest.json

json
{
  "id": "example.surface-preferences",
  "name": "Persistent personal density preference",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "description": "Save a personal compact/comfortable preference with compare-and-swap revisions, and make a concurrent conflict visible.",
  "author": "Gravewright documentation",
  "license": "MIT",
  "entry": "main.mjs"
}

main.mjs

javascript
// Original documentation example. License: MIT. Target: Gravewright 0.3.0, SDK 1.x.
export default {
  start({ api, styles }) {
    styles.use("styles.css");
    api.i18n.register({
      "en": {
        "Density": "Density",
        "Comfortable": "Comfortable",
        "Compact": "Compact",
        "Save": "Save",
        "Saved": "Saved",
        "Conflict: latest value reloaded. Choose again and save.": "Conflict: latest value reloaded. Choose again and save.",
        "Refresh": "Refresh",
        "Loading…": "Loading…",
        "Error: {code}": "Error: {code}",
        "Persistent personal density preference": "Persistent personal density preference"
      },
      "pt-BR": {
        "Density": "Densidade",
        "Comfortable": "Confortável",
        "Compact": "Compacta",
        "Save": "Salvar",
        "Saved": "Salvo",
        "Conflict: latest value reloaded. Choose again and save.": "Conflito: valor recente recarregado. Escolha novamente e salve.",
        "Refresh": "Atualizar",
        "Loading…": "Carregando…",
        "Error: {code}": "Erro: {code}",
        "Persistent personal density preference": "Preferência pessoal persistente de densidade"
      }
    });
  },
  register({ register }) {
    register("preferences", { mode: "augment", mount: this.mount });
  },
  async mount({ root, context, api, storage, viewport, events, signal, onDispose }) {
    const el = (tag, text) => {
      const node = root.ownerDocument.createElement(tag);
      if (text !== undefined) node.textContent = text;
      return node;
    };
    const t = (key, params) => api.i18n.t(key, params);
    const panel = el("section"); panel.className = "gw-example";
    const heading = el("h3", t("Persistent personal density preference"));
    const output = el("pre"); output.setAttribute("role", "status");
    panel.append(heading, output); root.append(panel);
    onDispose(() => panel.remove());
    const report = error => {
      if (!signal.aborted) output.textContent = t("Error: {code}", { code: error.code ?? "unavailable" });
    };
    const label = el("label", t("Density"));
    const select = el("select");
    for (const [value, title] of [["comfortable", "Comfortable"], ["compact", "Compact"]]) {
      const option = el("option", t(title)); option.value = value; select.append(option);
    }
    label.append(select);
    const save = el("button", t("Save")); save.type = "button";
    save.disabled = true;
    panel.append(label, save);
    let previous = null, ready = false;
    const apply = record => {
      if (record && (!record.value || record.value.version !== 1 || !["comfortable", "compact"].includes(record.value.density))) {
        throw Object.assign(new Error("Unsupported preference data"), { code: "invalid_data" });
      }
      previous = record;
      const density = record?.value.density ?? "comfortable";
      select.value = density;
      panel.classList.toggle("gw-example--compact", density === "compact");
    };
    try { const record = await storage.user.get("density"); if (!signal.aborted) { apply(record); ready = true; save.disabled = false; } }
    catch (error) { report(error); }
    save.addEventListener("click", async () => {
      save.disabled = true;
      try {
        const saved = await storage.user.set("density", { version: 1, density: select.value }, { expectedRevision: previous?.revision ?? null });
        if (!signal.aborted) { apply(saved); output.textContent = t("Saved"); }
      } catch (error) {
        if (error.code === "conflict") {
          try { const latest = await storage.user.get("density"); if (!signal.aborted) { apply(latest); output.textContent = t("Conflict: latest value reloaded. Choose again and save."); } }
          catch (refreshError) { ready = false; report(refreshError); return; }
        } else report(error);
      } finally { if (!signal.aborted) save.disabled = !ready; }
    }, { signal });
  },
  stop() {
    // Mount and module lifetimes already dispose styles, catalogs and subscriptions.
  }
};

styles.css

css
.gw-example { box-sizing: border-box; max-width: 640px; padding: var(--space-3); margin: var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-md); color: var(--text); background: var(--surface); font: 14px/1.5 system-ui, sans-serif; }
.gw-example h3 { font-size: 16px; margin: var(--space-0) var(--space-0) var(--space-2); }
.gw-example pre { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
.gw-example button, .gw-example select, .gw-example input { color: inherit; background: var(--surface); font: inherit; padding: var(--space-1) var(--space-2); margin: var(--space-1); border: 1px solid var(--border); border-radius: var(--radius-sm); }
.gw-example button:focus-visible, .gw-example input:focus-visible, .gw-example select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.gw-example--compact { padding: var(--space-1); }
.gw-example-center { position: absolute; transform: translate(-50%, -50%); pointer-events: none; color: var(--text); background: var(--surface); padding: var(--space-1); border-radius: var(--radius-sm); font: 14px/1.5 system-ui, sans-serif; }

Expected result and cleanup

Save a personal compact/comfortable preference with compare-and-swap revisions, and make a concurrent conflict visible.

The native interface remains usable. Closing a sheet, switching scenes, leaving the table or deactivating the module aborts the mount. Listeners use signal; events and owned elements are released through onDispose. Async reads check cancellation and discard old responses before rendering. Errors are displayed through error.code, including network failures.

Writes use the revision read by get. On conflict, the example reloads server state and never retries the write automatically. The preference owns a versioned {version: 1, density} shape and changes only the example panel, without claiming to control host density.

Validate on a disposable table

  1. Save, close and reopen. Use two tabs: save in the first and then in the stale second tab. The second must reload the latest value and ask for another explicit Save.

  2. Open/close three times, then deactivate while a read or modal is pending. Check that no old panel or error appears after disposal.

  3. Test empty state, long document names, keyboard navigation and a narrow viewport. Compare GM and player accounts.

  4. Check the browser console. These source examples have runtime contract tests; installation and visual behavior still require validation on the target host.

How to use AI for this surface

text
Adapt the preferences example for [concrete goal] on Gravewright 0.3.0.
Read registry.json, this surface’s context schema, frontend.json and the native services used.
Keep start/register/stop, mount-scoped api, signal, onDispose and native modals.
Provide the complete manifest and files. Do not use app.shell/inside.content in packages.
Do not invent payload fields, core RPG rules, permissions or APIs.
Test player/GM, empty data, abort, repeated mounting and this example’s behavior.
Explain limitations and which checks actually ran.

Continue

SDK · API · Examples by kind