feat(sidebar): drag it wider, fold it away, and have it stay that way

The sidebar was 268px and always there. On a wide monitor that wastes the
room; on a laptop it takes room the list needs.

The width drags from a handle on the border — 7px of target over a 1px
line, because a 1px target is a target you miss — and the whole column
folds away from a button in the topbar. Both are remembered.

In localStorage rather than on the account: this is a property of the
screen you are sitting at, not of you. The same person on a laptop and a
wide monitor wants different answers, and syncing it would make one of
them wrong every time they switch.

The handle is also a slider for the keyboard. Arrow keys move it, Home
puts it back, and so does a double-click — a drag handle with no keyboard
equivalent is a setting some people simply cannot reach.

Folding is desktop-only, and the media query says so rather than leaving
it to chance: below the breakpoint the sidebar is a drawer that opens over
the page, and a collapsed drawer would open to nothing. Clamped to
200–480px in the store, so a stored value from an older build cannot make
it unusable either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 16:09:16 +02:00
committed by Kristian Wind
co-authored by Claude Opus 5
parent c4e9e1519b
commit 8c748c2a88
3 changed files with 205 additions and 3 deletions
+52
View File
@@ -286,6 +286,58 @@ class AppState {
export const app = new AppState();
/**
* How wide the sidebar is, and whether it is showing at all.
*
* Kept in localStorage rather than on the account: it is a property of the
* screen you are sitting at, not of you. The same person on a laptop and on a
* wide monitor wants different answers, and syncing it would make one of those
* two wrong every time they switch.
*/
const SIDEBAR_MIN = 200;
const SIDEBAR_MAX = 480;
const SIDEBAR_DEFAULT = 268;
function storedNumber(key, fallback) {
if (typeof localStorage === 'undefined') return fallback;
const raw = Number(localStorage.getItem(key));
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
}
class SidebarLayout {
width = $state(storedNumber('verdande:sidebar-width', SIDEBAR_DEFAULT));
collapsed = $state(
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:sidebar-collapsed') === '1'
);
setWidth(px) {
// Clamped here rather than in the drag handler, so every caller gets the
// same rule and a stored value from an older build cannot make the
// sidebar unusably narrow.
this.width = Math.round(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, px)));
this.#save('verdande:sidebar-width', String(this.width));
}
toggle() {
this.collapsed = !this.collapsed;
this.#save('verdande:sidebar-collapsed', this.collapsed ? '1' : '0');
}
reset() {
this.setWidth(SIDEBAR_DEFAULT);
}
#save(key, value) {
try {
localStorage.setItem(key, value);
} catch {
// Private browsing; the choice simply will not persist.
}
}
}
export const sidebar = new SidebarLayout();
/** Theme, kept in localStorage and applied to the document element. */
export const theme = {
get current() {