Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ jobs:
# stale by three.
- name: svelte-check baseline gate
run: node scripts/check-ratchet.cjs
# 27-H (audit M4): 507 bare `localStorage` calls were routed through
# $lib/safeStorage in one pass. Without a gate that codemod decays on the next
# feature, because the file you are editing still shows you ninety-three examples
# of the old way. `setItem` throws in Safari private mode and on a full quota, and
# most of these sit inside $effects and store subscribers, where the throw kills
# the subscriber for the session.
- name: no bare localStorage
run: node scripts/check-storage.cjs

unit:
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion check-baseline.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"comment": "27-I: the svelte-check floor, read ONLY by scripts/check-ratchet.cjs. It used to be hardcoded in release.yml's shell block, where it went stale (362 while the tree measured 359). Ratchet it DOWN whenever a change legitimately removes errors - that is the project convention, and --update does it in one command.",
"errors": 358,
"errors": 357,
"warnings": 47,
"measured": "2026-09-12"
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"e2e": "node tests/e2e/run.cjs",
"deps:check": "node scripts/deps-check.cjs",
"sync-llms": "node scripts/sync-llms.cjs",
"test:unit": "vitest run"
"test:unit": "vitest run",
"check:storage": "node scripts/check-storage.cjs"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
Expand Down
72 changes: 72 additions & 0 deletions scripts/check-storage.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env node
// 27-H (hardening audit M4) — THE GUARD THAT KEEPS THE CODEMOD FROM DECAYING.
//
// 507 bare `localStorage` calls across 94 files were routed through `$lib/safeStorage` in
// one pass. Without something failing on the next bare one, that lasts exactly until the
// next feature: nobody grepping for "how do I persist a setting" finds the wrapper, they
// find ninety-three examples of `localStorage.setItem` in the file they are editing.
//
// This is the whole rule. `localStorage.setItem` THROWS in Safari private mode and on a
// full quota, and most of these sit inside `$effect`s and store subscribers, where a
// throw kills the subscriber for the session — the setting stops persisting AND the UI it
// drives stops updating, with nothing pointing at storage.
//
// Exits 1 on a violation; prints the file, the line and what to write instead. Wired into
// ci.yml's `check` job beside the svelte-check ratchet.
const fs = require('fs');
const path = require('path');

const ROOT = path.resolve(__dirname, '..');
const SRC = path.join(ROOT, 'src');

// `localStorage.x(` for the four methods, and the one enumeration form the codebase used
const CALL = /\blocalStorage\.(getItem|setItem|removeItem|clear)\s*\(/;
const KEYS = /\bObject\.keys\(\s*localStorage\s*\)/;

/**
* The only files allowed to touch it directly, each for a stated reason. Adding to this
* list is a decision, which is the point of it being a list.
*/
const ALLOWED = new Map([
['src/lib/safeStorage.js', 'it IS the wrapper'],
[
'src/app.html',
'an inline <script> in the document head, applying the saved theme BEFORE first paint — it runs before any module exists to import, and it is already wrapped in try/catch'
]
]);

/** @param {string} dir @param {string[]} out */
function walk(dir, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (/\.(js|ts|svelte|html)$/.test(entry.name)) out.push(full);
}
return out;
}

const violations = [];
for (const file of walk(SRC)) {
const rel = path.relative(ROOT, file).split(path.sep).join('/');
if (ALLOWED.has(rel)) continue;
const lines = fs.readFileSync(file, 'utf8').split('\n');
lines.forEach((line, i) => {
if (CALL.test(line) || KEYS.test(line)) violations.push({ rel, line: i + 1, text: line.trim() });
});
}

if (!violations.length) {
console.log('check:storage — no bare localStorage calls in src/.');
process.exit(0);
}

console.error('check:storage — ' + violations.length + ' bare localStorage call(s):\n');
for (const v of violations) console.error(' ' + v.rel + ':' + v.line + ' ' + v.text.slice(0, 100));
console.error(
"\nUse $lib/safeStorage instead — `import { safeStorage } from '$lib/safeStorage'` and" +
'\ncall safeStorage.getItem / setItem / removeItem / clear / keys. It never throws, and a' +
'\nwrite that cannot reach the disk still applies for this session.' +
'\n\nIf a file genuinely cannot import (an inline script before the bundle exists), add it' +
'\nto ALLOWED in scripts/check-storage.cjs with the reason.'
);
process.exit(1);
16 changes: 12 additions & 4 deletions src/App.svelte

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/components/CameraPreview.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { objectsGroup, TControls } from '../stores/sceneStore';
import { cameraPreview, writeBackPose, previewOrbit, seatOrbitBehind } from '$lib/cameraPreview';
import { cameraSpec, aspectRatio, syncCameraToObject } from '$lib/cameraObjects';
import { safeStorage } from '$lib/safeStorage';

// 16-P5: while a camera OBJECT is previewed, THIS is the render camera — a real
// perspective/orthographic camera (`makeDefault`, so threlte's `camera.current`
Expand Down Expand Up @@ -33,7 +34,7 @@

// debug probe for the suites (opt-in, like __outlineDebug)
$effect(() => {
if (typeof window === 'undefined' || !localStorage.getItem('debugStores')) return;
if (typeof window === 'undefined' || !safeStorage.getItem('debugStores')) return;
(window as any).__cameraPreviewDebug = () => ({
preview: $cameraPreview,
hasObject: !!object,
Expand Down
5 changes: 3 additions & 2 deletions src/components/ContextMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import Icon from './ui/Icon.svelte';
import { collectLeaves, rankMatches } from '$lib/menuFilter';
import { autofocusOk, typeToFocus } from '$lib/inputDevice';
import { safeStorage } from '$lib/safeStorage';

// Generic context menu. items: [{ label, action?, disabled?, tooltip?, danger?,
// icon?, hint?, checked?, keepOpen?, rowActions?, children?: items[] } |
Expand Down Expand Up @@ -116,12 +117,12 @@
/** @param {number} value */
function rememberHeight(value: number) {
try {
localStorage.setItem(heightStore(), String(Math.round(value)));
safeStorage.setItem(heightStore(), String(Math.round(value)));
} catch {}
}
function storedHeight(): number | null {
try {
const raw = parseInt(localStorage.getItem(heightStore()) ?? "", 10);
const raw = parseInt(safeStorage.getItem(heightStore()) ?? "", 10);
return Number.isFinite(raw) && raw >= MIN_LIST_HEIGHT ? raw : null;
} catch {
return null;
Expand Down
19 changes: 10 additions & 9 deletions src/components/Flow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import { bottomDockable } from '$lib/bottomDockDrop';
import { dockAddItems } from '$lib/dockMenu';
import { fly } from 'svelte/transition';
import { safeStorage } from '$lib/safeStorage';

const clampH = (h: number) => Math.min(Math.max(h || 320, 200), Math.round(window.innerHeight * 0.8));
// 18-B: floating-window size limits, shared with the clamp helpers
Expand All @@ -29,7 +30,7 @@
// mirrors Nodes' palette-open (bound below) so the docked content only insets above
// the Controls HUD when the node palette is actually shown (overlapping the HUD)
let paletteOpen = $state(
typeof localStorage !== 'undefined' ? localStorage.getItem('flowPaletteOpen') !== 'false' : true
typeof localStorage !== 'undefined' ? safeStorage.getItem('flowPaletteOpen') !== 'false' : true
);
let winW = $state(760);
let winH = $state(480);
Expand All @@ -42,9 +43,9 @@
winH = Math.min(fit.h, Math.round(window.innerHeight * 0.9));
}
if (typeof localStorage !== 'undefined') {
docked = localStorage.getItem('flowDocked') !== 'false';
winW = parseInt(localStorage.getItem('flowWinW') ?? '760') || 760;
winH = parseInt(localStorage.getItem('flowWinH') ?? '480') || 480;
docked = safeStorage.getItem('flowDocked') !== 'false';
winW = parseInt(safeStorage.getItem('flowWinW') ?? '760') || 760;
winH = parseInt(safeStorage.getItem('flowWinH') ?? '480') || 480;
clampWin();
}
// touch / limited-width: keep the editor docked (no room to float; undock hidden),
Expand All @@ -64,7 +65,7 @@

function setDocked(v: boolean) {
docked = v;
localStorage.setItem('flowDocked', String(v));
safeStorage.setItem('flowDocked', String(v));
if (v) activateDock('flow'); // re-docking makes it the visible tab
else forgetDockTab('flow'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip
}
Expand Down Expand Up @@ -151,15 +152,15 @@
winW = fit.w;
winH = fit.h;
resizeGroup('flow', winW, winH);
localStorage.setItem('flowWinW', String(winW));
localStorage.setItem('flowWinH', String(winH));
safeStorage.setItem('flowWinW', String(winW));
safeStorage.setItem('flowWinH', String(winH));
}
function endWinResize(e: any) {
if (!winResizing) return;
winResizing = false;
e.currentTarget.releasePointerCapture?.(e.pointerId);
localStorage.setItem('flowWinW', String(winW));
localStorage.setItem('flowWinH', String(winH));
safeStorage.setItem('flowWinW', String(winW));
safeStorage.setItem('flowWinH', String(winH));
}
</script>

Expand Down
5 changes: 3 additions & 2 deletions src/components/Outline.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
// 16-Q4: the camera preview window renders as an inset viewport of THIS renderer
import { pipRect, pipTarget, glRect } from '$lib/cameraPip';
import { buildCamera } from '$lib/cameraObjects';
import { safeStorage } from '$lib/safeStorage';

let outlineEffectSelected: OutlineEffect | null = null;
let outlineEffectLocked: OutlineEffect | null = null;
Expand Down Expand Up @@ -428,7 +429,7 @@
});
// e2e hook (debugStores opt-in): the effects live in this component only
onMount(() => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('debugStores'))
if (typeof localStorage !== 'undefined' && safeStorage.getItem('debugStores'))
(window as any).__outlineDebug = () => ({
selected: outlineEffectSelected?.selection.size ?? -1,
locked: outlineEffectLocked?.selection.size ?? -1,
Expand All @@ -439,7 +440,7 @@
// L1: the compiled chain lives in this component only, and its ORDER is the
// thing worth asserting — so the hook names each pass by identity rather than
// by constructor (minified in a build) and reports the merge plan.
if (typeof localStorage !== 'undefined' && localStorage.getItem('debugStores'))
if (typeof localStorage !== 'undefined' && safeStorage.getItem('debugStores'))
(window as any).__postDebug = () => ({
chain: ((composer as any).passes ?? []).map((pass: any) => {
if (pass === renderPass) return 'render';
Expand Down
27 changes: 14 additions & 13 deletions src/components/Scene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
import PathWaypoints from './PathWaypoints.svelte';
import LockHighlights from './LockHighlights.svelte';
import Grid from '../extensions/Grid.svelte';
import { safeStorage } from '$lib/safeStorage';
import Outline from './Outline.svelte'
import Player from './play/Player.svelte'
import { Mesh, Vector3 } from 'three'
Expand All @@ -99,23 +100,23 @@

$globalScene.background = new THREE.Color(0x101010);

$username = localStorage.getItem('username');
$userdata.push([$peers.peer.id, localStorage.getItem('username'), localStorage.getItem('avatar'), null, null, get(avatarConfig)]);
$username = safeStorage.getItem('username');
$userdata.push([$peers.peer.id, safeStorage.getItem('username'), safeStorage.getItem('avatar'), null, null, get(avatarConfig)]);
$userdata = $userdata;

$showGrid = localStorage.getItem('showGrid') === 'false' ? false : true;
$vrOverride = localStorage.getItem('vrOverride');
$showGrid = safeStorage.getItem('showGrid') === 'false' ? false : true;
$vrOverride = safeStorage.getItem('vrOverride');
camera.current.position.set(10.5, 7.57, 11.4);
let fov = camera.current.fov
let resetSettings = false;
setTimeout(() => {
// $peers.send({ type: 'userdata', userdata: $userdata });
if(localStorage.getItem("camx"))
camera.current.position.x = localStorage.getItem("camx");
if(localStorage.getItem("camy"))
camera.current.position.y = localStorage.getItem("camy");
if(localStorage.getItem("camz"))
camera.current.position.z = localStorage.getItem("camz");
if(safeStorage.getItem("camx"))
camera.current.position.x = safeStorage.getItem("camx");
if(safeStorage.getItem("camy"))
camera.current.position.y = safeStorage.getItem("camy");
if(safeStorage.getItem("camz"))
camera.current.position.z = safeStorage.getItem("camz");

// console.log(camera.current.position)
resetSettings = true;
Expand Down Expand Up @@ -276,9 +277,9 @@
// console.log(camera.current.rotation)
}
if (resetSettings == true) {
// localStorage.setItem("camx",camera.current.position.x);
// localStorage.setItem("camy",camera.current.position.y);
// localStorage.setItem("camz",camera.current.position.z);
// safeStorage.setItem("camx",camera.current.position.x);
// safeStorage.setItem("camy",camera.current.position.y);
// safeStorage.setItem("camz",camera.current.position.z);
}

if (!$specatorMode) {
Expand Down
29 changes: 15 additions & 14 deletions src/components/editors/AnimationWindow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize';
import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm, forgetDockTab } from '$lib/bottomDock';
import { bottomDockable } from '$lib/bottomDockDrop';
import { safeStorage } from '$lib/safeStorage';

// live-follow the primary selection (keeps a truthy [] before the first select)
const target = $derived($selectedObject && $selectedObject.uuid ? $selectedObject : null);
Expand Down Expand Up @@ -93,12 +94,12 @@
let view = $state(/** @type {'sheet'|'graph'} */ ('sheet'));
/** 'off' | 'frame' | a step in seconds as a string */
let snapMode = $state(
typeof localStorage !== 'undefined' ? (localStorage.getItem('animationSnap') ?? 'frame') : 'frame'
typeof localStorage !== 'undefined' ? (safeStorage.getItem('animationSnap') ?? 'frame') : 'frame'
);
let renaming = $state(/** @type {string|null} */ (null));
// how tall the clip list is allowed to be, dragged by the divider under it
let clipsH = $state(
typeof localStorage !== 'undefined' ? parseInt(localStorage.getItem('animationClipsH') ?? '96') || 96 : 96
typeof localStorage !== 'undefined' ? parseInt(safeStorage.getItem('animationClipsH') ?? '96') || 96 : 96
);
let clipsResizing = $state(false);
/** the sidebar's own height, measured — the resize ceiling comes from it */
Expand Down Expand Up @@ -130,7 +131,7 @@
if (!clipsResizing) return;
clipsResizing = false;
e.currentTarget.releasePointerCapture?.(e.pointerId);
localStorage.setItem('animationClipsH', String(clipsH));
safeStorage.setItem('animationClipsH', String(clipsH));
}

// imported clips for the selected object (empty for anything not imported
Expand Down Expand Up @@ -215,20 +216,20 @@
let winW = $state(660);
let winH = $state(460);
if (typeof localStorage !== 'undefined') {
docked = localStorage.getItem('animationDocked') !== 'false';
docked = safeStorage.getItem('animationDocked') !== 'false';
// 18-B: a size saved on a bigger screen must not come back oversized.
// Fitted before the assignment so nothing reads $state during init.
const savedWin = clampWinSize(
parseInt(localStorage.getItem('animationWinW') ?? '660') || 660,
parseInt(localStorage.getItem('animationWinH') ?? '460') || 460,
parseInt(safeStorage.getItem('animationWinW') ?? '660') || 660,
parseInt(safeStorage.getItem('animationWinH') ?? '460') || 460,
WIN_MIN
);
winW = savedWin.w;
winH = savedWin.h;
}
function setDocked(/** @type {boolean} */ v) {
docked = v;
localStorage.setItem('animationDocked', String(v));
safeStorage.setItem('animationDocked', String(v));
if (v) activateDock('animation');
else forgetDockTab('animation'); // an undock gives up its slot, so re-docking is a fresh add at the end of the strip
}
Expand Down Expand Up @@ -443,7 +444,7 @@
// select exactly what the eye picks out, including under zoom and pan.
/** @type {'box'|'lasso'} */
let marqMode = $state(
typeof localStorage !== 'undefined' && localStorage.getItem('animationMarquee') === 'lasso'
typeof localStorage !== 'undefined' && safeStorage.getItem('animationMarquee') === 'lasso'
? 'lasso'
: 'box'
);
Expand All @@ -459,7 +460,7 @@
function setMarqMode(/** @type {'box'|'lasso'} */ mode) {
marqMode = mode;
try {
localStorage.setItem('animationMarquee', mode);
safeStorage.setItem('animationMarquee', mode);
} catch {}
}

Expand Down Expand Up @@ -710,7 +711,7 @@
// MEAN, and one object can hold a 24fps swing beside a 60fps flourish — with a
// LOCAL default for clips that never set one (`animationFps` in localStorage).
const DEFAULT_FPS = (() => {
const raw = typeof localStorage !== 'undefined' ? Number(localStorage.getItem('animationFps')) : NaN;
const raw = typeof localStorage !== 'undefined' ? Number(safeStorage.getItem('animationFps')) : NaN;
return Number.isFinite(raw) && raw >= 1 && raw <= 240 ? raw : 30;
})();
const FPS = $derived(anim?.fps ?? DEFAULT_FPS);
Expand Down Expand Up @@ -1427,7 +1428,7 @@
tooltip: FPS + ' fps',
action: () => {
snapMode = snapMode === 'frame' ? 'off' : 'frame';
localStorage.setItem('animationSnap', snapMode);
safeStorage.setItem('animationSnap', snapMode);
}
});
menu = { x: e.clientX, y: e.clientY, items };
Expand Down Expand Up @@ -1652,8 +1653,8 @@
saveWinSize();
}
function saveWinSize() {
localStorage.setItem('animationWinW', String(winW));
localStorage.setItem('animationWinH', String(winH));
safeStorage.setItem('animationWinW', String(winW));
safeStorage.setItem('animationWinH', String(winH));
}
/** 18-B: double-click the grip — back to the default size, position kept */
function resetWinSize() {
Expand Down Expand Up @@ -2086,7 +2087,7 @@
value={snapMode}
onchange={(e) => {
snapMode = e.currentTarget.value;
localStorage.setItem('animationSnap', snapMode);
safeStorage.setItem('animationSnap', snapMode);
}}
>
<option value="off">off</option>
Expand Down
Loading
Loading