web: 修复移动端布局与首次运行死胡同,补齐可访问性地板,客户端脱离模板字符串 - #375
Open
oratis wants to merge 12 commits into
Open
Conversation
oratis
force-pushed
the
claude/opt-web-ui
branch
from
September 7, 2026 04:15
3eda740 to
3ffd015
Compare
oratis
force-pushed
the
claude/opt-billing
branch
from
September 7, 2026 04:15
605da16 to
20df61e
Compare
…e chat column fit its pane (UX-2) Two stacked failures made the web shell unusable on phones since the right panel started collapsed by default (#367): - body.rb-collapsed .frame (specificity 0,1,1) outranked the ≤720px single-column .frame rule (0,0,1), so a 375px viewport got the desktop "300px 1fr" grid: sidebar 300px, chat 75px. The collapse rule now lives inside @media (min-width: 721px); the ≤720px block owns the phone layout and the panel stays display:none at every width as before. - #viewChat's implicit auto grid column could never be narrower than the function bar's min-content (10 flex:none icon buttons + chip + find box ≈ 680px), so #log / #form / #fnbar grew to 681px inside any pane narrower than that and .main (overflow:hidden) clipped the send button away. This also hit tablets: at 768px the pane is 468px wide and #sendBtn sat at x=865–961. The column is now minmax(0, 1fr) and .fnbar is a min-width:0 overflow-x:auto strip, so it scrolls inside the pane instead of widening it. At ≤720px the bar additionally drops the five quick-panel buttons (all reachable from the Memory view; pairing is loopback-only anyway) and the right-panel toggle (nothing to toggle there), so chip · KB · mail · find · theme fit a 375px row without scrolling; log/composer padding tightens and .frame uses 100dvh where supported. Measured in headless Chromium 151 against a born-soul scratch instance (before → after): 375×812, rail collapsed (default): .frame columns 300px 75px → 375px; .main width 75 → 375; .main scrollWidth/clientWidth 681/75 → 375/375; document scrollWidth/innerWidth 375/375 → 375/375; #sendBtn x 800–884 (off-screen) → 277–361 visible; #log/#form 681 → 375. 375×812, rail open: .main scrollWidth 681/375 → 375/375; #sendBtn 583–667 (off-screen) → 277–361 visible. 768×900: #sendBtn 865–961 (off-screen) → 652–748 visible; .fnbar scrolls 528/468 instead of widening the column. 1024 and 1440 (both rail states): unchanged (.main 724 / 1140 / 820). lisa-html-snapshot.test.ts: byte pin recomputed (310580 bytes). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit cad0f222af235c70fa4df8c467d00feb36c56ceb)
…, live regions (UX-3) The shell had no visible keyboard focus at all, secondary text sat below the WCAG AA 4.5:1 floor in both themes, and a dozen labels were rendered at 8.5–11px where a 13" laptop at 100% zoom already loses them. Screen readers got nothing when Lisa started or finished a turn, and the "needs you" count changed silently. Focus - New --focus-ring / --focus-ring-offset tokens and one global :focus-visible rule (2px solid var(--accent), 2px offset). :focus-visible, not :focus, so a mouse click never paints a ring. Tree rows are real <button>s (makeLisaLeaf / makeAgentLeaf), so they are covered by the same rule. Text fields keep their more specific accent-border + halo :focus treatment. Contrast (measured with the WCAG 2.x formula, alpha-composited over the surface each token is actually painted on — numbers from the new lisa-css.test.ts): - Nebula --fg-3 #6c7398 → #8189ae: bg-deep 4.34→5.76, bg-1 4.15→5.50, bg-card 3.86→5.12, bg-3 chips 3.44→4.57. - Calm --fg-3 #8a919f → #5f6878: white 3.15→5.62, bg-deep 5.24, bg-2 4.92, bg-3 4.66. - --fg-faint is now decorative-only (pips, rules, disabled fills). The three places it was used as copy — .session-empty, .view-empty, .modal-body .empty, plus the .ttime/.stime timestamps — moved to --fg-3. Type scale - Every text rule below 11.5px raised to 11.5px (~60 rules: badges, chips, timestamps, stat captions at 8.5px, role labels, kv rows, cfg labels…). Three rules stay small because they draw glyphs, not text: .twist (the 8px tree arrow), .agent-glyph.mini (a single letter in a 14px box), and the unread dot. lisa-css.test.ts walks every font-size in the sheet and fails on a new offender outside that allowlist. Touch targets - .fbtn 34→36px; .new-btn 24×21 → 36×28; .session-ctrl .mc and .attach-rm get min-height/min-width. At ≤720px an inset ::after on each small control extends the hit area to ≥44px (WCAG 2.5.8) without growing the visible button — the controls are position:relative and ::after was unused. Announcements - #chatStatus (role=status, aria-live=polite, .sr-only) carries coarse turn state only — "Lisa is thinking / is replying / finished replying / the request failed". Deliberately not the streamed text, which would be re-announced on every paint. - #sbNeedsCount is aria-live and now appends a visually hidden noun, so it reads "2 agents need you" instead of "2". - The off-screen #fileInput gets tabindex=-1; it is opened from the + menu and was a dead Tab stop. Motion - One @media (prefers-reduced-motion: reduce) block silences the looping animations (pips, stars, record pulse, typewriter cursor), the reveal transitions and #log's smooth scroll. Chose the reduce-override form over wrapping each animation in no-preference: same effect, one place to add the next selector, and no risk of missing a declaration. State stays legible without motion — pips keep their colour, the record button stays red. New src/web/lisa-css.test.ts parses the token blocks out of the CSS string and asserts the ratios, the focus-ring rule and token, the 36px icon-button floor, the 11.5px minimum and the reduced-motion block, so a future "let's soften the secondary text" fails loudly. The byte-pin in lisa-html-snapshot.test.ts is re-pinned (310580 → 315861 bytes). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 01143e212a0b8cc7feadb9d78fe42078b9381244)
…rst screen (UX-4)
Two holes right after the birth ritual. (a) A session with no messages was
labelled with its raw id — "20260905-220846-9f7d58" in the tree, the context
chip, the title bar and the inspector, four places showing a string no human
reads. (b) #log had zero children, so the first thing a new user saw was an
empty pane whose only hint was the composer placeholder.
Naming
- sessionLabel() falls back to "New session · <relative time>" when
messageCount is 0 instead of the id; the first user message still wins the
moment there is one. Measured on a scratch instance: tree leaf, context chip
and title bar all read "New session · 2m".
- The id is demoted, never dropped: tree leaf title (already), new context-chip
title, new title-bar title, and the inspector's existing sub line
("<id> · <cwd>"). The title bar previously read "Lisa · 20260905-…"; it is
"Lisa · New session · 2m" with the id one hover away, which is what anyone
matching a session against ~/.lisa/sessions actually needs.
- setActiveSessionUI runs before /api/sessions lands, so the title bar asks the
sidebar closure through window.lisaSessionLabel and repaints from
renderSessionUI on every list refresh; it falls back to the id meanwhile.
Empty chat card (#chatEmpty, .chat-empty)
- One identity line reusing what the sidebar already fetched — "Lisa · born
2026-05-01 · 128 days" from #identitySub, no extra request.
- Three clickable starters. The first names her actual current pursuit when the
soul has one ("How is \"understand the codebase I live in\" going?" on the
scratch soul), so the card is about THIS Lisa, not a generic tour; without a
desire it degrades to "What's on your mind right now?". Clicking FILLS the
composer and focuses it — it never sends, so a mis-click cannot spend a model
call.
- Three ability lines (tools / knowledge / mail).
- It is drawn only after the first /api/history answer (an empty #log before
that just means the fetch is in flight) and is retired by the first real node
in the log: removeChatEmpty() sits in el(), in prependHistoryMessages, and in
the three idle SSE paths that append to #log directly. It repaints when
/api/soul and /api/island/ping land so the identity line and the desire-based
starter are not stale.
Verified in headless chromium at 1440×900 against a scratch instance (seeded
soul, placeholder key, --no-idle --no-reflect --no-mcp --no-plugins, port 5871,
no model call): card 560px wide and centred, all three starters render, a click
put the desire prompt in #input, zero console errors, and the first Tab stop
paints the new 2px rgb(106,212,255) focus ring at 2px offset.
New tests extract the served text of sessionLabel out of MAIN_CLIENT_JS and run
it in a vm sandbox, so the assertions are on the exact bytes the browser gets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit bc0ac6ddeebc4ac1aad4a719511cdfa8051b77ae)
…ent is blocked (UX-5) the inspector, mail, reflection and tokens. The only way back in was a 34px icon whose tooltip said "Collapse / expand the right panel" — nothing anywhere told you that two agents were sitting on a permission prompt. - .fbtn-badge on #fnPanel carries the needs-you count while the rail is collapsed (>9 renders "9+"), and the button's title/aria-label become "2 agents need you — open the right panel". Both revert to the plain toggle text when the count drops to zero or the rail is open, so nothing lingers. Warm token, not accent: it means "something is waiting on you", not "this control is on". #1a1206 on --warm is 12.8:1 in Nebula and 5.8:1 in Calm. - window.lisaRightbarAttention(count, needsDecision) is fed from renderNeeds on every roster tick. A "waiting" or pendingPermission agent counts as a decision; an errored one is badged but does NOT open the rail — an error is news, not a question. - The rail opens itself ONCE per page load, and only for a profile that has never driven the toggle. The manual toggle now persists lisaRightbarTouched, which permanently disarms the nudge. The auto-expand deliberately does not write lisaRightbar, so a reload returns to the collapsed default and one blocked agent can never silently become the user's layout — there is a test asserting that function body contains no setItem. Measured in headless chromium at 1440×900 against the scratch instance: fresh profile → collapsed, no badge; attention(2,false) → badge "2", still collapsed, .rightbar display:none; attention(2,true) → collapsed cleared, .rightbar display:flex, badge gone; one manual click → touched=1 stored, and a subsequent attention(4,true) leaves it collapsed with badge "4". Badge box 15×15px, rgb(255,208,102) on rgb(26,18,6), 11.5px. Zero page errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 394926affb741811daea313bfa5217b33c108e2d)
…e, and Cancel (UX-1)
The P0 dead end. A rejected key produced `401 {"type":"error","error":
{"type":"authentication_error",...}}` printed verbatim under the ritual, and
ENTER did location.reload() straight back into the same failing key. The gate
never returned — /api/config/status reports "configured" the moment a key is on
disk — so the only recovery was hand-editing ~/.lisa/config.env. The ritual had
no cancel either: the POST ran to completion whatever the user did.
Errors
- birthErrorCode(ev) understands the new frame ({kind,code,message,retryable})
and classifies an old server's raw payload by substring — 401/403/
authentication_error/invalid_api_key/unauthorized → auth, 429/rate_limit →
rate_limit, timed out/ETIMEDOUT → timeout, ECONNREFUSED/ENOTFOUND/fetch
failed → network, else unknown. An unrecognised code falls through to
classification rather than being trusted. Substring tests rather than
regexes on purpose: this file is a template literal, and every backslash
would need doubling.
- BIRTH_ERROR_TEXT carries one sentence per class; a test asserts none of them
contains "{". The provider payload lands on birthError.title — reachable on
hover and in a bug report, never on screen.
Recovery
- The gate is reopenable: openKeyGate({reconfigure, reason}) retitles it
"CHANGE · API · KEY", shows the reason banner (#cfgReason) explaining why the
form is back, and always clears the key field — retyping is the point.
- code "auth" (or retryable:false) offers "Change key"; everything else offers
"Try again" + "Change key".
- Saving from reconfigure mode restarts the ritual IN PLACE — no
location.reload(), so the page keeps its SSE connection, its log and its
scroll. resetBirthUI() clears steps/final/ENTER/error between runs.
Cancel
- The ritual fetch now carries an AbortController. A Cancel button sits in
#birthActions for the whole run and is withdrawn when the stream ends; it
aborts the request and returns to the gate with "Cancelled. Set a key and
Lisa will start again." An AbortError is never rendered as a failure.
Verified in headless chromium against a keyless scratch instance (empty
LISA_HOME, no key, port 5872) with fetch stubbed for the ritual, so no outbound
request and no model call: boot shows the gate focused on the key field; the
raw 401 payload renders as the auth sentence with the JSON only in title and a
single "Change key" that reopens the gate in CHANGE mode with the reason shown;
timeout/network/rate_limit/derived-ECONNREFUSED each render their sentence with
"Try again"+"Change key"; retryable:false collapses to "Change key"; during a
stream the only action is "Cancel" and clicking it aborts the fetch
(signal fired) and reopens the gate; a save from repair mode issued
/api/config/save then /api/birth with no page reload, replayed the steps and
revealed ENTER. Zero page errors throughout.
Known gap for another stream: /api/birth still does not listen for the client
disconnect, so an aborted ritual keeps inferring server-side until it finishes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit bd5ef57fdcd27c4f86be7f037981709f86972903)
The gate asked for an ANTHROPIC_API_KEY (required) and an optional
OPENAI_API_KEY. The CLI's `lisa doctor` lists 13 providers and the site
advertises "10+ LLM providers", so a DeepSeek/GLM/Qwen user could not complete
first run in the browser at all — the only route was editing config.env by hand.
- One table drives both surfaces. It is built from
/api/config/status.providers ({id, envKey, label, modelPrefixes, configured})
when the server reports one, and otherwise from a built-in list mirroring
src/providers/registry.ts: Anthropic, OpenAI, DeepSeek, Zhipu GLM, Aliyun
Qwen, Moonshot Kimi, Google Gemini and a custom OpenAI-compatible base URL.
Matching between the two is by envKey first — ids belong to the server, the
environment variable name is the stable identity — so a provider this client
has never heard of still renders, with a generic placeholder.
- The gate is now PROVIDER / KEY / MODEL, plus BASE URL for the custom entry
only. The key label shows the actual env var, the console link retargets per
provider, and a repair keeps the provider that just failed selected.
- The model field is prefilled as a placeholder, not a value, and is only sent
automatically for providers the server cannot auto-detect. Anthropic and
OpenAI are resolved from the key alone by resolveDefaultModel(); every other
provider would otherwise fall back to claude-sonnet-4-6 with a key that
cannot pay for it, so those pin LISA_MODEL.
- POST /api/config/save carries the new {keys:{ENV:value}, model, baseUrl}
shape plus every legacy field name the current server reads
(anthropicKey/openaiKey) and the shorter aliases (anthropic/openai).
- After saving, the client re-reads the status and confirms the key survived.
An older backend silently drops anything that is not Anthropic or OpenAI, so
instead of walking into a ritual that cannot succeed the user gets:
"This Lisa did not keep the DeepSeek key — it only accepts Anthropic and
OpenAI keys. Update Lisa (npm i -g @oratis/lisa), or add DEEPSEEK_API_KEY=…
to ~/.lisa/config.env and restart." Confirmation returns null (never blocks)
when the answer is genuinely unknowable.
- The Settings view lists every provider with a configured/not-set chip and
uses the same picker + the same guard.
Measured in headless chromium. Against the keyless instance (5872, old-shape
status): 8 options, Anthropic preselected, switching to DeepSeek retitles the
key label to DEEPSEEK_API_KEY and the model placeholder to deepseek-chat,
custom reveals the base-URL field. A DeepSeek save posted
{keys:{DEEPSEEK_API_KEY},model:"deepseek-chat"} and was refused with the note
above (gate stayed open); an Anthropic save posted keys + anthropicKey +
anthropic and closed the gate. Injecting a new-contract status with an unknown
"Brand New Co" provider rebuilt the list to exactly those three and preselected
the configured one. Against the born instance (5871) the Settings view listed
seven providers with chips and a zhipu save posted
{keys:{ZHIPU_API_KEY},model:"glm-4-plus"}. Zero page errors.
Contract note for the server stream: today's handler reads `anthropicKey` /
`openaiKey`, not `anthropic` / `openai` — this client sends all four spellings
plus `keys`, so either naming works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit d0186bad5b571a211707b28f407b9805125e2600)
…terals (UX-8)
The shell shipped as <html lang="en"> with ~30 Chinese literals baked into it —
the knowledge-base button ("💾 存入知识库", "保存中…", "已存入知识库 ✓"), the chat
error block ("⚠ 请求出错", "↻ 重试") — so an English user got Chinese buttons and
a Chinese user got an otherwise English UI. The lang attribute never matched
either.
- LISA_STRINGS carries an `en` and a `zh-CN` table; tr(key, vars) looks up the
locale, falls back to English for a missing key, returns the key itself
rather than "undefined" if it is in neither, and interpolates {name}
placeholders. A test asserts the two tables have identical key sets.
- LISA_LOCALE comes from navigator.language (zh* → zh-CN, everything else →
en) and is written to document.documentElement.lang, so screen readers and
hyphenation follow what is actually rendered.
- Routed through it: the former CJK strings, plus everything this stream added
or touched — chat status announcements, empty-state card, session labels,
right-rail badge, the whole key gate and every birth error.
- Named tr(), not t(). "t" is already a local variable in twenty places in this
4000-line file, and sessionLabel's own `const t` silently shadowed the helper
into a string on the first attempt. A test now scans the cooked bytes for any
bare `t(` call so the trap cannot come back.
- Two deliberate exceptions, both documented in the source: idleHeaderLabel
keeps its ja/ko branches (they predate the table, and dropping them would be
a regression), and the QQ / 163 mailbox setup help keeps 设置 / 服务 / 授权码
because it is quoting those providers' own Chinese UI labels — translating
them would make the instructions wrong. The CJK-scan test allowlists exactly
those two.
- lisa-html.ts loses its two Chinese comments (九宫格 / 功能区).
Measured in headless chromium against the scratch instances with the browser
locale forced. en-US: lang="en", "Say anything — or start here:", tree "New
session · 25m", rail "2 agents need you — open the right panel", gate
"SET · API · KEY" / "ANTHROPIC_API_KEY is required." zh-CN: lang="zh-CN",
"随便说点什么 —— 或者从这里开始:", tree "新会话 · 25m", rail "2 个 agent 在等你 ——
打开右侧面板", gate "设 · 置 · KEY" / "需要填写 ANTHROPIC_API_KEY。". Zero page
errors in either locale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit f367daac66c91080a46bb308d053c092e2e20026)
The daily driver stalled twice during the review (5s+ with no response, then
back to 3–100ms) and the UI said nothing — the only feedback path was a request
failing outright, so a slow backend and a working one looked identical.
Title-bar pill
- #connPill ("reconnecting…", aria-live=polite) sits next to the session tag
and is hidden by default.
- Liveness is: every /events frame and the stream's own `open` event call
noteEventBytes(), which hides the pill; es.onerror shows it immediately
(the existing 3s reconnect then clears it on the next open).
- A 5s checker covers the rest. EventSource readyState is authoritative for a
closed or reconnecting stream — no request needed. The one case it cannot
see is a half-open socket (laptop sleep, network change) that still reports
OPEN while nothing flows; after CONN_QUIET_MS (45s) of silence that is
settled with a single 4s-timeout GET /health, rate-limited to once per 30s.
A good answer counts as liveness, a bad one shows the pill.
- Worth recording for the server stream: the ": ping" comment heartbeat keeps
the socket warm but is invisible to JS — EventSource does not surface
comments at all — which is exactly why liveness here is readyState + a probe
rather than a byte timer. A named `ping` event would be visible; a comment is
not.
Chat waiting state
- /chat normally answers in a few hundred ms. If two seconds pass with no
frame, the existing "⋯ thinking" line escalates in place to
"⋯ waiting for the backend" with .thinking.waiting (warm, not italic), so a
stalled backend reads differently from normal thinking. The first frame
reverts it, and the turn's finally clears the timer so it can never fire onto
a finished or failed turn.
Both strings are in the i18n table.
Measured in headless chromium against the scratch instance: connected →
hidden, readyState 1. Closing the stream and running the checker → visible
"reconnecting…". A stubbed half-open socket quiet for 60s with /health healthy
→ stays hidden; with fetch rejecting → visible; the next noteEventBytes()
hides it again. With /chat stubbed to never resolve: at 0.6s the line is
"⋯ thinking" (class "thinking"), at 2.6s it is "⋯ waiting for the backend"
(class "thinking waiting"). Zero page errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit ef073dd6617ad38b07c25d5e2a6d6cc1e70a1af9)
…eadable cwd (UX-11)
Three small dishonesties in the shell.
Sense
- A fresh install with no connector still read "Publishing active · Pause
publishing", which implied Lisa was posting somewhere. The policy row is now
gated on connectors.length; with none installed it is a neutral state line
("Nothing is published — no social connector is installed.") and there is no
pause button to press.
Pair panel
- The panel already had a Copy button on every row (the review's note predates
it); what it lacked was any word about the credential's lifetime. Device
tokens minted by devices.ts have NO expiry — they are valid until the device
is revoked — so the panel says exactly that, in a warm note under the fields:
anyone who gets the link can reach this Lisa, revoke the device when done.
Overstating it ("expires in 10 minutes") would have been worse than silence.
Inspector
- cwd was the full absolute path (90+ chars here), ellipsised into nothing in a
320px rail. abbrevPath() collapses /Users/<name> and /home/<name> to ~ (and
the home directory itself to "~"), leaving anything else untouched. The full
path stays in the tooltip and is one click away via a new .copy-btn, which is
dim until the row is hovered or the button takes focus. The inspector's sub
line is abbreviated the same way. No regex — this file is a template literal
and every backslash would need doubling.
Measured in headless chromium against the scratch instance: abbrevPath maps
/Users/oratis/Projects/LISA/… → ~/Projects/LISA/…, /Users/oratis → ~,
/home/deploy/app → ~/app, /opt/lisa unchanged, "" and null → "". The cwd row
renders "~/Projects/LISA/.claude/worktrees/agent-a24374ea4d601d4d1" with the
absolute path in title, and clicking Copy put the absolute path on the
clipboard and flipped the label to "Copied". Sense rendered
"social-policy neutral" with no #socialPauseBtn. The pair panel showed Copy on
all four rows plus the lifetime note. Zero page errors.
All new copy goes through the i18n table (en + zh-CN).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 99ecb17823c8fea877a67f33fbc1079602a046c9)
…help (UX-11) The shell had Enter, Shift+Enter and Esc. Switching sessions meant reaching for the sidebar tree; find-in-chat meant spotting a 36px magnifier in a twelve-icon bar. - ⌘K / Ctrl+K opens a minimal session switcher: a filter field over the same session cache the tree uses, ↑/↓ to move, Enter to activate, Esc to close, click (mousedown, so the overlay closes before the click could land on what is underneath) to pick. The active session is tinted, each row carries "<n> msgs · <age>", and the raw id is the row tooltip. The list is built from window.lisaSessionsForSwitcher(), pre-rendered inside the sidebar closure so the top-level switcher needs no access to sessionLabel / relativeTime / cachedSessions. - ⌘/ focuses the composer. ⌘F opens the find bar and selects it — deliberately shadowing the browser's own find, which searches a virtualised log and the whole chrome around it. - "?" opens the shortcut list, but only when the user is not typing into a field, so a literal question mark still reaches the composer. - Esc closes the switcher, then the find bar, most-nested first. It deliberately does NOT close the key gate or the birth overlay: dismissing those drops the user into a shell that cannot work. There is a test asserting neither id appears in that handler. - The list is also reachable without knowing it exists: a "Keyboard shortcuts" row in Settings → About opens the same modal. Measured in headless chromium against a scratch instance with three sessions: ⌘K opened the overlay and focused the filter with the active session preselected; ArrowDown moved the selection; typing "zzzz" showed "No sessions match."; Esc closed it; ArrowDown+Enter switched window.lisaActiveSessionId to the second row's id; ⌘/ focused #input; ⌘F revealed and focused #fnFind and Esc hid it; "?" opened the KEYBOARD modal listing all six bindings; "?" with the composer focused typed a "?" and opened nothing. Zero page errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 1645f8022bf00a2ec3f4870e49c3028616422dc0)
…es (T-2)
src/web/lisa-client.ts was a 4,700-line JavaScript program inside an untagged
template literal, and lisa-css.ts a 3,000-line stylesheet in another. The cost
was not cosmetic:
- Every backslash in a regex had to be doubled, because the literal ate one
pass of escaping. lisa-client.test.ts exists entirely because that bit the
idle-note sentinel.
- A backtick anywhere — including inside a comment — silently ended the
literal. It happened twice while writing the commits before this one
(`configured` in a prose comment, then a JSON payload quoted in another).
- No editor could highlight, fold, format or lint any of it, and no type
checker could see it at all.
The bytes now live at src/web/assets/client/main.js and main.css, byte-identical
to what the template literals produced (verified: MAIN_HTML hashed to the same
sha256 immediately after the extraction, before any edit). lisa-client.ts and
lisa-css.ts are four-line modules that readFileSync them at module load,
resolved from import.meta.url so the same code works under tsx (src/web/…) and
from the compiled build (dist/web/…, where `assets` is a symlink in development
and a real copy in the published package — copy-assets / prepublishOnly). Their
exports keep the same names, so lisa-html.ts and every existing test are
unchanged.
Single-file HTML delivery is a product promise — one GET, no secondary
requests, a saved copy still runs — so this is a SOURCE split, not a runtime
one: the page still inlines both files exactly as before.
Deviation from the brief worth naming: the readFileSync calls sit in
lisa-client.ts / lisa-css.ts rather than in lisa-html.ts. Doing it in
lisa-html.ts would have meant either a circular import (the other two modules
are imported by it) or a third module; keeping each asset owned by the module
that already exported it is smaller and leaves every import site untouched.
Tests
- html-syntax.test.ts (vm.Script compile of every inline <script>) is kept
as-is; it is the check that matters most now that an editor will let anyone
type into the file.
- lisa-html-snapshot.test.ts stops pinning bytes. The length+sha256 pin did its
job while the split was in flight, but with the client in real files it was
pure churn — every one-word copy change failed two tests and rewrote a hash
that proves nothing about behaviour (it was re-pinned nine times in this
branch alone). It now asserts the property the pin stood in for: the file
list in assets/client is exactly {main.css, main.js}, neither contains a
</script or </style sequence, the exported constants equal the file contents,
the served page contains each file verbatim exactly once and inside the right
element, there is no <script src> or <link rel=stylesheet>, and the document
is complete with balanced style/script tags.
- The obsolete "double-escape in the template literal" commentary in
lisa-client.test.ts is rewritten: the trap is gone, the behavioural check
stays.
Type checking
- New tsconfig.client.json (allowJs, checkJs, ES2022 + DOM, noEmit, strict
false) wired as `npm run typecheck:client`. It surfaced 320 errors; it now
reports ZERO.
- ~150 were the client's own architecture, not defects: window.lisa* globals
(about forty names that let the top-level script and the two IIFE blocks talk),
the source-injected renderMarkdown, and the forward-declared
updateReflection / refreshClaudeSessions. types/web-client.d.ts declares them,
which is documentation as much as a fix.
- ~150 were DOM narrowing a plain .js file cannot express: getElementById
returns HTMLElement so `.value` / `.disabled` / `.placeholder` are errors on
every form control, and e.target is an EventTarget so `.closest()` is an
error in every delegated handler. Index signatures on Element / EventTarget /
Event drop exactly that class and keep everything else — crucially TS2304,
unknown identifiers, which is this file's real bug class (a renamed-but-not-
updated global; that is what the tr() rename hit earlier in this branch, and
no test would have caught it).
- The rest were fixed in the code: five click handlers used `this` inside a
function expression (now ev.currentTarget, which also survives a future
arrow-function conversion), the provider-hint fallback was a bare `{}` that
hid which fields the merge relies on (now a spelled-out EMPTY_PROVIDER_HINT),
FileReader.result is string|ArrayBuffer and was `.split(',')` directly, and
navigator.standalone is declared as the iOS-only flag it is.
- types/ sits outside src/, so the main build never sees these declarations.
Verified: MAIN_HTML byte-identical at the moment of extraction; full suite
green (1715 pass); npm run build; and a simulated published layout
(dist with a real assets copy instead of the dev symlink) loads and inlines
both files verbatim. The empty state, ⌘K switcher, inspector copy, Sense and
pair panels were re-driven in headless chromium after the move with zero
console errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 29c156ef726056707972c5c9a0eefd9bc0f58146)
Two loose ends from the UX pass.
**CSP.** The shell renders untrusted text in a dozen places — tool results,
mail subjects, knowledge-base extracts, an observed agent's output through the
Markdown renderer. Escaping is the first defence and it is applied, but it is
one review away from a hole, and a hole there executes with the full authority
of a page that can drive the agent. A Content-Security-Policy makes an injected
<script> inert regardless.
The page carries two inline <script> blocks, so the policy needs a per-response
nonce rather than 'unsafe-inline' — that is the whole point, since an attacker
cannot guess it. MAIN_HTML becomes renderMainHtml({nonce}), the GET / handler
mints 16 random bytes per response, and MAIN_HTML stays exported (nonce-less,
byte-identical to before) for tests and non-HTTP consumers. no-store on the
shell already guaranteed a fresh document, which is what makes a per-response
nonce safe.
style-src keeps 'unsafe-inline': the client sets style="…" on elements in dozens
of places and nonces do not apply to style attributes. Everything else is as
narrow as the shell actually needs — data:/blob: images and media for attachment
previews and recorded dictation, frame-src 'self' for the Room iframe,
connect-src 'self' for fetch and EventSource, object-src 'none'.
Verified in a real browser against a scratch instance: shell renders, Room
iframe loads, both inline scripts execute (nonce set on each), zero CSP
violations in the console.
**Composer placeholder.** At 375px the textarea is ~160px wide, so
"Talk to Lisa… (Enter to send · Shift+Enter for newline)" wrapped and clipped
mid-word — and the hint is meaningless on a soft keyboard anyway. Narrow
viewports get the short form, kept correct across rotation by a matchMedia
listener.
**docs/DESIGN_TOKENS.md.** Both palettes, the type scale, spacing, radius,
motion, the focus ring, the breakpoints and the touch-target rule — with the
measured contrast ratios, which lisa-css.test.ts enforces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit e925c2a540a33b2cf8958182ddbaf4c7c62c4b0a)
oratis
force-pushed
the
claude/opt-billing
branch
from
September 7, 2026 04:24
20df61e to
f7816a6
Compare
oratis
force-pushed
the
claude/opt-web-ui
branch
from
September 7, 2026 04:24
3ffd015 to
7f7dbde
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
第 7 / 8 条,实现 UX 审查的两个 P0 与 UX-3/4/5/8/10/11 以及技术审查的 T-2、T-10。基于 #374。
两个 P0
移动端布局(UX-2)。 #367 让右栏默认收起,但
body.rb-collapsed .frame的特异性高于 720px 媒体查询,375px 宽屏上主区只剩 75px;即便展开,功能栏 12 个图标也把聊天视图撑到 681px,发送按钮落在屏幕外。现在收起规则限定在 ≥721px,#viewChat固定minmax(0,1fr),功能栏在窄屏下减到 5 个按钮并可横向滚动。实测 375×812(收起与展开两种状态一致):
.main宽度首次运行死胡同(UX-1)。 填错 key 后:出生仪式对 401 也重试、把 Anthropic 的原始 JSON 直接显示给用户、点 ENTER 只是刷新页面用同一个错 key 再跑一遍,key 表单再也不出现。现在错误一律是人话,认证错误附 Change key 按钮回到密钥面板(重配置模式),保存成功后不刷新页面直接重启出生流;运行期间可 Cancel。
其他
:focus-visible焦点环(此前outline: none,键盘用户完全看不到焦点);两套主题的--fg-3提到 AA 以上(Calm 3.2:1 → 5.62:1,Nebula 4.4:1 → 5.76:1);最小字号 10px → 11.5px;窄屏下小控件用透明::after撑到 44pt 触控区;聊天流与待办计数加aria-live;非必要动效包进prefers-reduced-motion。对比度由单元测试从 CSS 中解析令牌并断言 ≥4.5。t(key)表(en / zh-CN),按navigator.language选择并同步lang属性。turn_start显示等待指示。结构(T-2)
客户端 JS 与 CSS 从模板字符串移入真实文件(
src/web/assets/client/main.{js,css})。之前每个正则的反斜杠都要手工加倍、注释里一个反引号就会静默截断字符串(编辑过程中发生过两次),且typecheck完全看不见这 7000 行。现在npm run typecheck:client用 DOM lib 检查真实文件;单文件 HTML 交付这一产品承诺不变(构建期内联)。字节快照测试改为组合校验,UI 改动不再需要重钉哈希。CSP(T-10)
外壳在十多处渲染不可信文本(工具结果、邮件主题、知识库摘录、被观察 agent 的 Markdown 输出)。转义是第一道防线且已经到位,但离出漏洞只差一次 review 疏忽,而这里的漏洞将以能驱动 agent 的页面权限执行。现在每次响应生成 nonce,注入的
<script>无法执行。真实浏览器验证:外壳渲染、Room iframe 加载、两段内联脚本均执行、控制台零 CSP 违规。docs/DESIGN_TOKENS.md记录两套色板、字号阶梯、间距、焦点环、断点与实测对比度表。🤖 Generated with Claude Code