feat: picker wheel settings are saved with the project

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xw1SXwGfadnrcxvgivgffg
This commit is contained in:
Claude
2026-10-23 17:48:22 +00:00
parent a271331210
commit a79f635758
2 changed files with 67 additions and 7 deletions
+55
View File
@@ -0,0 +1,55 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createDom } from "../../shell/dom.js";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
let hub, readPrefs, createProject;
test.before(async () => {
setupDom();
({ createProject } = await import("../../shell/project.js"));
({ default: hub, readPrefs } = await import("./index.js"));
});
test.after(() => teardownDom());
/** A server holding two projects, as the backend does: whatever was PUT comes back on the next GET. */
function server() {
const store = {
aaaaaaaaaaaa: { id: "aaaaaaaaaaaa", name: "A", scenes: [], updated_at: 1 },
bbbbbbbbbbbb: { id: "bbbbbbbbbbbb", name: "B", scenes: [], updated_at: 1 },
};
globalThis.fetch = async (path, opts = {}) => {
if (path === "/funpack/api/projects" && !opts.method) return { ok: true, status: 200, json: async () => ({ projects: Object.values(store).map((p) => ({ id: p.id })) }) };
const id = decodeURIComponent(String(path).split("/").pop());
if (opts.method === "PUT") { const body = JSON.parse(opts.body); store[id] = { ...body, updated_at: 2 }; return { ok: true, status: 200, json: async () => store[id] }; }
return { ok: true, status: 200, json: async () => JSON.parse(JSON.stringify(store[id])) };
};
}
test("the wheel's settings are saved with the project and come back on a fresh browser, per project", async () => {
server();
localStorage.clear();
const project = createProject({});
await project.start(); // project A
const app = { dom: createDom(), project, actions: [], editorSettings: [] };
const off = hub.setup({ host: document.createElement("div"), app });
try {
// change it through the real settings panel
const panel = app.editorSettings[0]();
document.body.append(panel);
const half = [...panel.querySelectorAll("button,[role=radio]")].find((b) => b.textContent.trim() === "Half wheel");
const left = [...panel.querySelectorAll("button,[role=radio]")].find((b) => b.textContent.trim() === "Left");
half.click(); left.click();
await project.flush();
assert.deepEqual(readPrefs(app), { shape: "half", side: "left", enabled: null });
// a rental with an empty browser: same project, no localStorage
localStorage.clear();
const fresh = createProject({});
await fresh.start();
assert.deepEqual(readPrefs({ project: fresh }), { shape: "half", side: "left", enabled: null }, "the choice did not travel with the project");
// another project keeps its own choice
await fresh.open("bbbbbbbbbbbb");
assert.deepEqual(readPrefs({ project: fresh }), { shape: "full", side: "right", enabled: null });
} finally { off(); }
});
+12 -7
View File
@@ -2,13 +2,18 @@
import { composer as c } from "../../composer/composer.js";
import { offer } from "../../shell/actions.js";
// Per browser, not per project: the wheel is a habit of the hand. enabled null = everything offered except the opt-in actions.
// Saved with the project (a new rental has an empty browser); the browser copy is only the starting point for a project that has no choice yet. enabled null = everything offered except the opt-in actions.
const KEY = "funpack_wheel";
export function readPrefs() {
try { const p = JSON.parse(localStorage.getItem(KEY)) || {}; return { shape: p.shape === "half" ? "half" : "full", side: p.side === "left" ? "left" : "right", enabled: Array.isArray(p.enabled) ? p.enabled : null }; }
catch { return { shape: "full", side: "right", enabled: null }; }
const clean = (p) => ({ shape: p.shape === "half" ? "half" : "full", side: p.side === "left" ? "left" : "right", enabled: Array.isArray(p.enabled) ? p.enabled : null });
const fromBrowser = () => { try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch { return {}; } };
export function readPrefs(app) {
const saved = app && app.project && app.project.pref ? app.project.pref("wheel", null) : null;
return clean(saved && typeof saved === "object" ? saved : fromBrowser());
}
function writePrefs(app, p) {
if (app.project && app.project.setPref) app.project.setPref("wheel", p);
try { localStorage.setItem(KEY, JSON.stringify(p)); } catch { /* private window: lasts until reload */ }
}
const writePrefs = (p) => { try { localStorage.setItem(KEY, JSON.stringify(p)); } catch { /* private window: lasts until reload */ } };
export default {
id: "wheel",
@@ -35,7 +40,7 @@ export default {
const onDown = (e) => {
if (e.button !== 1 || wheel || app.dom.dialogOpen()) return; // nothing behind a dialog
e.preventDefault();
const prefs = readPrefs(), on = (a) => prefs.enabled ? prefs.enabled.includes(a.id) : !a.optIn;
const prefs = readPrefs(app), on = (a) => prefs.enabled ? prefs.enabled.includes(a.id) : !a.optIn;
const items = (app.actions || []).filter((a) => on(a) && (!a.when || a.when())).slice(0, 12).map((a) => ({ id: a.id, label: a.label, icon: a.icon }));
if (!items.length) return;
const shared = { items, onClose: () => { wheel = null; },
@@ -43,7 +48,7 @@ export default {
wheel = prefs.shape === "half" ? c.wheel.half({ ...shared, edge: prefs.side, at: e.clientY }) : c.wheel.picker({ ...shared, x: e.clientX, y: e.clientY });
};
app.editorSettings.push(() => {
const prefs = readPrefs(), set = (patch) => writePrefs({ ...readPrefs(), ...patch });
const prefs = readPrefs(app), set = (patch) => writePrefs(app, { ...readPrefs(app), ...patch });
const offered = (app.actions || []).map((a) => ({ value: a.id, label: `${a.icon || ""} ${a.label}`.trim() }));
return c.region.stack({ gap: "sm", children: [c.label.section({ text: "Picker wheel" }),
c.hint.default({ text: "Hold the middle mouse button anywhere, flick toward an action and let go." }),