diff --git a/openspec/changes/persist-dm-panel-sizes/.openspec.yaml b/openspec/changes/persist-dm-panel-sizes/.openspec.yaml new file mode 100644 index 00000000..b4c82a0a --- /dev/null +++ b/openspec/changes/persist-dm-panel-sizes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-06 diff --git a/openspec/changes/persist-dm-panel-sizes/design.md b/openspec/changes/persist-dm-panel-sizes/design.md new file mode 100644 index 00000000..875cae25 --- /dev/null +++ b/openspec/changes/persist-dm-panel-sizes/design.md @@ -0,0 +1,78 @@ +## Context + +`src/views/UserContent/Campaigns/RunCampaign.vue` renders the DM screen with the [`splitpanes`](https://github.com/antoniandre/splitpanes) library (registered globally in `src/boot/plugins.js`). The wide-layout (`container.width >= lg`) uses a nested splitter structure: + +- Outer horizontal splitter with three panes: `left` (SoundBoard + Share), `mid` (Encounters + Players), `right` (Resources) +- Left vertical splitter: `left-top` (SoundBoard) + remainder (Share) +- Mid vertical splitter: `mid-top` (Encounters) + remainder (Players) + +Pane sizes are produced by the `paneSize(name)` method, which currently returns hardcoded percentages (25/45/30, 60, 50). Splitpanes emits a `resized` event with `[{ size, min, max }, ...]` on drag release, but the new sizes are never persisted, so reloads reset everything. + +Mobile/tablet/legacy layouts use either tabs or fixed-size splitters; this change does not touch them. + +## Goals / Non-Goals + +**Goals:** +- Restore the user's last-chosen pane sizes for the wide DM layout on reload, in the same browser. +- Keep the implementation small, contained to `RunCampaign.vue` and one small storage helper. +- Store sizes as percentages so they remain layout-relative across screen resolutions automatically. +- Degrade gracefully when storage is unavailable, missing, or corrupt — always fall back to the existing defaults. + +**Non-Goals:** +- Persisting layout per campaign or per user account (server-side). One layout per browser is enough. +- Persisting sizes for the mobile/tablet/legacy layouts. +- Adding a "reset to defaults" UI button (can be a follow-up; clearing localStorage works for now). +- Syncing across devices. + +## Decisions + +### 1. Persist sizes as percentages, not pixels + +Splitpanes already operates in percent space, and percentages are inherently relative — the same value produces the same proportional layout at any window width. Storing pixels would require recomputing on every resolution change and would defeat the goal of cross-resolution consistency. + +**Alternative considered:** storing pixel widths + recomputing on mount. Rejected — more code, more edge cases, no benefit over percentages. + +### 2. Storage: single `localStorage` key with a JSON object + +Key: `dm-screen-panes`. Value shape: +```json +{ "left": 25, "left-top": 60, "mid": 45, "mid-top": 50, "right": 30 } +``` + +One key keeps reads/writes atomic and easy to clear. The five pane names match the existing `paneSize(name)` switch cases. + +**Alternative considered:** one key per pane. Rejected — five reads on mount is noisier with no benefit. + +**Alternative considered:** Vuex + persisted state plugin. Rejected — overkill for a single view's UI preference; introduces a state dependency for a purely local concern. + +### 3. Read on mount, write on `resized` + +- On `mounted()` (or before first render), read and validate the stored object; merge over defaults. +- Bind each pane's `:size` to a reactive `panes` data object instead of the current pure-function `paneSize`. +- Wire `@resized` on each `Splitpanes` instance to a handler that maps emitted sizes back to named keys (in template order) and writes the updated object to `localStorage`. + +Writing on `resized` (drag-release) — not during drag — avoids thrashing storage. + +### 4. Validation and fallback + +Stored values must be: +- a plain object +- each key one of the five known pane names +- each value a finite number between `min-size` (20) and 80 + +Any failure (parse error, schema mismatch, out-of-range value) → discard the stored object and use defaults. This protects against tampering and against future changes to pane names. + +### 5. Helper location + +Add a tiny module `src/utils/dmScreenLayout.js` exporting `loadPaneSizes()` and `savePaneSizes(partial)`. Keeping it out of `generalFunctions.js` avoids bloating the shared utility file with view-specific concerns. + +### 6. SSR safety + +The project runs in SSR mode (`quasar build -m ssr`). `localStorage` is unavailable on the server. The helper must guard with `typeof window !== "undefined"` and return `null` / no-op on the server. The DM screen is an authenticated client-only route in practice, but the guard is cheap insurance. + +## Risks / Trade-offs + +- **[Risk]** A stored layout from a much wider screen could feel cramped on a smaller screen → **Mitigation:** percentages already adapt, and `min-size="20"` on every pane prevents truly broken layouts; defaults remain available by clearing storage. +- **[Risk]** Future structural changes to the splitter tree (adding/removing a pane) would mismatch stored data → **Mitigation:** validation rejects unknown keys; unknown values silently fall back. Naming the storage key with a version suffix (e.g. `dm-screen-panes-v1`) lets us bump on breaking changes. +- **[Trade-off]** Per-browser persistence means a DM using two machines won't get a synced layout. Acceptable given the scope and zero backend cost. +- **[Risk]** `localStorage` quota exceeded or disabled (private mode) → **Mitigation:** wrap writes in try/catch; silently no-op on failure (functionality degrades to current behavior). diff --git a/openspec/changes/persist-dm-panel-sizes/proposal.md b/openspec/changes/persist-dm-panel-sizes/proposal.md new file mode 100644 index 00000000..6cedc4c2 --- /dev/null +++ b/openspec/changes/persist-dm-panel-sizes/proposal.md @@ -0,0 +1,27 @@ +## Why + +On the DM screen (`RunCampaign.vue`), the splitter panels (Encounters, Players, SoundBoard, Share, Resources) can be resized by dragging, but the sizes reset to hardcoded defaults on every reload. DMs lose their preferred layout between sessions, which is a recurring papercut during long campaigns. + +## What Changes + +- Persist user-adjusted pane sizes for the DM screen splitters across page reloads. +- Store sizes as **percentages** (the format splitpanes already emits/consumes), so they remain layout-relative and adapt automatically across different screen resolutions. +- Persist per device (browser `localStorage`) — not per user account — to keep the change scoped and avoid Firebase writes. +- Persist sizes only for the current responsive breakpoint layout (`container.width >= lg` — the three-column layout with nested splitters). Other breakpoints (mobile/tablet/legacy) continue to use the existing hardcoded defaults to keep the scope tight. +- Fall back to existing defaults when no saved sizes exist or stored values are invalid/corrupt. + +## Capabilities + +### New Capabilities +- `dm-screen-layout`: persistence of resizable panel layout on the campaign DM screen, including read/write to browser storage, validation of stored values, and graceful fallback to defaults. + +### Modified Capabilities + + +## Impact + +- **Code**: `src/views/UserContent/Campaigns/RunCampaign.vue` (template bindings, `paneSize` method, new resize handlers, mount hook). One small new utility for storage read/write (likely in `src/utils/generalFunctions.js` or a new `src/utils/dmScreenLayout.js`). +- **APIs**: none. No backend changes. +- **Dependencies**: none added. Uses existing `splitpanes` `resized` event and browser `localStorage`. +- **Storage**: a single `localStorage` key (e.g. `dm-screen-panes`) holding a small JSON object of percentages. +- **Risk**: low — purely client-side, easy to clear by removing the key. diff --git a/openspec/changes/persist-dm-panel-sizes/specs/dm-screen-layout/spec.md b/openspec/changes/persist-dm-panel-sizes/specs/dm-screen-layout/spec.md new file mode 100644 index 00000000..880559fe --- /dev/null +++ b/openspec/changes/persist-dm-panel-sizes/specs/dm-screen-layout/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: Persist DM screen pane sizes across reloads + +The system SHALL persist the user's adjusted pane sizes for the wide-layout DM screen (`RunCampaign.vue`, `container.width >= lg`) in the browser's `localStorage` and restore them on subsequent visits in the same browser. + +#### Scenario: User resizes a pane and reloads +- **WHEN** the user drags a splitter to resize any of the named panes (`left`, `left-top`, `mid`, `mid-top`, `right`) and then reloads the page +- **THEN** the DM screen restores the panes to the most recently dragged sizes instead of the hardcoded defaults + +#### Scenario: First visit with no stored layout +- **WHEN** the user opens the DM screen for the first time on this browser, with no entry in `localStorage` +- **THEN** the panes render at the existing hardcoded default sizes (left=25, mid=45, right=30, left-top=60, mid-top=50) + +#### Scenario: User on a different screen resolution +- **WHEN** the user opens the DM screen on a browser at a different window width than when the layout was saved +- **THEN** the panes restore to the same relative proportions (percentages), preserving the visual aspect ratio of the saved layout + +### Requirement: Persist sizes as relative percentages + +The system SHALL store pane sizes as numeric percentages of their parent splitter (matching the `splitpanes` library's native unit) rather than absolute pixel values. + +#### Scenario: Sizes round-trip as percentages +- **WHEN** the user drags a pane to a given proportion of the splitter and the value is persisted +- **THEN** the stored value is a finite number in the range `[20, 80]` representing percent + +### Requirement: Save on drag release, not during drag + +The system SHALL write the updated layout to storage in response to the splitter's drag-release event (the `splitpanes` `resized` event), not on every pixel of movement during the drag. + +#### Scenario: Drag in progress +- **WHEN** the user is actively dragging a splitter +- **THEN** no write to `localStorage` occurs + +#### Scenario: Drag released +- **WHEN** the user releases the splitter after a drag +- **THEN** exactly one write to `localStorage` occurs, containing the updated sizes for the affected panes + +### Requirement: Graceful fallback on missing or invalid storage + +The system SHALL fall back to the hardcoded default sizes when the stored layout is missing, malformed, or contains values that fail validation, and SHALL NOT raise an error visible to the user. + +#### Scenario: Stored value is not valid JSON +- **WHEN** `localStorage` contains an unparseable string under the layout key +- **THEN** the DM screen renders with default sizes and the invalid entry is ignored (or overwritten on the next resize) + +#### Scenario: Stored value contains out-of-range numbers +- **WHEN** a stored pane size is not a finite number in the range `[20, 80]` +- **THEN** that pane uses its default size; other valid stored sizes are still applied + +#### Scenario: Stored value contains unknown pane keys +- **WHEN** the stored object contains keys not in the known pane set +- **THEN** the unknown keys are ignored and known keys are still applied + +#### Scenario: localStorage is unavailable +- **WHEN** `localStorage` is disabled, full, or otherwise throws on read or write (e.g. SSR, private mode, quota exceeded) +- **THEN** the DM screen still renders at default sizes and resize interactions continue to work without error + +### Requirement: Scope limited to the wide DM layout + +The system SHALL persist sizes only for the wide DM layout (the three-column nested-splitter layout used when `container.width >= lg`). Mobile, tablet, and legacy layouts SHALL continue to use their existing hardcoded sizes unchanged. + +#### Scenario: Mobile layout +- **WHEN** the user opens the DM screen on a viewport below the `sm` breakpoint +- **THEN** the mobile tabbed layout renders unchanged and no layout values are read from or written to storage on its behalf + +#### Scenario: Legacy layout opt-in +- **WHEN** the user has the `legacy_campaign_layout` setting enabled +- **THEN** the legacy two-pane splitter renders unchanged and no layout values are read from or written to storage on its behalf diff --git a/openspec/changes/persist-dm-panel-sizes/tasks.md b/openspec/changes/persist-dm-panel-sizes/tasks.md new file mode 100644 index 00000000..741a7c94 --- /dev/null +++ b/openspec/changes/persist-dm-panel-sizes/tasks.md @@ -0,0 +1,33 @@ +## 1. Storage helper + +- [x] 1.1 Create `src/utils/dmScreenLayout.js` with `DEFAULT_PANE_SIZES`, `STORAGE_KEY = "dm-screen-panes-v1"`, and known-pane allowlist (`left`, `left-top`, `mid`, `mid-top`, `right`) +- [x] 1.2 Implement `loadPaneSizes()` — SSR-guard (`typeof window`), try/catch around `JSON.parse`, validate each key is allowlisted and each value is a finite number in `[20, 80]`, drop bad entries, return `{ ...DEFAULT_PANE_SIZES, ...validStored }` +- [x] 1.3 Implement `savePaneSizes(partial)` — SSR-guard, merge with currently stored object, validate, wrap `setItem` in try/catch so quota/disabled storage is a silent no-op + +## 2. Wire helper into `RunCampaign.vue` + +- [x] 2.1 Import `loadPaneSizes` and `savePaneSizes` from `src/utils/dmScreenLayout.js` +- [x] 2.2 Add reactive `panes` to `data()` initialized via `loadPaneSizes()` +- [x] 2.3 Replace each `:size="paneSize('')"` binding on the wide layout (`container.width >= lg`) with `:size="panes."`, and `100 - paneSize('left-top')` / `100 - paneSize('mid-top')` with `100 - panes['left-top']` / `100 - panes['mid-top']` +- [x] 2.4 Keep the existing `paneSize` method as the source of `DEFAULT_PANE_SIZES` (or delete it once defaults live in the helper) — pick one and remove the dead path +- [x] 2.5 Leave mobile, tablet, and legacy splitter blocks (`container.width < lg`, `legacy_layout`) untouched + +## 3. Resize handlers + +- [x] 3.1 Add `onOuterResized(sizes)` — maps splitpanes' emitted array to `{ left, mid, right }` in template order and calls `savePaneSizes` + updates local `panes` +- [x] 3.2 Add `onLeftResized(sizes)` — maps to `{ "left-top": sizes[0].size }` +- [x] 3.3 Add `onMidResized(sizes)` — maps to `{ "mid-top": sizes[0].size }` +- [x] 3.4 Bind `@resized` on the three corresponding `` elements in the wide layout + +## 4. Manual verification + +- [ ] 4.1 Run `npm run ssr` and open a campaign at >= `lg` width; drag each splitter, reload, confirm sizes are restored +- [ ] 4.2 Resize the browser window to a different width and reload; confirm proportions (not pixels) are preserved +- [ ] 4.3 In DevTools, set `localStorage["dm-screen-panes-v1"]` to invalid JSON, an out-of-range value, and an unknown key; reload and confirm graceful fallback to defaults for the bad entries +- [ ] 4.4 Confirm mobile (narrow viewport) and `legacy_campaign_layout` enabled both still render with their existing hardcoded sizes and write nothing to storage +- [x] 4.5 Run `npm run lint` and fix any issues introduced + +## 5. Wrap up + +- [ ] 5.1 Commit on `feature/persist-dm-panel-sizes` and push +- [ ] 5.2 Open PR `feature/persist-dm-panel-sizes` → `develop` (per Git Flow in CLAUDE.md) diff --git a/src/utils/dmScreenLayout.js b/src/utils/dmScreenLayout.js new file mode 100644 index 00000000..13955787 --- /dev/null +++ b/src/utils/dmScreenLayout.js @@ -0,0 +1,52 @@ +export const STORAGE_KEY = "dm-screen-panes-v1"; + +export const DEFAULT_PANE_SIZES = Object.freeze({ + left: 25, + "left-top": 60, + mid: 45, + "mid-top": 50, + right: 30, +}); + +const KNOWN_PANES = Object.keys(DEFAULT_PANE_SIZES); + +function isValidSize(value) { + return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100; +} + +function pickValid(raw) { + const valid = {}; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return valid; + for (const key of KNOWN_PANES) { + if (Object.prototype.hasOwnProperty.call(raw, key) && isValidSize(raw[key])) { + valid[key] = Math.round(raw[key] * 100) / 100; + } + } + return valid; +} + +function readRaw() { + if (typeof window === "undefined" || !window.localStorage) return null; + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (!stored) return null; + return JSON.parse(stored); + } catch (e) { + return null; + } +} + +export function loadPaneSizes() { + const valid = pickValid(readRaw()); + return { ...DEFAULT_PANE_SIZES, ...valid }; +} + +export function savePaneSizes(partial) { + if (typeof window === "undefined" || !window.localStorage) return; + const merged = pickValid({ ...readRaw(), ...partial }); + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(merged)); + } catch (e) { + // quota exceeded / storage disabled — silent no-op + } +} diff --git a/src/views/UserContent/Campaigns/RunCampaign.vue b/src/views/UserContent/Campaigns/RunCampaign.vue index d7d23ac8..650405cb 100644 --- a/src/views/UserContent/Campaigns/RunCampaign.vue +++ b/src/views/UserContent/Campaigns/RunCampaign.vue @@ -94,23 +94,39 @@ /> - - - + + + - + - - - + + + - + - + @@ -212,7 +228,9 @@ import Players from "src/components/campaign/Players.vue"; import SoundBoard from "src/components/campaign/soundBoard/index.vue"; import Share from "src/components/campaign/share"; import Resources from "src/components/campaign/resources"; +import HkPane from "src/components/hk-components/hk-pane"; import { getCharacterSyncStorage } from "src/utils/generalFunctions"; +import { loadPaneSizes, savePaneSizes } from "src/utils/dmScreenLayout"; import AddPlayers from "src/components/campaign/AddPlayers"; import { mapGetters, mapActions } from "vuex"; @@ -226,6 +244,7 @@ export default { Share, Resources, AddPlayers, + "hk-pane": HkPane, }, data() { return { @@ -273,6 +292,8 @@ export default { md: 768, lg: 992, xl: 1200, + panes: loadPaneSizes(), + dragFlags: { outer: false, left: false, mid: false }, }; }, async mounted() { @@ -352,19 +373,31 @@ export default { setSize(size) { this.container = size; }, - paneSize(pane) { - switch (pane) { - case "left": - return 25; - case "mid": - return 45; - case "right": - return 30; - case "left-top": - return 60; - case "mid-top": - return 50; - } + updatePanes(partial) { + this.panes = { ...this.panes, ...partial }; + savePaneSizes(partial); + }, + onOuterResized(sizes) { + const fromDrag = this.dragFlags.outer; + this.dragFlags.outer = false; + if (!fromDrag || !Array.isArray(sizes) || sizes.length < 3) return; + this.updatePanes({ + left: sizes[0].size, + mid: sizes[1].size, + right: sizes[2].size, + }); + }, + onLeftResized(sizes) { + const fromDrag = this.dragFlags.left; + this.dragFlags.left = false; + if (!fromDrag || !Array.isArray(sizes) || sizes.length < 1) return; + this.updatePanes({ "left-top": sizes[0].size }); + }, + onMidResized(sizes) { + const fromDrag = this.dragFlags.mid; + this.dragFlags.mid = false; + if (!fromDrag || !Array.isArray(sizes) || sizes.length < 1) return; + this.updatePanes({ "mid-top": sizes[0].size }); }, open_player_dialog() { this.add_players_dialog = true;