Skip to content

Commit 2c1ecbb

Browse files
author
DavidQ
committed
Persist Palette Manager V2 edits in normalized workspace session data across return and reopen - PR_26128_028-palette-manager-v2-session-persistence
1 parent 9b6602e commit 2c1ecbb

6 files changed

Lines changed: 275 additions & 19 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Palette Manager V2 Session Persistence
2+
3+
## Scope
4+
- Updated Palette Manager V2 workspace launches to load palette data from the normalized session key `workspace.tools.palette-manager-v2`.
5+
- Palette edits now update `workspace.tools.palette-manager-v2.data.swatches`.
6+
- Palette edits now mark `workspace.tools.palette-manager-v2.dirty` as:
7+
- `isDirty: true`
8+
- `reason: "palette-updated"`
9+
- `changedAt`: current ISO timestamp
10+
- `changedKeys`: palette field/path list for the edit.
11+
- Updated Workspace Manager V2 hydration so valid existing tool sessions for the same selected game are preserved instead of overwritten with manifest defaults.
12+
13+
## Session Boundary
14+
- Preserved the normalized per-tool shape:
15+
- `schema`
16+
- `workspace`
17+
- `data`
18+
- `dirty`
19+
- Preserved `workspace.repo.reference` as the repo session reference key.
20+
- Palette Manager V2 reads and writes only session storage for this handoff.
21+
- Workspace Manager V2 hydrates defaults only when a tool session key is missing or invalid for the current selected game/context.
22+
23+
## Persistence Behavior
24+
- Launching Palette Manager V2 from Workspace Manager V2 loads `workspace.tools.palette-manager-v2.data`.
25+
- Adding, updating, removing, pinning, importing, undoing, or redoing palette swatches persists the edited swatch list into session storage.
26+
- Returning to Workspace Manager V2 preserves dirty palette session data.
27+
- Reopening Palette Manager V2 shows the prior unsaved session edits.
28+
- No `game.manifest.json` write path was added.
29+
30+
## Guardrails
31+
- No cross-tool direct communication was added.
32+
- No sample JSON was modified.
33+
- No roadmap content was modified.
34+
- No schema contract changes were made.
35+
36+
## Skipped
37+
- Full samples smoke test was skipped by request. The changed surface is Workspace Manager V2 and Palette Manager V2 session launch/persistence behavior, covered by `npm run test:workspace-v2` and the targeted Palette Manager V2 launch persistence assertions.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Playwright Palette Manager V2 Session Persistence
2+
3+
## Commands
4+
- `npx playwright test tests/playwright/tools/WorkspaceManagerV2.spec.mjs --project=playwright --workers=1 --reporter=list -g "exports manifests and launches tools from fixed Workspace Manager V2 tiles"`
5+
- `npm run test:workspace-v2`
6+
7+
## Results
8+
- Focused launch persistence test: passed 1/1.
9+
- Workspace Manager V2 suite: passed 16/16.
10+
11+
## Targeted Coverage
12+
- Verified Palette Manager V2 launches from Workspace Manager V2 and loads the Asteroids palette from `workspace.tools.palette-manager-v2.data`.
13+
- Verified a palette edit updates `workspace.tools.palette-manager-v2.data.swatches`.
14+
- Verified the same edit marks `dirty.isDirty` true with `reason: "palette-updated"`, an ISO `changedAt`, and palette `changedKeys`.
15+
- Verified returning to Workspace Manager V2 preserves the edited session data instead of overwriting it with manifest defaults.
16+
- Verified reopening Palette Manager V2 shows the unsaved edited swatch from session storage.
17+
- Verified Preview Generator V2 still launches and generates after the Palette Manager V2 persistence flow.
18+
- Verified Session Inspector V2 Delete All behavior still clears displayed session entries through the Workspace Manager V2 suite.
19+
20+
## Skipped
21+
- Full samples smoke test was skipped by request. The relevant session persistence, Workspace Manager V2 return, Palette Manager V2 reopen, Preview Generator V2 launch, and Session Inspector V2 reset paths are covered by `tests/playwright/tools/WorkspaceManagerV2.spec.mjs`.

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1689,12 +1689,56 @@ test.describe("Workspace Manager V2 bootstrap", () => {
16891689
await expect(page.locator('#userSwatchList [aria-label="Edit Space Black"]')).toBeVisible();
16901690
await expect(page.locator('#userSwatchList [aria-label="Edit Space Black"]')).toHaveAttribute("title", /Name: Space Black/);
16911691
await expect(page.locator("#paletteStatus")).toHaveText("Loaded active workspace palette Asteroids Palette.");
1692+
await page.locator("#swatchSymbolInput").fill("@");
1693+
await page.locator("#swatchHexInput").fill("#123456");
1694+
await page.locator("#swatchNameInput").fill("Workspace Session Purple");
1695+
await page.locator("#addSwatchButton").click();
1696+
await expect(page.locator("#userPaletteCount")).toHaveText("12 user swatches");
1697+
await expect(page.locator('#userSwatchList [aria-label="Edit Workspace Session Purple"]')).toBeVisible();
1698+
await expect(page.locator("#paletteStatus")).toHaveText("Added Workspace Session Purple.");
1699+
const editedPaletteSession = await page.evaluate(() => JSON.parse(sessionStorage.getItem("workspace.tools.palette-manager-v2")));
1700+
expect(editedPaletteSession.data.swatches).toHaveLength(12);
1701+
expect(editedPaletteSession.data.swatches.at(-1)).toMatchObject({
1702+
hex: "#123456",
1703+
name: "Workspace Session Purple",
1704+
source: "User Added",
1705+
symbol: "@"
1706+
});
1707+
expect(editedPaletteSession.dirty).toMatchObject({
1708+
isDirty: true,
1709+
reason: "palette-updated"
1710+
});
1711+
expect(Date.parse(editedPaletteSession.dirty.changedAt)).not.toBeNaN();
1712+
expect(editedPaletteSession.dirty.changedKeys).toEqual(expect.arrayContaining([
1713+
"data.swatches",
1714+
"data.swatches[11]"
1715+
]));
16921716
await page.locator("#returnToWorkspaceButton").click();
16931717
await expect(page).toHaveURL(/workspace-manager-v2\/index\.html\?hostContextId=workspace-manager-v2-/);
16941718
await expectWorkspaceReturnRehydrated(page);
1719+
const returnedPaletteSession = await page.evaluate(() => JSON.parse(sessionStorage.getItem("workspace.tools.palette-manager-v2")));
1720+
expect(returnedPaletteSession.data.swatches).toHaveLength(12);
1721+
expect(returnedPaletteSession.data.swatches.at(-1)).toMatchObject({
1722+
hex: "#123456",
1723+
name: "Workspace Session Purple",
1724+
source: "User Added",
1725+
symbol: "@"
1726+
});
1727+
expect(returnedPaletteSession.dirty).toMatchObject({
1728+
isDirty: true,
1729+
reason: "palette-updated"
1730+
});
16951731
await expect(page.locator("#statusLog")).toHaveValue(/OK Restored repo destination from workspace\.repo\.reference for HTML-JavaScript-Gaming\./);
16961732
await expect(previewTile).toBeEnabled();
16971733
await expect(previewTile).toContainText("Schema-valid manifest");
1734+
await page.locator('[data-workspace-tool-id="palette-manager-v2"]').click();
1735+
await expect(page).toHaveURL(/palette-manager-v2\/index\.html.*launch=workspace/);
1736+
await expect(page.locator("#userPaletteCount")).toHaveText("12 user swatches");
1737+
await expect(page.locator('#userSwatchList [aria-label="Edit Workspace Session Purple"]')).toBeVisible();
1738+
await expect(page.locator("#paletteStatus")).toHaveText("Loaded active workspace palette Asteroids Palette.");
1739+
await page.locator("#returnToWorkspaceButton").click();
1740+
await expect(page).toHaveURL(/workspace-manager-v2\/index\.html\?hostContextId=workspace-manager-v2-/);
1741+
await expectWorkspaceReturnRehydrated(page);
16981742
await page.locator('[data-workspace-tool-id="preview-generator-v2"]').click();
16991743
await expect(page).toHaveURL(/preview-generator-v2\/index\.html.*launch=workspace/);
17001744
await expect(page.locator('[data-launch-mode-nav="tool"]')).toBeHidden();

tools/palette-manager-v2/main.js

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { PaletteUsageService } from "../common/PaletteUsageService.js";
22
import { PaletteSortService } from "../common/PaletteSortService.js";
33
import { PaletteManagerApp } from "./modules/PaletteManagerApp.js";
44

5+
const PALETTE_MANAGER_V2_TOOL_SESSION_KEY = "workspace.tools.palette-manager-v2";
6+
7+
function isPlainObject(value) {
8+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
9+
}
10+
511
function resolvePaletteSource() {
612
const paletteSource = globalThis.paletteList;
713
if (!paletteSource || !paletteSource.SOURCE_PALETTES) {
@@ -18,6 +24,70 @@ function reportBootstrapError(error) {
1824
console.error(error);
1925
}
2026

27+
function readSessionJson(key) {
28+
const rawValue = window.sessionStorage.getItem(key);
29+
if (!rawValue) {
30+
return { ok: false, message: `${key} was not found in sessionStorage.` };
31+
}
32+
try {
33+
const value = JSON.parse(rawValue);
34+
return isPlainObject(value)
35+
? { ok: true, value }
36+
: { ok: false, message: `${key} must contain a JSON object.` };
37+
} catch (error) {
38+
return { ok: false, message: `${key} contains invalid JSON: ${error.message}` };
39+
}
40+
}
41+
42+
function readWorkspacePaletteToolSession() {
43+
const result = readSessionJson(PALETTE_MANAGER_V2_TOOL_SESSION_KEY);
44+
if (!result.ok) {
45+
return result;
46+
}
47+
const session = result.value;
48+
if (!isPlainObject(session.data) || !Array.isArray(session.data.swatches)) {
49+
return { ok: false, message: `${PALETTE_MANAGER_V2_TOOL_SESSION_KEY}.data.swatches must contain the active workspace palette.` };
50+
}
51+
if (!isPlainObject(session.dirty)) {
52+
return { ok: false, message: `${PALETTE_MANAGER_V2_TOOL_SESSION_KEY}.dirty must contain dirty tracking.` };
53+
}
54+
return { ok: true, session };
55+
}
56+
57+
function createWorkspacePaletteSessionPersistence() {
58+
const launchParams = getWorkspaceLaunchParams();
59+
if (!launchParams.isWorkspaceLaunch) {
60+
return null;
61+
}
62+
return {
63+
save(paletteValue, changedKeys = []) {
64+
const result = readWorkspacePaletteToolSession();
65+
if (!result.ok) {
66+
return result;
67+
}
68+
const session = result.session;
69+
const uniqueChangedKeys = Array.from(new Set((Array.isArray(changedKeys) ? changedKeys : [])
70+
.map((key) => String(key || "").trim())
71+
.filter(Boolean)));
72+
const nextSession = {
73+
...session,
74+
data: {
75+
...session.data,
76+
swatches: Array.isArray(paletteValue?.swatches) ? paletteValue.swatches : []
77+
},
78+
dirty: {
79+
isDirty: true,
80+
reason: "palette-updated",
81+
changedAt: new Date().toISOString(),
82+
changedKeys: uniqueChangedKeys.length ? uniqueChangedKeys : ["data.swatches"]
83+
}
84+
};
85+
window.sessionStorage.setItem(PALETTE_MANAGER_V2_TOOL_SESSION_KEY, JSON.stringify(nextSession));
86+
return { ok: true, key: PALETTE_MANAGER_V2_TOOL_SESSION_KEY, session: nextSession };
87+
}
88+
};
89+
}
90+
2191
function normalizeSamplePresetPath(samplePresetPath) {
2292
const cleanPath = typeof samplePresetPath === "string"
2393
? samplePresetPath.trim().replace(/\\/g, "/")
@@ -91,29 +161,19 @@ function loadWorkspacePalette(app) {
91161
app.rejectImport(["Workspace Manager V2 launch did not include hostContextId."], "Workspace palette load failed.");
92162
return;
93163
}
94-
const rawValue = window.sessionStorage.getItem(launchParams.hostContextId);
95-
if (!rawValue) {
96-
app.rejectImport(["Workspace Manager V2 manifest was not found in sessionStorage."], "Workspace palette load failed.");
97-
return;
98-
}
99-
let workspaceManifest;
100-
try {
101-
workspaceManifest = JSON.parse(rawValue);
102-
} catch (error) {
103-
app.rejectImport([`Workspace Manager V2 manifest JSON is invalid: ${error.message}`], "Workspace palette load failed.");
104-
return;
105-
}
106-
const palettePayload = workspaceManifest?.tools?.["palette-manager-v2"];
107-
if (!palettePayload || !Array.isArray(palettePayload.swatches)) {
108-
app.rejectImport(["Workspace Manager V2 manifest is missing tools.palette-manager-v2.swatches."], "Workspace palette load failed.");
164+
const sessionResult = readWorkspacePaletteToolSession();
165+
if (!sessionResult.ok) {
166+
app.rejectImport([sessionResult.message], "Workspace palette load failed.");
109167
return;
110168
}
169+
const palettePayload = sessionResult.session.data;
111170
app.importPaletteDocument({
112171
name: palettePayload.name || "Workspace Palette",
113172
source: palettePayload.source || palettePayload.sourceId || palettePayload.name || "Workspace Manager V2",
114173
swatches: palettePayload.swatches
115174
}, {
116175
failureStatus: "Workspace palette load failed.",
176+
persistWorkspaceSession: false,
117177
successStatus: `Loaded active workspace palette ${palettePayload.name || "Workspace Palette"}.`
118178
});
119179
}
@@ -164,7 +224,8 @@ try {
164224
documentRef: document,
165225
paletteSource: resolvePaletteSource(),
166226
sortService: new PaletteSortService(),
167-
usageService: new PaletteUsageService()
227+
usageService: new PaletteUsageService(),
228+
workspaceSessionPersistence: createWorkspacePaletteSessionPersistence()
168229
});
169230
app.init();
170231
configureWorkspaceNav();

tools/palette-manager-v2/modules/PaletteManagerApp.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,12 @@ function collectRefs(documentRef) {
156156
}
157157

158158
export class PaletteManagerApp {
159-
constructor({ documentRef, paletteSource, sortService, usageService }) {
159+
constructor({ documentRef, paletteSource, sortService, usageService, workspaceSessionPersistence = null }) {
160160
this.document = documentRef;
161161
this.paletteSource = paletteSource;
162162
this.sortService = sortService;
163163
this.usageService = usageService;
164+
this.workspaceSessionPersistence = workspaceSessionPersistence;
164165
this.globalPaletteToolKey = PALETTE_MANAGER_V2_TOOL_KEY;
165166
this.hexColorPattern = USER_HEX_COLOR_PATTERN;
166167
this.sourcePalettes = paletteSource.SOURCE_PALETTES;
@@ -268,6 +269,7 @@ export class PaletteManagerApp {
268269
this.renderSelectedSwatchState();
269270
this.setActionState([], status, false);
270271
this.render();
272+
this.persistWorkspacePalette(["data.swatches"]);
271273
}
272274

273275
renderSelectedSwatchState() {
@@ -541,6 +543,18 @@ export class PaletteManagerApp {
541543
}
542544
}
543545

546+
persistWorkspacePalette(changedKeys) {
547+
if (!this.workspaceSessionPersistence || typeof this.workspaceSessionPersistence.save !== "function") {
548+
return true;
549+
}
550+
const result = this.workspaceSessionPersistence.save(this.getPaletteValue(), changedKeys);
551+
if (result?.ok) {
552+
return true;
553+
}
554+
this.setActionState([result?.message || "Unable to update workspace palette session."], "Workspace palette session update failed.");
555+
return false;
556+
}
557+
544558
setAvailableTags(tags) {
545559
this.state.availableTags = sortUniqueTags(tags);
546560
}
@@ -620,6 +634,10 @@ export class PaletteManagerApp {
620634
this.editorControl.showUserDefinedSwatch(cleanSwatch);
621635
this.recordHistorySnapshot();
622636
this.setActionState([], `Added ${cleanSwatch.name}.`);
637+
this.persistWorkspacePalette([
638+
"data.swatches",
639+
`data.swatches[${this.state.selectedUserIndex}]`
640+
]);
623641
}
624642

625643
updateSelectedSwatch(swatch) {
@@ -658,6 +676,12 @@ export class PaletteManagerApp {
658676
}
659677
this.recordHistorySnapshot();
660678
this.setActionState([], `Updated ${cleanSwatch.name}.`);
679+
this.persistWorkspacePalette([
680+
`data.swatches[${this.state.selectedUserIndex}]`,
681+
`data.swatches[${this.state.selectedUserIndex}].symbol`,
682+
`data.swatches[${this.state.selectedUserIndex}].hex`,
683+
`data.swatches[${this.state.selectedUserIndex}].name`
684+
]);
661685
}
662686

663687
addTagToSelectedSwatch(tag) {
@@ -697,6 +721,7 @@ export class PaletteManagerApp {
697721
}
698722
this.recordHistorySnapshot();
699723
this.setActionState([], `Added ${cleanTag} to ${cleanSwatch.name}.`);
724+
this.persistWorkspacePalette([`data.swatches[${this.state.selectedUserIndex}].tags`]);
700725
return true;
701726
}
702727

@@ -737,6 +762,7 @@ export class PaletteManagerApp {
737762
}
738763
this.recordHistorySnapshot();
739764
this.setActionState([], `Removed ${cleanTag} from ${cleanSwatch.name}.`);
765+
this.persistWorkspacePalette([`data.swatches[${this.state.selectedUserIndex}].tags`]);
740766
return true;
741767
}
742768

@@ -865,6 +891,7 @@ export class PaletteManagerApp {
865891
this.setActionState([], shouldAddTag
866892
? `Added ${tag} to ${updates.length} selected swatches.`
867893
: `Removed ${tag} from ${updates.length} selected swatches.`);
894+
this.persistWorkspacePalette(updates.map((update) => `data.swatches[${update.index}].tags`));
868895
return true;
869896
}
870897

@@ -920,6 +947,10 @@ export class PaletteManagerApp {
920947
this.shiftCheckedUserSwatchIndexesAfterRemove(index);
921948
this.recordHistorySnapshot();
922949
this.setActionState([], `Removed ${swatch.name}.`);
950+
this.persistWorkspacePalette([
951+
"data.swatches",
952+
`data.swatches[${index}]`
953+
]);
923954
return true;
924955
}
925956

@@ -946,6 +977,10 @@ export class PaletteManagerApp {
946977
this.editorControl.clearUserDefinedSwatch();
947978
this.recordHistorySnapshot();
948979
this.setActionState([], `Pinned ${pinnedSwatch.name}.`);
980+
this.persistWorkspacePalette([
981+
"data.swatches",
982+
`data.swatches[${this.state.selectedUserIndex}]`
983+
]);
949984
}
950985

951986
pinVisibleSourceSwatches() {
@@ -988,6 +1023,9 @@ export class PaletteManagerApp {
9881023

9891024
const status = `Pinned ${pinnedCount} source swatches. Skipped ${skippedCount} duplicate or invalid swatches.`;
9901025
this.setActionState(Array.from(new Set(skipReasons)), status);
1026+
if (pinnedCount > 0) {
1027+
this.persistWorkspacePalette(["data.swatches"]);
1028+
}
9911029
}
9921030

9931031
findDuplicateUserSwatchIndex(swatch) {
@@ -1017,6 +1055,9 @@ export class PaletteManagerApp {
10171055
this.editorControl.clearForm();
10181056
this.resetHistorySnapshot();
10191057
this.setActionState([], sanitizeText(options.successStatus) || `Imported ${this.state.userSwatches.length} user swatches.`);
1058+
if (options.persistWorkspaceSession !== false) {
1059+
this.persistWorkspacePalette(["data.swatches"]);
1060+
}
10201061
return true;
10211062
}
10221063

0 commit comments

Comments
 (0)