From 54aef9d81b1160a461536839d65173e810cd7aa8 Mon Sep 17 00:00:00 2001 From: Anders Hafreager Date: Sat, 15 Aug 2026 21:51:48 +0200 Subject: [PATCH 1/2] feat: collapsible sidebar The sidebar collapses to a 64px icon rail (chevron toggle next to the logo), keeping Home/Examples/projects/settings reachable via icons with tooltips. The choice persists in localStorage. Collapsed footer shows a single theme-flip button instead of the pair. Fixes #408 Co-Authored-By: Claude Fable 5 --- src/shell/Shell.test.tsx | 23 +++ src/shell/Sidebar.tsx | 349 ++++++++++++++++++++++++++------------- 2 files changed, 258 insertions(+), 114 deletions(-) diff --git a/src/shell/Shell.test.tsx b/src/shell/Shell.test.tsx index 01af31ff..5252a8f0 100644 --- a/src/shell/Shell.test.tsx +++ b/src/shell/Shell.test.tsx @@ -110,6 +110,29 @@ describe("Shell", () => { expect(localStorage.getItem("atomify_theme")).toBe("light"); }); + it("collapses the sidebar to an icon rail and persists the choice", async () => { + renderShell(); + + const sidebar = screen.getByTestId("sidebar"); + expect(sidebar.getAttribute("data-collapsed")).toBe("false"); + expect(screen.getByTestId("nav-examples")).toHaveTextContent( + "Example library", + ); + + await userEvent.click(screen.getByTestId("sidebar-collapse")); + + expect(sidebar.getAttribute("data-collapsed")).toBe("true"); + // Icon rail: the nav buttons stay clickable but drop their labels. + expect(screen.getByTestId("nav-examples")).not.toHaveTextContent( + "Example library", + ); + expect(localStorage.getItem("atomify_sidebar_collapsed")).toBe("1"); + + await userEvent.click(screen.getByTestId("sidebar-collapse")); + expect(sidebar.getAttribute("data-collapsed")).toBe("false"); + expect(localStorage.getItem("atomify_sidebar_collapsed")).toBe("0"); + }); + it("navigates to the example library and shows fetched examples", async () => { renderShell(); diff --git a/src/shell/Sidebar.tsx b/src/shell/Sidebar.tsx index eef3f9c6..4359e34e 100644 --- a/src/shell/Sidebar.tsx +++ b/src/shell/Sidebar.tsx @@ -2,9 +2,11 @@ * Global sidebar (ADR-003 §2): Home, Example library, the PROJECTS list with * color dots + running pulse, New project, and a footer with the theme * toggle, Settings, and the engine-loading chip (loading never blocks - * browsing — ADR-003 §6). + * browsing — ADR-003 §6). Collapsible to a narrow icon rail (persisted) so + * the simulation gets the horizontal room. */ +import { useState } from "react"; import type { CSSProperties, ReactNode } from "react"; import { useStoreActions, useStoreState } from "../hooks"; import type { ThemeName } from "../store/settings"; @@ -12,6 +14,7 @@ import { track } from "../utils/metrics"; import { useShellUI } from "./ShellContext"; import { AtomLogo, + ChevronRightIcon, GridIcon, HomeIcon, MoonIcon, @@ -21,12 +24,16 @@ import { } from "./icons"; import { ColorDot, PulseDot } from "./ui"; -const navButtonStyle = (active: boolean): CSSProperties => ({ +/** Persisted collapse choice — the rail should survive reloads. */ +const SIDEBAR_COLLAPSED_KEY = "atomify_sidebar_collapsed"; + +const navButtonStyle = (active: boolean, collapsed: boolean): CSSProperties => ({ display: "flex", alignItems: "center", - gap: 11, + justifyContent: collapsed ? "center" : "flex-start", + gap: collapsed ? 0 : 11, width: "100%", - padding: "8px 12px", + padding: collapsed ? "9px 0" : "8px 12px", borderRadius: 10, border: "none", cursor: "pointer", @@ -40,22 +47,28 @@ const navButtonStyle = (active: boolean): CSSProperties => ({ const NavButton = ({ active, + collapsed, onClick, - children, + icon, + label, testId, }: { active: boolean; + collapsed: boolean; onClick: () => void; - children: ReactNode; + icon: ReactNode; + label: string; testId?: string; }) => ( ); @@ -76,6 +89,20 @@ const Sidebar = () => { ); const ui = useShellUI(); + const [collapsed, setCollapsed] = useState( + () => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1", + ); + const toggleCollapsed = () => + setCollapsed((previous) => { + const next = !previous; + try { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? "1" : "0"); + } catch { + // best effort + } + return next; + }); + const themeButtonStyle = (mode: "dark" | "light"): CSSProperties => ({ width: 30, height: 28, @@ -89,11 +116,39 @@ const Sidebar = () => { color: theme === mode ? "#fff" : "var(--text-3)", }); + const collapseButton = ( + + ); + return ( From 0f83f5f49e48f5d034624d90454ba92f72923d40 Mon Sep 17 00:00:00 2001 From: Anders Hafreager Date: Sat, 15 Aug 2026 22:11:15 +0200 Subject: [PATCH 2/2] fix: guard the sidebar collapse localStorage read Review fix: localStorage access can throw (Safari private mode, sandboxed iframes); default to expanded on failure like every other storage read in the shell. Co-Authored-By: Claude Fable 5 --- src/shell/Sidebar.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/shell/Sidebar.tsx b/src/shell/Sidebar.tsx index 4359e34e..768b8c7e 100644 --- a/src/shell/Sidebar.tsx +++ b/src/shell/Sidebar.tsx @@ -89,9 +89,13 @@ const Sidebar = () => { ); const ui = useShellUI(); - const [collapsed, setCollapsed] = useState( - () => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1", - ); + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1"; + } catch { + return false; + } + }); const toggleCollapsed = () => setCollapsed((previous) => { const next = !previous;