Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/persist-dm-panel-sizes/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-06
78 changes: 78 additions & 0 deletions openspec/changes/persist-dm-panel-sizes/design.md
Original file line number Diff line number Diff line change
@@ -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).
27 changes: 27 additions & 0 deletions openspec/changes/persist-dm-panel-sizes/proposal.md
Original file line number Diff line number Diff line change
@@ -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
<!-- None — no existing OpenSpec capability covers this. -->

## 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.
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions openspec/changes/persist-dm-panel-sizes/tasks.md
Original file line number Diff line number Diff line change
@@ -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('<name>')"` binding on the wide layout (`container.width >= lg`) with `:size="panes.<name>"`, 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 `<Splitpanes>` 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)
52 changes: 52 additions & 0 deletions src/utils/dmScreenLayout.js
Original file line number Diff line number Diff line change
@@ -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
}
}
77 changes: 55 additions & 22 deletions src/views/UserContent/Campaigns/RunCampaign.vue
Original file line number Diff line number Diff line change
Expand Up @@ -94,23 +94,39 @@
/>
</hk-pane>
</Splitpanes>
<Splitpanes v-else-if="container.width >= lg" class="default-theme">
<Pane :size="paneSize('left')" min-size="20">
<Splitpanes horizontal>
<Splitpanes
v-else-if="container.width >= lg"
class="default-theme"
@resized="onOuterResized"
@mousedown.native.capture="dragFlags.outer = true"
@touchstart.native.capture="dragFlags.outer = true"
>
<Pane :size="panes.left" min-size="20">
<Splitpanes
horizontal
@resized="onLeftResized"
@mousedown.native.capture="dragFlags.left = true"
@touchstart.native.capture="dragFlags.left = true"
>
<hk-pane>
<SoundBoard />
</hk-pane>
<hk-pane v-if="!campaign.private" :size="100 - paneSize('left-top')" min-size="20">
<hk-pane v-if="!campaign.private" :size="100 - panes['left-top']" min-size="20">
<Share :campaign="campaign" />
</hk-pane>
</Splitpanes>
</Pane>
<Pane :size="paneSize('mid')" min-size="20">
<Splitpanes horizontal>
<hk-pane :size="paneSize('mid-top')" min-size="20">
<Pane :size="panes.mid" min-size="20">
<Splitpanes
horizontal
@resized="onMidResized"
@mousedown.native.capture="dragFlags.mid = true"
@touchstart.native.capture="dragFlags.mid = true"
>
<hk-pane :size="panes['mid-top']" min-size="20">
<Encounters />
</hk-pane>
<hk-pane :size="100 - paneSize('mid-top')" min-size="20">
<hk-pane :size="100 - panes['mid-top']" min-size="20">
<Players
:userId="user.uid"
:campaignId="campaignId"
Expand All @@ -122,7 +138,7 @@
</hk-pane>
</Splitpanes>
</Pane>
<hk-pane :size="paneSize('right')" min-size="20">
<hk-pane :size="panes.right" min-size="20">
<Resources />
</hk-pane>
</Splitpanes>
Expand Down Expand Up @@ -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";
Expand All @@ -226,6 +244,7 @@ export default {
Share,
Resources,
AddPlayers,
"hk-pane": HkPane,
},
data() {
return {
Expand Down Expand Up @@ -273,6 +292,8 @@ export default {
md: 768,
lg: 992,
xl: 1200,
panes: loadPaneSizes(),
dragFlags: { outer: false, left: false, mid: false },
};
},
async mounted() {
Expand Down Expand Up @@ -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;
Expand Down
Loading