From 05e0fdcc86cc65e1b70b20abb1d43be9c2d407e9 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Sun, 11 Oct 2026 21:04:15 +0000 Subject: [PATCH] Fix sidebar rail overflow on the chat page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The content offset was fine — the rail's glass panel was the problem. As a flex-1 item in the row-flex aside, the panel's automatic minimum size (min-width: auto) is its content's min-content width. The chat list's search input (an defaults to size=20) has a ~192px intrinsic width, which with the New chat button stretched the panel to 345px — 89px past the 268px rail — where it painted over the chat page: the header title, the composer placeholder ("sk about your notes...") and the context chip ("ts"). - min-w-0 on the panel so the rail width always wins and wide content truncates instead (the input and list rows already shrink/truncate). - Chat suite: assert the panel stays inside the rail and the main content starts after it, expanded and collapsed (the old checks counted elements, never their boxes — this is what slipped through). - README: history bullet + decision 49. SW -> freezmo-v36. Verified on the dev instance and a production build: panel 244px inside the 268px rail (60px in the 84px collapsed rail) at 1024-1920, content starts after the rail, all sidebar pages unchanged, dark + light, and the full e2e (12/12 suites, 516 checks). --- README.md | 22 ++++++++++++++++------ components/Sidebar.tsx | 8 ++++++-- public/sw.js | 2 +- tests/chat-test.mjs | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5c914a7..fce7805 100644 --- a/README.md +++ b/README.md @@ -765,11 +765,12 @@ have a step explained without leaving the app. allowlist and never accepts a model name from the client. - **Attachments.** Photos, PDFs and documents go through the existing vision/extraction step first (plain text), so any chat model works — text-only included. -- **History.** Conversations live in a local IndexedDB store: a list on the left on desktop, a - sheet on phones, with auto titles from your first question, rename, delete (with Undo) and a - search across titles and message text. Conversations remember their scope and model, reopen from - `?c=` (so a reload continues where you left off), stay readable offline, and are included in - the backup export and the encrypted sync — still never the key. +- **History.** Conversations live in a local IndexedDB store: the list sits in the main sidebar on + desktop (it takes the deck navigation's place on `/chat`), a sheet on phones, with auto titles + from your first question, rename, delete (with Undo) and a search across titles and message + text. Conversations remember their scope and model, reopen from `?c=` (so a reload continues + where you left off), stay readable offline, and are included in the backup export and the + encrypted sync — still never the key. - **From an answer.** Every answer can become a deck (**Turn into flashcards** opens New deck with the answer as notes, on the options step) or be appended to the scoped deck's notes (**Save to notes**, only offered when exactly one deck is in scope). @@ -1258,11 +1259,20 @@ Things I chose while building, with the reasoning: Assistant answers also offer **Turn into flashcards** (opens New deck with the answer as notes, landing on the options step) and **Save to notes** (appends to the single scoped deck, trimming at 40k characters). Motion comes from `lib/motion.ts`: message fade-up, sheet springs, a 150 ms - composer height transition — all skipped under reduced motion. Verified by the chat suite (89 + composer height transition — all skipped under reduced motion. Verified by the chat suite (92 checks: everything from phase 1 plus history persistence, reload, rename/search/delete/undo, both actions, the export payload, a two-device sync round-trip of a chat, offline readability and the desktop panel). +49. **The sidebar rail never lets its content widen it.** The desktop rail is a fixed-width `flex` + aside and its glass panel is a `flex-1` item, so the panel's automatic minimum size + (`min-width: auto`) is its content's min-content width. When the chat list moved into the + sidebar, its search input's intrinsic width (an `` defaults to `size=20`) stretched the + panel 89 px past the rail, where it painted over the page — the content offset itself was + correct, which is what made it look like the page had lost its left margin. The panel now + carries `min-w-0` so wide content truncates instead, and the chat suite asserts that the panel + stays inside the rail and the main content starts after it, expanded and collapsed. + 36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck flow and a 2-step homework flow that share the exact state and handlers with the desktop cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`); diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 8f74d0e..787ca9f 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -373,8 +373,12 @@ export function Sidebar() { isCollapsed ? "w-[84px]" : "w-[268px]", )} > - {/* One tall inset glass panel, so the ice scene shows around it. */} -
+ {/* One tall inset glass panel, so the ice scene shows around it. The + min-w-0 keeps it inside the rail: as a flex item its automatic + minimum size is the content's min-content width, and wide content + (e.g. the chat list's search input) would otherwise stretch the + panel past the rail and paint over the page. */} +
diff --git a/public/sw.js b/public/sw.js index 0fda80a..d02a97e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v35"; +const CACHE_VERSION = "freezmo-v36"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/chat-test.mjs b/tests/chat-test.mjs index 28c495e..c39cc3a 100644 --- a/tests/chat-test.mjs +++ b/tests/chat-test.mjs @@ -256,6 +256,38 @@ try { await desktop.page.waitForTimeout(1200); check("desktop: chat route opens with the sidebar", desktop.page.url().endsWith("/chat") && (await desktop.page.locator("aside").count()) >= 1); check("desktop: no tab bar on chat", (await desktop.page.locator("nav.tabbar").count()) === 0); + + /* The rail must not paint over the page: its glass panel stays inside the + rail and the chat content starts after it. Regression: a wide chat list + stretched the panel (flex item min-width: auto) past the rail and + covered the header title, composer and context chip. */ + const railMetrics = () => + desktop.page.evaluate(() => { + const aside = document.querySelector("aside[aria-label]"); + const panel = aside?.firstElementChild; + const main = document.querySelector("main"); + if (!aside || !panel || !main) return null; + const a = aside.getBoundingClientRect(); + const p = panel.getBoundingClientRect(); + const m = main.getBoundingClientRect(); + return { + panelInside: p.right <= a.right - 1, + contentClear: m.left >= a.right - 1, + gap: Math.round(m.left - p.right), + overflowing: [...panel.querySelectorAll("*")].some((el) => el.getBoundingClientRect().right > p.right + 1), + }; + }); + + const expandedRail = await railMetrics(); + check("desktop: sidebar panel stays inside the rail", expandedRail !== null && expandedRail.panelInside && !expandedRail.overflowing); + check("desktop: chat content starts after the sidebar", expandedRail !== null && expandedRail.contentClear && expandedRail.gap > 0); + + await desktop.page.getByRole("button", { name: "Collapse sidebar" }).click(); + await desktop.page.waitForTimeout(400); + const collapsedRail = await railMetrics(); + check("desktop: collapsed rail also stays inside and clear", collapsedRail !== null && collapsedRail.panelInside && !collapsedRail.overflowing && collapsedRail.contentClear); + await desktop.page.getByRole("button", { name: "Expand sidebar" }).click(); + await desktop.page.waitForTimeout(400); await desktop.context.close(); const phone = await makePage(browser);