Merge pull request #22 from runbear-io/feat/mobile-polish

Mobile layout quality: designer-scored to 9/9/9/9/8 across four viewports
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-13 21:59:13 -07:00
committed by GitHub
8 changed files with 185 additions and 15 deletions
+113
View File
@@ -0,0 +1,113 @@
# Spec: Mobile layout quality for the BearDrive web app
Authoritative spec for the mobile-polish goal loop. The bar is met by
independent review: a `beardrive-designer` subagent audits the running app
and returns scores; the implementing agent never scores its own work.
## Harness
Seeded hub: `BDRIVE_E2E_SERVE=1 go test -count=1 -timeout 8h -run
TestE2EServe ./internal/webapp` → http://localhost:8993 (state resets per
start). Accounts (password `e2e-pass-1` for all): `e2e@example.com`
(admin — insights, admin bar, org Manage), `member@example.com` (member),
`solo@example.com` (no org — onboarding empty state).
## Viewports (all must pass)
| Name | Size |
|---|---|
| phone-small | 360×800 |
| phone | 390×844 |
| phone-landscape | 844×390 |
| tablet | 768×1024 |
## Surfaces to audit (per viewport)
1. Login page (`/auth/login`) — server-rendered, still in scope.
2. Project home: connect guide (tabs, code blocks + copy buttons) and, as
admin, the embedded Insights below (treemap/scatter/hot-path/matrix
SVGs must not overflow or become unreadably small).
3. Folder listing (`/<pid>/notes`): rows, heat dots, Recent changes feed.
4. Markdown file view (`/<pid>/index.md`): content, breadcrumbs, meta
line, topbar actions (Share/History/Upload/Download vs the ⋯ menu —
targets must stay tappable, header must not wrap or overflow).
5. History (`/<pid>/history`): entry rows, expandable notes.
6. Dedicated Insights (`/<pid>/insights`).
7. Org admin panel (Manage) and hub settings (Admin) — lists, selects,
buttons, toggles.
8. Off-canvas sidebar: hamburger open/close, backdrop, tree interaction,
auto-close after selecting a file.
9. ⌘K palette (on tablet; on phones verify the Search button opens it and
it is usable).
10. Modals (new-project prompt, share dialog, confirms) and toasts.
11. Onboarding empty state (as `solo@`).
## Scoring rubric (designer subagent returns 010 per category)
- **layout** — nothing overflows the viewport; no horizontal page scroll;
wide content (code blocks, tables, SVGs, URLs) scrolls in its own box.
- **readability** — type sizes/line lengths sane; nothing truncated
without recourse; contrast preserved.
- **tap-targets** — interactive elements ≥ ~44px effective target; no
overlapping/cramped controls.
- **navigation** — sidebar, breadcrumbs, back/forward, deep links all
usable one-handed; nothing reachable only by hover.
- **polish** — spacing, alignment, safe-area behavior, orientation change.
Findings carry severity (high/medium/low), the viewport+surface, and a
concrete CSS/markup fix suggestion.
## Exit bar
Two CONSECUTIVE designer rounds with every category ≥ 8/10 and zero
high-severity mobile findings across all viewports. Desktop (1360×900)
spot-checked each round — no regressions introduced by mobile fixes.
## Rules
- Fix in `internal/webapp/frontend` (prefer `src/style.css`; markup only
when CSS can't). Rebuild committed static (`npm run build`) after every
change; `npm run e2e` (42 specs) must stay green each iteration.
- No new runtime dependencies; no desktop redesign — mobile fixes only.
- Never commit `internal/webapp/manual_serve_test.go`.
- Branch `feat/mobile-polish`, commit per iteration
(`feat(webapp): [mobile] ...`), PR at the end; never merge or deploy.
- Disputed/won't-fix findings: record below with reasoning, count them
out of the exit bar only if justified here.
## Scorecard (append one row per designer round)
| Round | layout | readability | tap-targets | navigation | polish | high-sev findings |
|---|---|---|---|---|---|---|
| 1 (before fixes) | 5 | 7 | 6 | 8 | 7 | 1 (topbar overflow at 768/844 — breakpoint gap) |
| 2 (after round-1 fixes) | 9 | 8 | 8 | 9 | 8 | 0 (5 low cosmetics, fixed before round 3) |
| 3 (after low-fixes) | 6 | 6 | 7 | 9 | 6 | 1 (REGRESSION: URL rows collapsed by the wrap fix — streak reset, fixed for round 4) |
| 4 (after round-3 fixes) | 9 | 9 | 8 | 9 | 8 | 0 (4 lows: chips/tabs/modal-input heights + share row at 360, fixed before round 5) |
| 5 (confirmation) | 9 | 9 | 9 | 9 | 8 | 0 — EXIT BAR MET (rounds 4+5 consecutive passes) |
## Won't-fix / disputed
- **Palette footer shows keyboard hints ("↑↓ · ↵ · esc") on touch** (round-5
low #1, second half): cosmetic copy noise; tap interaction fully works.
Changing the hint per-viewport adds conditional copy for no functional
gain. (The ⌘K badge half of the finding was a real bug — the React port
dropped `id="search-btn"`, so the existing hide rule never matched; fixed
post-exit, e2e green.)
- **Share dialog's Done sits alone on its last row at ≤430** (round-5 low
#2): reviewer marked it "intended destructive-isolation behavior…
subjective/taste — no action needed".
- **Server auth pages use 44px controls via their own inline CSS**
(`authlocal.go`), the one fix outside `frontend/` — the login page is a
spec surface but is server-rendered, unreachable from the frontend
stylesheet.
## Status
GOAL COMPLETE (2026-07-14): rounds 4 and 5 both scored every category ≥8
with zero high-severity findings. Five designer rounds total; round 3
caught and reset on a regression the loop itself introduced — the
independent-scoring design worked as intended.
## Status / blockers
(record and stop rather than deviate)
+1
View File
@@ -597,6 +597,7 @@ button:focus-visible{outline:2px solid #ffcf85;outline-offset:2px}
.alt{margin-top:16px;font-size:12.5px;color:#868b93}
.alt a{color:#ffcf85;text-decoration:none}
.alt a:hover{text-decoration:underline}
@media (max-width:900px){input{height:44px}button{height:44px}}
code{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);padding:2px 6px;border-radius:5px;
font-family:ui-monospace,Menlo,monospace}
</style></head><body><div class="card"><div class="logo">🐻</div><h1>%s</h1>%s</div></body></html>`,
@@ -349,7 +349,7 @@ export default function Browser(props: {
meta={uploadStatus || meta}
actions={
<>
<button className="btn ghost" title="Search (⌘K)" onClick={() => setPaletteOpen(true)}>
<button id="search-btn" className="btn ghost" title="Search (⌘K)" onClick={() => setPaletteOpen(true)}>
<Icon name="search" /> <span className="lbl">Search</span> <kbd>K</kbd>
</button>
{canShare && (
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { api } from "../api/http";
import { copyText } from "../util";
import { toast } from "../toast";
@@ -14,6 +15,13 @@ export function ShareDialog({
onClose: () => void;
}) {
const token = url.split("/s/")[1];
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="modal-back" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal">
+55 -7
View File
@@ -294,7 +294,7 @@ button, input, a.btn { font-family: inherit; }
.gd-desc { margin: 2px 0 8px 32px; color: var(--text-faint); font-size: 13px; line-height: 1.5; }
.gd-extra { font-size: 12.5px; margin-top: 6px; }
.gd-code { position: relative; margin: 6px 0 6px 32px; padding: 10px 72px 10px 12px; background: var(--bg-raise); border: 1px solid var(--border); border-radius: var(--r-card); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.6; color: var(--text); overflow-x: auto; white-space: pre; }
.gd-copy { position: absolute; top: 7px; right: 7px; font: inherit; font-family: inherit; font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 6px; border: 1px solid var(--border-2); background: var(--surface); color: var(--text-faint); cursor: pointer; }
.gd-copy { position: absolute; top: 7px; right: 7px; font: inherit; font-family: inherit; font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 6px; border: 1px solid var(--border-2); background: var(--bg-raise); color: var(--text-faint); cursor: pointer; box-shadow: -14px 0 12px -6px var(--bg-raise); }
.gd-copy:hover { color: var(--accent-bright); border-color: var(--accent-dim); }
.gd-done { margin: 22px 0 8px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); color: var(--text-faint); font-size: 13px; line-height: 1.5; }
.home-insights { margin-top: 30px; padding-top: 22px; border-top: 1px solid var(--border); }
@@ -359,9 +359,9 @@ button, input, a.btn { font-family: inherit; }
.hpath { font-weight: 500; cursor: pointer; color: var(--text); font-size: 13px; }
.hpath:hover { color: var(--accent-bright); }
.htime { margin-left: auto; color: var(--text-faint); font-size: 12px; font-variant-numeric: tabular-nums; }
.hmeta { display: flex; gap: 14px; margin-top: 4px; padding-left: 23px; font-size: 12px; color: var(--text-dim); }
.hmeta { display: flex; align-items: center; gap: 14px; margin-top: 4px; padding-left: 23px; font-size: 12px; color: var(--text-dim); }
.hdev, .hsize { color: var(--text-faint); }
.hsize { font-variant-numeric: tabular-nums; }
.hsize { font-variant-numeric: tabular-nums; white-space: nowrap; flex: none; }
.hnote { margin-top: 4px; padding-left: 23px; font-size: 12px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.hnote:hover { color: var(--text); }
.hnote.open { white-space: normal; overflow-wrap: anywhere; }
@@ -410,8 +410,12 @@ button, input, a.btn { font-family: inherit; }
#sb-backdrop { display: none; }
/* ---- responsive ---- */
@media (max-width: 760px) {
/* ---- responsive ----
900px covers tablet portrait (768) and phone landscape (844): with the
fixed 264px sidebar plus the full topbar those widths overflow the page
(measured 849px needed at 768) they need the off-canvas/collapsed
chrome just like phones. */
@media (max-width: 900px) {
#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,.6); }
body.sb-open #sidebar { transform: translateX(0); }
body.sb-open #sb-backdrop { display: block; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 50; }
@@ -423,20 +427,64 @@ button, input, a.btn { font-family: inherit; }
#topbar .btn .ico { width: 18px; height: 18px; }
#more-btn:not([hidden]) { display: inline-flex; }
#history-btn, #upload-btn, #download { display: none !important; }
/* Desktop right-aligns the actions via #meta's flex:1 with meta
hidden here, the crumb becomes the spacer so Search/Share/ pin to
the top-right corner. */
#meta { display: none; }
#crumb { flex: 1; }
#vault { padding: 0 8px 0 12px; }
.icon-btn2, #signout, .adminbar { min-width: 44px; min-height: 44px; }
#tree li > .row, #projects .row { height: 44px; }
#invite-btn { min-height: 40px; padding: 0 14px; }
#invite-btn { min-height: 44px; padding: 0 14px; }
#org-name { min-height: 44px; }
.nav-add { min-width: 44px; min-height: 44px; }
.markdown, .admin, .onboard, .history, .dirlist { max-width: 100%; }
.markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; }
.ob-row { flex-direction: column; }
.ob-row input { flex: none; min-height: 44px; }
/* Admin rows: 27-28px selects/buttons are too small to tap; let rows
wrap so the controls keep room next to long names/URLs. */
.admin-item { flex-wrap: wrap; row-gap: 8px; padding: 12px 14px; }
.admin-item select { height: 44px; }
.ai-btn, .ai-del { height: auto; min-height: 44px; padding: 0 12px; }
/* No hover on touch: a truncated email/URL would be unreadable wrap
it. The name/URL takes the whole row (tag + buttons drop below):
without full-row basis, a long flex:none .ai-tag starves the URL down
to one character per line. break-word prefers natural break points
(@, .) and still splits long unbroken URLs when it must. */
.admin-item .ai-main { flex: 1 1 100%; white-space: normal; overflow-wrap: break-word; }
/* The code-block Copy button needs a real touch target; give one-line
blocks the height to hold it. */
.gd-code { min-height: 62px; padding-top: 12px; padding-bottom: 12px; }
.gd-copy { min-height: 44px; padding: 0 14px; }
.gd-tab { min-height: 44px; }
.in-lens-btn { min-height: 44px; padding: 0 14px; }
.modal-input { height: 44px; }
.modal-actions button { height: auto; min-height: 44px; }
.pbtn { height: auto; min-height: 44px; }
#palette-results li { height: auto; min-height: 44px; }
.more-item { min-height: 44px; }
}
/* The destructive Revoke must not sit flush against the safe Done on a
touch row push it to the far side (share dialog only; prompts and
confirms have no .ai-del). */
.modal-actions .ai-del { margin-right: auto; }
@media (max-width: 430px) {
/* The name wins the row: drop the verbose meta (the heat dot still
conveys activity) instead of truncating "readme.md" to "readme...." */
.dl-meta { display: none; }
.ai-tag { font-size: 11px; }
.htime { white-space: nowrap; font-size: 12px; }
.hline { flex-wrap: wrap; }
/* Four share-dialog buttons don't fit one row at 360 the destructive
Revoke takes its own line rather than sitting 9px from Done. */
.modal-actions .ai-del { flex: 0 0 100%; }
}
/* ---- markdown reading view ---- */
.markdown { max-width: 704px; margin: 0 auto; }
.markdown { max-width: 704px; width: 100%; margin: 0 auto; }
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { color: #f4f6f9; line-height: 1.25; letter-spacing: -.018em; margin: 1.5em 0 .5em; text-wrap: balance; }
.markdown h1:first-child { margin-top: 0; }
.markdown h1 { font-size: 1.85em; font-weight: 660; letter-spacing: -.024em; }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-BmKSe0_Y.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CemqdDtO.css">
<script type="module" crossorigin src="/assets/index-ISEZlu5u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D_DgiVAj.css">
</head>
<body>
<svg width="0" height="0" class="sprite" aria-hidden="true" focusable="false">