From 442232bab21ef7b12988d6efc7608b563d99a2d3 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Wed, 8 Jul 2026 23:36:41 -0700 Subject: [PATCH] design(round 1): accessibility + layout pass (design-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the baseline design review's failing objective gates and layout defects; no backend or flow changes. Accessibility (was the ceiling — 3 of 4 objective gates failed): - Contrast to AA: lifted --text-faint (#6e6e6e→#8a8a8a) and --text-dim; darkened --accent-dim (#7c5cd6→#6a48e0) so white button labels reach 4.5:1; new --accent-bright (#c9b3ff) for accent text on the tinted active background (tree/project active rows, admin badge, invite/ghost buttons all lifted off sub-AA pairings). Same fix applied to the server-rendered auth pages, which now share the app's token values. - Keyboard + focus: file tree, project, and org-name rows are now focusable (tabindex/role + Enter/Space) with a global :focus-visible ring; restored input focus rings on app and auth pages. - Touch targets: every header + sidebar control is a 44x44 hit area on mobile; secondary file actions (History/Upload/Download) collapse under a "⋯ More" menu so the row still fits with zero horizontal overflow. Layout defects: - #meta no longer wraps to 5 lines / shoves the action buttons — single truncating line; #crumb truncates too. - Long tree filenames ellipsize (label span flex:1 min-width:0) instead of hard-clipping. Consistency: - Fixed the .markdown specificity leak: admin/history/onboarding views no longer inherit markdown type rules (content class toggles per view), so the admin type scale renders as declared. - Recolored the off-system gold Admin badge to the accent family; added a 3-step radius token scale; swapped the mojibake-ish ▣/⛛ markers. - Added a global [hidden] guard so explicit display rules can't override the hidden attribute. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs --- .claude/agents/beardrive-designer.md | 57 ++++++++++++++++ internal/webapp/authlocal.go | 30 +++++---- internal/webapp/static/app.js | 56 ++++++++++++++-- internal/webapp/static/index.html | 4 +- internal/webapp/static/style.css | 99 +++++++++++++++++++--------- 5 files changed, 198 insertions(+), 48 deletions(-) create mode 100644 .claude/agents/beardrive-designer.md diff --git a/.claude/agents/beardrive-designer.md b/.claude/agents/beardrive-designer.md new file mode 100644 index 0000000..01193f1 --- /dev/null +++ b/.claude/agents/beardrive-designer.md @@ -0,0 +1,57 @@ +--- +name: beardrive-designer +description: Product designer who reviews the BearDrive web app's visual design, UX, and layout. Drives the real UI with Playwright (headless Chromium via Bash + node) across desktop and mobile viewports, inspects computed styles, and reports prioritized design findings with concrete CSS/markup fixes and per-category scores. Use for a design/UX critique of the running web app. +tools: Bash, Read, Write, Glob, Grep +model: opus +--- + +You are a senior product designer doing a rigorous design review of the +BearDrive web application (a dark-themed, dependency-free file viewer + team +admin console, styled after Obsidian). You care about craft: visual +hierarchy, consistency, spacing rhythm, typography, color, motion, and — above +all — whether the interface communicates clearly and feels considered. You are +opinionated but specific: every critique names the element and proposes a fix. + +## What you're reviewing +The frontend is hand-written vanilla JS/CSS in +`internal/webapp/static/` (`index.html`, `app.js`, `style.css`) plus +server-rendered auth pages (`internal/webapp/authlocal.go`, the `authPage` +helper) and the public share page shell (`internal/webapp/shares.go`, +`sharedMarkdownShell`). Repo root: /Users/snow/workspace/runbear/sfs. + +## How to review +Drive the REAL UI with Playwright headless. A ready-to-use install is at the +path given in your task prompt (require("playwright") from that directory); +Chromium is already downloaded. Write small node scripts, run them with Bash, +and **capture screenshots of every distinct screen and state** into your +working directory (given in the prompt) with descriptive names. + +- Review at **desktop (1280×800)** and **mobile (390×844)** at minimum; spot-check a narrow tablet width for reflow. +- Cover every surface: sign-in / sign-up / verify / awaiting-approval pages, the empty/onboarding state, the file tree + markdown reading view, file history, the ⌘K command palette, the org-admin panel, the hub "Signup & access" settings, the share dialog/modal, and a public `/s/` share page. +- Exercise **states**, not just happy paths: hover, keyboard focus rings, active/selected, disabled, empty lists, error toasts, long file names / deep trees (overflow), and a very long document. +- Use the browser to read **computed styles** (font sizes, line-height, colors, contrast ratios, spacing, touch-target sizes) — back up claims with numbers, don't just eyeball. Read `style.css` for ground truth on the design system (CSS variables, scale). + +## What to evaluate +1. **Visual design** — typographic scale & rhythm, color usage and restraint, contrast (call out WCAG AA failures with ratios), spacing consistency (is there a coherent spacing scale?), border/radius/shadow consistency, iconography coherence (the UI mixes emoji and glyphs — assess), dark-theme execution. +2. **UX** — clarity of hierarchy and affordances, discoverability, feedback on actions, error/empty states, cognitive load, progressive disclosure, consistency of interaction patterns (e.g. native prompt()/confirm() vs in-app modals), copy/microcopy quality. +3. **Layout** — alignment and grid discipline, whitespace and density, responsive behavior and breakpoints, overflow handling (horizontal scroll is a defect), viewport fit, the sidebar/topbar/content composition. +4. **Accessibility** — color contrast, visible focus states, keyboard operability, touch-target sizes (≥44px on mobile), semantic markup / ARIA, labels on icon-only controls. + +## Reporting +Return (as your FINAL message — it goes to the orchestrator, not the user) a +structured report: + +- **Verdict** — 2-3 sentences: overall design maturity and the single most + impactful thing to change. +- **Category scores (1–5)** — Visual, UX, Layout, Accessibility, each with a one-line justification. +- **Findings** — numbered, ordered by impact. Each: severity + (critical / high / medium / polish), the exact element/screen, what's wrong + and why it matters, and a concrete fix (name the CSS property, value, + selector, or markup change). Cite screenshot filenames. +- **What's working** — briefly credit the strong design decisions, so the + signal isn't only negative. + +Rules: be concrete and back visual claims with measured values where you can. +Distinguish taste from defects — label subjective calls as such. Never edit +product code; you review only. If a Playwright script fails, debug it up to +twice, then fall back to reading the CSS/markup directly and note the fallback. diff --git a/internal/webapp/authlocal.go b/internal/webapp/authlocal.go index 470d258..58d595c 100644 --- a/internal/webapp/authlocal.go +++ b/internal/webapp/authlocal.go @@ -543,21 +543,25 @@ func authPage(w http.ResponseWriter, title, body string) { fmt.Fprintf(w, ` %s — BearDrive

%s

%s
`, html.EscapeString(title), html.EscapeString(title), body) } diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js index 4bcb419..7d3378e 100644 --- a/internal/webapp/static/app.js +++ b/internal/webapp/static/app.js @@ -113,8 +113,11 @@ async function loadProjects() { const row = document.createElement("div"); row.className = "row" + (currentProject && currentProject.id === p.id ? " active" : ""); row.textContent = p.name; - row.title = p.id; + row.title = p.name; + row.tabIndex = 0; + row.setAttribute("role", "button"); row.onclick = () => selectProject(p, null); + row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } }; ul.appendChild(li).appendChild(row); } nav.appendChild(ul); @@ -129,6 +132,7 @@ function selectProject(p, path) { $("crumb").textContent = ""; $("meta").textContent = ""; $("download").hidden = true; + $("content").className = "view"; $("content").innerHTML = `
Select a file to read it.
On a phone, tap ☰ to browse.
`; loadProjects(); // refresh active highlight updateOrgBar(); @@ -165,6 +169,7 @@ async function acceptInviteFromHash() { and — since any member can — offer to start a new project. */ function showEmptyState() { $("orgbar").hidden = true; + $("content").className = "view"; const auth = serverConfig.auth && serverConfig.auth.enabled; $("content").innerHTML = `
@@ -243,6 +248,7 @@ function updateOrgBar() { nm.textContent = org.name; nm.title = "Manage organization"; nm.onclick = () => showOrgAdmin(org); + nm.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); nm.click(); } }; const btn = $("invite-btn"); btn.hidden = org.role !== "owner"; btn.textContent = "Manage"; @@ -257,9 +263,10 @@ async function showOrgAdmin(org) { closeSidebarOnMobile(); $("crumb").textContent = org.name; $("meta").textContent = ""; - $("share-btn").hidden = $("history-btn").hidden = $("download").hidden = true; + $("share-btn").hidden = $("history-btn").hidden = $("download").hidden = $("more-btn").hidden = true; const owner = org.role === "owner"; const box = $("content"); + box.className = "view"; box.innerHTML = `

`; box.querySelector("#org-title").textContent = org.name + (owner ? "" : " · member"); const panel = box.querySelector(".admin"); @@ -455,8 +462,9 @@ async function showHubSettings() { try { pol = await getJSON("api/admin/policy"); } catch (e) { toast(e.message, true); return; } currentPath = null; markActive(); closeSidebarOnMobile(); $("crumb").textContent = "Signup & access"; - $("share-btn").hidden = $("history-btn").hidden = $("download").hidden = true; + $("share-btn").hidden = $("history-btn").hidden = $("download").hidden = $("more-btn").hidden = true; const box = $("content"); + box.className = "view"; box.innerHTML = `

Signup & access

Who can create an account on this hub, and how new accounts are vetted.

`; const panel = box.querySelector(".admin"); @@ -523,8 +531,9 @@ async function showPending() { try { pending = (await getJSON("api/admin/pending")).pending || []; } catch { } currentPath = null; markActive(); $("crumb").textContent = "Pending signups"; - $("share-btn").hidden = $("history-btn").hidden = $("download").hidden = true; + $("share-btn").hidden = $("history-btn").hidden = $("download").hidden = $("more-btn").hidden = true; const box = $("content"); + box.className = "view"; box.innerHTML = `

Pending signups

`; const panel = box.querySelector(".admin"); const list = el(panel, "div", "admin-list"); @@ -590,8 +599,14 @@ function renderNode(n) { chev.className = "chev"; chev.textContent = "▾"; // ▾ const label = document.createElement("span"); + label.className = "label"; label.textContent = n.name; row.append(chev, label); + // Keyboard-operable: the row behaves as a button. + row.tabIndex = 0; + row.setAttribute("role", "button"); + row.title = n.name; + row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } }; li.appendChild(row); if (n.dir) { if (serverConfig.mode === "hub") { @@ -633,7 +648,9 @@ async function openFile(p) { const dl = $("download"); dl.href = apiBase + "download?path=" + encodeURIComponent(p); dl.hidden = false; + $("more-btn").hidden = false; const content = $("content"); + content.className = "markdown"; // document view: markdown type rules apply content.innerHTML = ""; $("meta").textContent = ""; try { @@ -803,6 +820,8 @@ async function showHistory(q) { $("crumb").textContent = "History — " + title; $("meta").textContent = ""; $("download").hidden = true; + content.className = "view"; + $("more-btn").hidden = true; content.innerHTML = ""; const wrap = document.createElement("div"); wrap.className = "history"; @@ -1137,6 +1156,35 @@ $("palette-overlay").addEventListener("click", (e) => { /* Visible search affordance in the top bar → opens the palette. */ $("search-btn").addEventListener("click", paletteOpen); +/* Mobile "⋯ More": the secondary file actions (History, Upload, Download) + collapse behind one 44px button so every target stays tappable without + the header overflowing. The menu proxies to the real buttons, so their + behavior and visibility rules are the single source of truth. */ +function buildMoreMenu() { + const menu = $("more-menu"); + menu.innerHTML = ""; + const items = [ + ["History", $("history-btn")], + ["Upload", $("upload-btn")], + ["Download", $("download")], + ].filter(([, el]) => el && !el.hidden); + for (const [label, el] of items) { + const b = document.createElement("button"); + b.className = "more-item"; + b.textContent = label; + b.onclick = () => { $("more-menu").hidden = true; el.click(); }; + menu.appendChild(b); + } + return items.length; +} +$("more-btn").addEventListener("click", (e) => { + e.stopPropagation(); + const menu = $("more-menu"); + if (menu.hidden) { buildMoreMenu(); menu.hidden = false; } + else menu.hidden = true; +}); +document.addEventListener("click", () => { $("more-menu").hidden = true; }); + /* Mobile: the sidebar is off-canvas; a hamburger toggles it. */ function toggleSidebar() { document.body.classList.toggle("sb-open"); } function closeSidebarOnMobile() { document.body.classList.remove("sb-open"); } diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html index fcc5541..54e55bb 100644 --- a/internal/webapp/static/index.html +++ b/internal/webapp/static/index.html @@ -21,7 +21,7 @@
- +
@@ -36,6 +36,8 @@ + +
Select a file to read it.
diff --git a/internal/webapp/static/style.css b/internal/webapp/static/style.css index 35bc35c..ae77c44 100644 --- a/internal/webapp/static/style.css +++ b/internal/webapp/static/style.css @@ -6,15 +6,23 @@ --bg-active: #3f3550; --border: #363636; --text: #dadada; - --text-dim: #9a9a9a; - --text-faint: #6e6e6e; + --text-dim: #a6a6a6; /* AA on --bg and --bg-side */ + --text-faint: #8a8a8a; /* raised from #6e6e6e → ~4.7:1 on --bg */ --accent: #a882ff; - --accent-dim: #7c5cd6; + --accent-bright: #c9b3ff; /* accent text on the tinted --bg-active (AA) */ + --accent-dim: #6a48e0; /* darker so white labels reach 4.5:1 */ --code-bg: #2a2a2a; + /* radius scale: controls / cards / overlays */ + --r-ctl: 6px; + --r-card: 8px; + --r-over: 12px; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; } * { box-sizing: border-box; } +[hidden] { display: none !important; } /* explicit display rules must not override the hidden attribute */ html, body { height: 100%; margin: 0; } +#more-btn { display: none; } /* mobile-only; shown in the responsive block */ body { display: flex; background: var(--bg); @@ -66,9 +74,10 @@ body { overflow: hidden; text-overflow: ellipsis; } -#projects .row::before { content: "▣ "; color: var(--text-faint); } +#projects .row::before { content: "◇"; margin-right: 7px; color: var(--text-faint); } #projects .row:hover { background: var(--bg-hover); color: var(--text); } -#projects .row.active { background: var(--bg-active); color: var(--accent); } +#projects .row.active { background: var(--bg-active); color: var(--accent-bright); box-shadow: inset 2px 0 0 var(--accent); } +#projects .row.active::before { color: var(--accent); } #tree { flex: 1; overflow-y: auto; padding: 8px 6px 24px; font-size: 13.5px; } #orgbar { display: flex; @@ -82,14 +91,14 @@ body { } #orgbar[hidden] { display: none; } #org-name { cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -#org-name::before { content: "⛛ "; color: var(--text-faint); } +#org-name::before { content: "◈"; margin-right: 7px; color: var(--text-faint); } #org-name:hover { color: var(--text); } #invite-btn { border: none; cursor: pointer; font: inherit; font-size: 11.5px; - padding: 2px 10px; border-radius: 5px; - background: var(--bg-active); color: var(--text-dim); + padding: 3px 10px; border-radius: var(--r-ctl); + background: var(--bg-active); color: var(--text); } -#invite-btn:hover { color: var(--text); background: var(--bg-hover); } +#invite-btn:hover { color: #fff; background: var(--bg-hover); } #tree ul { list-style: none; margin: 0; padding-left: 14px; } #tree > ul { padding-left: 4px; } #tree li > .row { @@ -105,7 +114,8 @@ body { text-overflow: ellipsis; } #tree li > .row:hover { background: var(--bg-hover); color: var(--text); } -#tree li > .row.active { background: var(--bg-active); color: var(--accent); } +#tree li > .row.active { background: var(--bg-active); color: var(--accent-bright); box-shadow: inset 2px 0 0 var(--accent); } +#tree li > .row .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } #tree .chev { width: 12px; flex: none; font-size: 10px; color: var(--text-faint); transition: transform .12s; } #tree li.collapsed > ul { display: none; } #tree li.collapsed > .row .chev { transform: rotate(-90deg); } @@ -114,6 +124,7 @@ body { /* ---- main pane ---- */ #main { flex: 1; display: flex; flex-direction: column; min-width: 0; } #topbar { + position: relative; display: flex; align-items: center; gap: 12px; @@ -121,21 +132,24 @@ body { border-bottom: 1px solid var(--border); min-height: 46px; } -#crumb { font-size: 13px; color: var(--text-dim); } -#meta { flex: 1; font-size: 12px; color: var(--text-faint); text-align: right; } +#crumb { font-size: 13px; color: var(--text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Single truncating line — never let author/time metadata wrap and shove + the action buttons around. */ +#meta { flex: 1; min-width: 0; font-size: 12px; color: var(--text-faint); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .btn { flex: none; - padding: 4px 12px; - border-radius: 6px; + padding: 5px 12px; + border-radius: var(--r-ctl); background: var(--accent-dim); color: #fff; font-size: 12.5px; text-decoration: none; + border: none; cursor: pointer; font-family: inherit; } .btn:hover { background: var(--accent); } #content { flex: 1; overflow-y: auto; padding: 28px 48px 80px; } -#history-btn, #share-btn { border: none; cursor: pointer; font: inherit; font-size: 12.5px; background: var(--bg-active); color: var(--text-dim); } -#history-btn:hover, #share-btn:hover { color: var(--text); background: var(--bg-hover); } +#history-btn, #share-btn { border: none; cursor: pointer; font: inherit; font-size: 12.5px; background: var(--bg-active); color: var(--text); } +#history-btn:hover, #share-btn:hover { color: #fff; background: var(--bg-hover); } .dir-history { margin-left: auto; padding: 0 4px; visibility: hidden; color: var(--text-faint); font-size: 11px; } #tree li > .row:hover .dir-history { visibility: visible; } .dir-history:hover { color: var(--text); } @@ -223,8 +237,8 @@ body { /* ---- vault header actions ---- */ .vault-actions { display: flex; align-items: center; gap: 8px; } -.adminbar { border: none; cursor: pointer; font: inherit; font-size: 11.5px; padding: 2px 8px; border-radius: 5px; background: #4a3a12; color: #f5c451; } -.adminbar:hover { background: #5c4816; } +.adminbar { border: none; cursor: pointer; font: inherit; font-size: 11.5px; padding: 3px 9px; border-radius: var(--r-ctl); background: var(--bg-active); color: var(--accent-bright); } +.adminbar:hover { background: var(--bg-hover); color: #fff; } .icon-btn2 { border: none; background: transparent; color: var(--text-faint); font-size: 15px; cursor: pointer; padding: 0 2px; line-height: 1; } .icon-btn2:hover { color: var(--text); } @@ -233,8 +247,8 @@ body { .icon-btn:hover { color: var(--text); background: var(--bg-hover); } #search-btn { display: inline-flex; align-items: center; gap: 6px; } #search-btn kbd { font: 11px var(--mono, ui-monospace, Menlo, monospace); background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 0 5px; color: var(--text-faint); } -.btn.ghost { background: var(--bg-active); color: var(--text-dim); } -.btn.ghost:hover { background: var(--bg-hover); color: var(--text); } +.btn.ghost { background: var(--bg-active); color: var(--text); } +.btn.ghost:hover { background: var(--bg-hover); color: #fff; } /* ---- onboarding empty state ---- */ .onboard { max-width: 560px; margin: 8vh auto 0; } @@ -296,25 +310,50 @@ body { #sidebar { position: fixed; z-index: 60; top: 0; left: 0; height: 100%; transform: translateX(-100%); transition: transform .2s ease; box-shadow: 0 0 40px rgba(0,0,0,.5); } body.sb-open #sidebar { transform: translateX(0); } body.sb-open #sb-backdrop { display: block; position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 50; } - .icon-btn { display: inline-block; } + .icon-btn { display: inline-flex; } #content { padding: 20px 18px 60px; } - #topbar { padding: 8px 14px; gap: 8px; } + #topbar { padding: 6px 10px; gap: 4px; } #crumb { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #search-btn kbd { display: none; } - /* On a phone the header shrinks every control to an icon so all the - per-file actions stay reachable (the R2 mistake was hiding them). */ - #topbar .btn { font-size: 0; gap: 0; padding: 5px 8px; } - #search-btn::before { content: "🔍"; font-size: 15px; } - #share-btn::before { content: "↗"; font-size: 15px; } - #history-btn::before { content: "🕘"; font-size: 14px; } - #upload-btn::before { content: "⬆"; font-size: 14px; } - #download::before { content: "⬇"; font-size: 14px; } + /* Every header control is a 44×44 hit area (WCAG target size). Only the + primary actions (menu, search, share) stay in the bar; History, Upload + and Download collapse under "⋯ More" so the row never overflows. */ + #topbar .btn, #topbar .icon-btn { + min-width: 44px; min-height: 44px; padding: 0; + display: inline-flex; align-items: center; justify-content: center; + font-size: 0; gap: 0; + } + #menu-btn { font-size: 20px; } + #search-btn::before { content: "🔍"; font-size: 16px; } + #share-btn::before { content: "↗"; font-size: 17px; } + #more-btn:not([hidden]) { display: inline-flex; font-size: 20px; } + #history-btn, #upload-btn, #download { display: none !important; } #meta { display: none; } + /* Sidebar header controls also meet the 44px target on touch. */ + .icon-btn2, #signout, .adminbar { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; } + #vault { padding: 8px 12px; } .markdown, .admin, .onboard, .history { max-width: 100%; } .markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; } .ob-row { flex-direction: column; } } +/* ---- "More" overflow menu (mobile) ---- */ +#more-btn.icon-only { font-size: 15px; } +#more-menu { + position: absolute; right: 10px; top: 100%; z-index: 80; + background: var(--bg-side); border: 1px solid var(--border); + border-radius: var(--r-card); box-shadow: 0 12px 32px rgba(0,0,0,.5); + padding: 6px; min-width: 160px; +} +#more-menu[hidden] { display: none; } +.more-item { + display: block; width: 100%; text-align: left; + min-height: 44px; padding: 0 14px; + background: transparent; border: none; cursor: pointer; + color: var(--text); font: inherit; font-size: 14px; border-radius: var(--r-ctl); +} +.more-item:hover { background: var(--bg-hover); } + /* ---- markdown ---- */ .markdown { max-width: 820px; } .markdown h1, .markdown h2, .markdown h3, .markdown h4 {