Systemic check — the four-dimension auditrun · 2026-07-10
A full pass over the prototype at main = v349 (e2051ba): the routine safety net, plus the four dimensions asked for — performance (what loads slow), language (one thing, many names), UI reuse (combine the duplicated components), and formats (numbers, fonts, chips, copy). Method: live latency probes on :8011 + one real fetch of production from the China side, and four parallel code-audit passes over every app surface. Nothing is fixed yet — this page is the menu.
How to use this page. Every potential fix carries a permanent serial — #1…#38 (they will never be renumbered). Tap a row to pick it; your picks collect in the tray at the bottom and survive reload on this device. Then tell CC: “fix #2 #7 #22” — or hit copy in the tray and paste. Rows marked decision need a call from you before code moves. Status 2026-07-11: all 38 serials are done — #1–#30 live on the box (v361); #31–#38 on main (v362–v365), riding the next deploy.
0 · Routine check
Check
Result
Note
python lib/smoketest.py
PASS
All 39 asserts — persona roots, builder/studio states, publish & visibility, take-off/put-on.
Version sync
in sync
BUILD "live v349" = sw.js CACHE "mad-shell-v349".
Git
clean
Working tree clean; main == origin/main.
Parse-health (prod replay)
skipped
The box refused SSH twice (port 22 closed by peer — the known GFW sensitivity); stopped rather than hammer. No saved corpus for offline replay. Retry from a normal deploy window.
Deploy drift
deployed
2026-07-11: the box serves v361 (55f816f) — the whole audit batch #10–#30 + perf #1–#9 is LIVE. Verified from the China side: shell minified 514 KB → 140 KB zstd wire (was 210 KB gzip of the raw shell, −33%), content-encoding: zstd, ETag revalidation → 304 / 0 bytes, v361 marker on /, theme.css serving. Venv gained rjsmin/rcssmin; Caddy now encode zstd gzip.
Stale records
corrected
Three session memories were out of date and got fixed in passing: perf/local-first is fully merged (0 ahead); the cold-open fix is boxed; the e-ink purge in md-viewers/seen/personas is already done.
1 · Performance — what actually loads slow
Nothing is broken-slow on a warm device: the service worker paints the shell instantly (FCP 84 ms) and the local-first cache paints rooms before the network answers. The cost concentrates in three places: the cold first open over the China wire, two disk-scanning endpoints, and payloads that grow with transcript size.
Measured (dev :8011, 33 rooms · prod wire from China)
Surface
Measured
Why
Cold first open (new device / evicted SW)
shell 1.76 s wire
850 KB unminified shell → 210 KB gzip (v315; v349 will be ~260 KB). Then fonts + boot + first room. Repeat opens skip it all.
Picker “Popularity” sort · /api/personas/usage
~430 ms every call
_persona_usage (run_room.py:6261) full-parses every room in the system — 42.5 MB on dev. Also fires on every admin-console open. Worst scaler: grows with everyone’s rooms.
/api/boot
150–580 ms
Chat-list build full-parses each room’s state.json (avg 206 KB, max 2.9 MB) for ~10 header fields (run_room.py:4574); boot also embeds the landing room’s whole transcript (run_room.py:6241).
Opening the biggest room
748 KB
/replay returns full history on first fetch (delta sync exists only after a cache is seeded).
Post-boot prefetch
≈ 2 MB · 42 req
Deliberate warm-12-recent (room-ui.html:6137) — but the cap counts rooms, not bytes, so one 731 KB transcript ships every fresh session.
First diagram
3.3 MB
Vendored mermaid, correctly lazy — just know the first diagram pays it.
login / admin / builder / md-viewer / share
< 15 ms
All fine (20–171 KB).
The one genuinely slow path. Every serial below shortens a bar: #5/#6 shrink the shell bar, #1 shrinks boot, #3 shrinks the landing replay, #8 tames the prefetch tail.
Fix points — performance
#
Fix
Effort
Gain
#1
Room-meta sidecar — stop full-parsing state.json for the chat list ✓ doneWrite a tiny per-room meta.json (title · cast · updated · rounds · cost) on persist, or an mtime-keyed meta cache; _meta_from_diskrun_room.py:4574 stops loading whole transcripts. The cache is cold after every deploy, so each active user’s first boot re-pays this today.
M
CPU · scales
#2
Persona-popularity aggregate in SQLite ✓ done · memo_persona_usagerun_room.py:6261 parses all rooms globally per call (~430 ms now, grows with total rooms; fires on picker Popularity + every admin overview). Keep a slug → room_count aggregate updated on room create / cast edit; never touch disk to sort. Done as a 60s memo instead of a SQLite aggregate — after #1 the walk reads tiny sidecars, so a short memo gets 430→6ms with zero drift risk. Measured: 430ms → 165ms cold / 6ms warm.
M
CPU · worst scaler
#3
Windowed history — boot landing + first room open ✓ done · deltaBoot embeds the landing room’s full transcript (run_room.py:6241) and a big room’s first /replay is 748 KB. Window to the last N turns (older history lazy on scroll), or send the client’s cached total as since — the delta endpoint already exists. Done via the cursor route: the client sends its persisted cursor for the expected landing room; boot ships only the tail (diverged→full, verified). First-open /replay stays full BY DESIGN — no load-older UI exists yet, so windowing it would hide history.
M
wire · scales
#4
LRU cap on the in-RAM room cache ✓ doneRoomManager.roomsrun_room.py:4679 never evicts — each resumed room holds its full history (up to ~2.9 MB). On the small SG box RSS climbs until everything gets slow. Evict least-recently-used; rooms rehydrate from disk.
S
RAM · box health
#5
Minify the shipped shell ✓ done · −38%room-ui.html is 850 KB / 11.5k lines with ~1.7k comment lines. Minify at serve time (cached transform in run_room) or in the deploy step — the dev file stays readable, the wire drops ~25-40% and phones parse less.
M
wire + parse
#6
Caddy: encode zstd gzip✓ repo · box at deployThe box compresses gzip-only today. zstd/brotli-class encoding is ~15-25% smaller on text this size — one line in deploy/Caddyfile, rides the next deploy batch.
S
wire
#7
Long-lived Cache-Control on vendored assets ✓ done/mermaid (3.3 MB) / /katex / /hljs / /fonts are served with no cache headers (run_room.py:5556-5847) — browsers without the SW revalidate per load. Add max-age + immutable (+ a ?v= bump convention on upgrade).
S
wire · repeat
#8
Byte-aware prefetch ✓ done · rounds capThe warm-12-recent prefetch (room-ui.html:6137) counts rooms, not bytes → ≈2 MB per fresh app-open on mobile data when a big transcript sits in your recent-12. Cap by size, or prefetch windowed bodies (rides #3).
S
wire · mobile data
#9
Move the changelog out of sw.js ✓ donesw.js:17 carries the entire v312→v349 release history as one comment line — every client downloads 11.7 KB of mostly notes on every update check. Keep the changelog in git; ship a ~2 KB worker.
S
tidy · wire
Verified fine — no action: boot is already local-first + one round trip (the perf/local-first work is all live on main) · send path is async (model runs in the background drain, streams over SSE) · SSE connect does no scans · SQLite hot queries are indexed · heavy libs (mermaid/katex/hljs/panzoom) load lazily · app fonts are self-hosted woff2 (China-safe) · SW nav routing is correct on main.
2 · Language — one thing, many names
8 of 10 core concepts carry two or more names in the UI. Much of it is renames-in-flight (the changelog shows Usage→Popularity, Mine→Built by me… landing on some surfaces and not their siblings). The worst single screen is the new-chat picker (room-ui.html:2870-2944): one flow says “character library” → “each chat keeps its own cast” → “Convene a panel” → “Character vividness” → “Add to your panel” — four words for the same concept before the user has typed anything.
The concept map (found → recommended canon)
Concept
Words in the UI today
Keep
Sample evidence
An AI participant
persona · character · voice · “AI”
persona
character: room-ui 2877/2900/7630/7705 · voice: 2803/3810/7221 · both in one block: 7431
The AI group
panel · cast · crew
panel
cast: 2877 · crew: 2671 (Vibes toggle)
Humans
people · members · guests · friend
people
“people” header 2451 vs “New members…” 5330 — same panel; “N guests” 8923
two heading schemes too: Occupation/Essence vs At a glance/Background
Start the panel
Convene a panel · Call the panel
pick one
2531 vs 2539 — same action, adjacent buttons
Feature names
Notes · Seen · Vibes · Energy
already consistent ✓
—
Fix points — language
#
Fix
Effort
Gain
#10
persona everywhere — retire “character” and “voice” ✓ v350The big sweep: “character library” + “Character vividness” (2877/2900), “add a character” (7630/7705), invite dialogs’ “voices” (7221-7222/3810/3814), Me-sheet “Voices you’ve met” (2803), the mixed empty-state (7431/7685), manifest “AI characters”, admin “The AI character library”. (If you want to bless “voice” as deliberate poetry in the Me sheet, say so — recommendation is one word.)
M
polish · big
#11
panel everywhere — retire “cast” and “crew” ✓ v350“each chat keeps its own cast” (2877), “this crew’s public chat” (2671). “Cast” stays fine as internal code vocabulary.
S
polish
#12
people — align “members / guests / friend” copy ✓ v350Chat-info header says “people” (2451) while its own body says “New members won’t see…” (5330/5341); Vibes hot-line says “N guests” (8923, ties to #36); share landing says “friend”. Keep “members” only if you want it as the join/leave-mechanics word — otherwise people throughout. Done: post-join copy says “newcomers”, account age says “Joined”, Vibes badges say “people”; the share landing's “friend” kept deliberately (it names the sharer, not a room role).
Studio is the surface, “build” the verb — kill the “builder” leak ✓ v350Step 1’s subline “the builder works out who. You confirm.” (builder-ui.html:417) exposes the internal route name inside a surface branded “Persona Studio”.
S
polish
#15
profile — one name + one heading scheme for the persona page ✓ v350 · verifiedTooltips/aria already say “view profile”; unify prose “persona page”→profile, and pick ONE about-structure — “Occupation/Essence” (personas.html:180) vs “At a glance/Background” (run_room.py:329). Decided: “view profile” · Occupation + Essence. Verified: Occupation/Essence is already the only rendered scheme — the “At a glance/Background” sighting was profile.md source-section names in parser comments, never UI headings. No visible change needed; canon recorded in the Style Guide.decision
S
polish
#16
One verb for starting the panel — Convene vs Call ✓ v350“Convene a panel” (2531/2881) and “Call the panel” (2537/2539) are the same action on adjacent surfaces. Decided: “Call the panel” — button, tooltip, the describe-box prose (“we'll call a panel for you”), and the marquee (“Calling your panel — sifting the library…”).decision
S
polish
#17
Finish the v342 rename: “Mine” → “Built by me” ✓ v350The Studio browser still shows “Mine” (personas.html:103) while every other persona list says “Built by me” (room-ui 2701/2890/2933).
S
trivial
#18
Align the console’s sort vocabulary with v346 “Popularity” ✓ v350 · verifiedThe app’s sort pills say Popularity; the console still speaks “Usage” in its persona-list sort. (The “Usage & cost” stats panel can keep its name — it genuinely shows usage + cost.) Verified: the console pill already said “Popularity” (v346 did land there) — only a stale code comment said Usage, now updated. Console tab subline “The AI character library” → “The persona library” fixed under #10.
S
trivial
#19
Invite (humans) vs Add (personas) — unify or codify ✓ v350Adding a human is “Invite people” (2955), adding a persona is “Add to your panel” (2925) / “Invite to this chat” (7221) — structurally the same action, different verbs. Decided: the existing-chat pair is “Invite people” / “Invite persona” (+ “Remove people” / “Remove persona”); “Add” is reserved for composing a NEW chat's panel (“Add to your panel”, “add a persona”) — codified in the Style Guide.decision
S
polish
#20
Write the canon into the Style Guide ✓ v350Add the concept→word table (and the #19 rule) to docs/style-guide.html so every future ship inherits it — this is what stops the drift from regrowing. Done: new “Terminology — the canon” section (#sg-terms), 10 entries incl. the #15/#16/#19 decisions; the guide's own Vocabulary entries scrubbed of cast/voice/character.
S
leverage
3 · UI consistency — combine & reuse
The root cause is structural: there is no shared layer. Colour + font tokens exist, but zero radius / spacing / shadow / size tokens, and no shared stylesheet — six pages each re-inline their own :root copy (md-viewer’s has already drifted to different values). Every component hand-rolls its own box, which is exactly why the × never lands in the same corner twice.
Family
Today
Should be
Dialog scaffolds
~8 for a 2-style system — .modal/.pickmodal, .vp, .mep, survey, confirm, drawers, admin, builder; scrim opacity ∈ {.18 · .28 · .32 · .34}; the Me sheet fills --bg while siblings fill --surface
1 scaffold, 4 variants
Close ×
~11 treatments — offsets span top 10–24 px / right 6–28 px, glyph 17–22 px, three encodings (char / SVG / text-button)
1 class
Primary buttons
2 languages — the rect radius-10 button exists under 3 names in 3 files (.btn-primary room 2251 ≡ builder 100 ≡ .adm-btn admin 249); the coral pill is re-invented ~10×
1 system
Search boxes
1 rich component (clear-× + “Try searching”) for the 3 pickers; admin + personas hand-roll plain inputs over the same shared scorer
1 chrome
Menus · avatars · chips · toasts
5 popover engines · 10 avatar sizes · ~10 pill variants (radius 7/20/999) · two#toasts under the same id
1 each
Files
md-viewer-tts.html is byte-identical to md-viewer.html (47,110 B twins)
one file
The reuse pipeline. Legitimately distinct (won’t merge): the image lightbox’s circular dark ×, the confer/wildcard slide drawers, the Seen long-form article typography.
Fix points — UI reuse
#
Fix
Effort
Gain
#21
Shared tokens — one theme.css for all pages ✓ v351Extract the :root palette into one stylesheet linked by all 8 pages (kills the 6 drifting copies; fixes md-viewer’s --rule/#FAF8F2/#F2DDD2 drift) and mint the missing --radius-* / --space-* / --shadow-* tokens. Adoption is gradual — new code uses tokens; components adopt as #22-#30 touch them. The enabler for everything below.Done (v351): 7 pages linked (zero visual delta, verified by computed-style parity light+dark); page-specific tokens stay local (console vendor hues, Studio red/yellow, Seen tan gold); the attribute-less share landing gets dark via a prefers-color-scheme block. Deviation: md-viewers deliberately NOT linked — their reading palette is a design, and a dedicated persona viewer is planned.
M
DX · unlock
#22
One close button — .dialog-x (+ .dialog-back) ✓ v352Your literal complaint, resolved in one class: single position, size, glyph and 40 px hit-area, swept across the ~11 variants (.modal-x 14/14 2149, .drawer-close 12/12 1225, .lib-x flex 2113, seen’s 24/28, admin’s text-only “Close”…). Lightbox’s circular dark × stays as an explicit variant.
S
polish · visible
#23
One search chrome everywhere ✓ v353Port the pickers’ rich component (clear-× + “Try searching” panel, room-ui 2121/7245) to admin’s #pSearch (admin 1047) and the Studio browser’s plain input (personas 44). The fuzzy scorer is already shared — only the chrome is copy-drifted. Done (v353) as PersonaSearch.attachChrome; bonus fix: theme.css + persona-search.js includes now carry ?v=N cache-busters (no Cache-Control on statics → they went heuristic-stale; bump alongside BUILD when either changes).
S
polish
#24
De-dup md-viewer-tts.html✓ doneByte-identical twin of md-viewer.html (47,110 B each) — collapse to one file (a param or one-line include decides TTS), so viewer fixes stop needing to land twice.
S
DX
#25
One dialog scaffold ✓ v358Collapse .modal/.pickmodal + .vp + .mep + survey + confirm into one scrim + card + head with variants main/sub/sheet/confirm (admin’s .adm-modal optional later). Normalize scrim to one opacity, one surface fill, one z-ladder. Care: preserve the hero-collapse scroll behaviour and the layer stacking that the back-gesture registry depends on.
L
DX · biggest
#26
One button system ✓ v359First the call: rect-10 vs coral pill — or codify the semantic that’s half-true already (pill = floating CTA, rect = in-dialog action). Then collapse the 3-name rect (room 2251 · builder 100 · admin 249) and the ~10 bespoke pills (1685/576/585/613/1043/1110/1639…) onto it. Decided + done (v359): pill = floating CTA, rect = in-dialog action — each defined once in theme.css; sites keep only size/context; vc-cancel ghost pill, red confirm, and round icon buttons stay variants.decision
M–L
polish · DX
#27
One avatar component ✓ v35510 magic sizes (22→84 px) become a 4-5 step scale; one initials/colour fallback helper replaces the three re-derivations (avOf 3415 · humanAvatar 5497 · inline 5020/6627/7613). Mechanical, visually safe.
M
DX
#28
One chip/pill ✓ v356One pill (radius 999, two paddings) + colour variants absorbs .chip/.pb-pill/.sd-chip/.lib-mine/.rt-chip/.fb-tag; align the outliers (radius 20 964, radius 7 2341); tame the 15 letter-spacing values to 2-3 steps. Builder’s mono status chip stays as .chip--status.
M
polish
#29
One popover engine ✓ v357.popmenu absorbs the model-menu (243), admin .rowmenu (195), builder .cardmenu (205), md-viewer .ctx-menu (232). Positioning logic differs per site — the riskiest of the mechanical merges.
M
DX
#30
One toast ✓ v354Room and admin both define #toast with drifted styling (room-ui 1125 · admin 205) — share the component (and the inline .err style with login/builder).
S
tidy
4 · Formats — numbers, fonts, copy
The flagship offender: four relative-time formatters + three absolute-date formats, disagreeing on units, “just now” vs “0m ago”, and locale (en-GB here, en-US there, system elsewhere) — the same “last used” value renders differently in the app and the console. Numbers, font weights and copy voice drift the same way, one copy-paste at a time.
Fix points — formats
#
Fix
Effort
Gain
#31
One time module — shared fmt.js, part 1 ✓ v362Merge timeAgo (room-ui 7025), relTime (7036), the inline Vibes ago (8920) and admin’s rel() (admin 447 — shows “0m ago” + ISO fallback) into one formatter pair (chat-row style + ago style) with ONE locale policy. Same field → same string, everywhere. Decided: absolute dates are en-GB (“5 Jan 2026”) — the Me-sheet’s existing look; relatives are locale-free.decision: locale
M
consistency
#32
One number module — fmt.js, part 2 ✓ v362Token suffix 1.2k everywhere (builder says 1.2K, builder-ui 1526); one money rule (room-ui has TWO — adaptive-3dp 3498 vs always-2dp 4948); one duration format (today: 3:07 / 42s / 2.3s / 2.34s); thousands separators on the Me-sheet counters (2770-2773).
S
consistency
#33
Font delivery + weights ✓ v363Admin + the 3 md-viewers load fonts from the Google CDN (slow/blocked in China) while the app self-hosts — move them to /fonts woff2; give seen_template faces at all (it loads neither). Kill faux-bold: weight 700 is used 9× (unread badge 675) but never loaded — load it or map those to 600. Unify the 4+ sans fallback stacks to the canonical CJK cascade (personas.html drops the CJK aliases).
M
China wire + fidelity
#34
Dark-mode colour-bypass sweep ✓ v364Coral re-hardcoded as rgba(204,120,92,…) ~10× (370/578/1332/1609…) → derive from --coral; light-only #F7F5EE on the poster + image-lightbox (537/543/603/622); the #8475B5 forecast badge (796-810).
S
dark mode
#35
Copy-voice rules + sweep ✓ v365Pick and enforce: Couldn’t (vs “Could not”, 7542/9679…); one trailing-period rule for toasts; one placeholder capitalization (“Search” vs “search name…”); ONE empty-state pattern — recommend “No X yet — hint.” (today also “Nothing here yet” + “X is empty”). Land the rules in the Style Guide with #20.
S
polish
#36
Vibes chrome: 回复 / 热议 in the English UI ✓ v365The feed meta-lines mix Chinese verbs with English nouns (“回复 · 3m ago · N messages”, 8921/8923). Flagged in-code as mock data — decide the English words (or localize the whole chrome) when Vibes graduates. Decided: 回复 → “replied” (via the shared Fmt.agoMin), 热议 → “lively”.decision
S
polish · deferred ok
Fix points — housekeeping
#
Fix
Effort
Gain
#37
Remove the 4 dead “e-ink” comments in room-ui ✓ v365room-ui.html 397 · 931 · 2019 · 3979 — the functional e-ink code is already gone from every app surface; these comments are the last misleading residue.
S
tidy
#38
Delete the merged perf/local-first branch ✓ doneVerified 0 commits ahead of main (everything shipped v179–v187, deployed 2026-06-20) — remove local + origin so nobody mistakes it for pending work.
S
tidy
False alarms & already-solid
--serif ending in sans-serif is deliberate, not a bug — PRESENTATION.md mandates it so Android never resolves the generic to Noto Serif CJK (宋体). Do not “fix”.
E-ink strays: the suspected leftover theme code in md-viewers / seen / personas is already gone — only the #37 comments remain.
Perceived-speed architecture — local-first paint, one-round-trip boot, idle prefetch, delta replay, outbox retry: all live on main and working as designed.
Feature names Notes / Seen / Vibes / Energy — each used consistently.
Suggested serving order (if you want a default): #22 (the ×, half a day, instantly visible) → #21 (tokens, the unlock) → #10-#18 + #20 (the words) → #1-#3 (the scans, before user growth) → #31-#33 (fmt + fonts) → #25/#26 (the big merges). But every serial stands alone — pick freely.
Dialogue · Systemic check · run 2026-07-10 · main v349 e2051ba · box v315 bd2fdeb · dev probes on :8011 (33 rooms) + prod wire from the China side · smoketest PASS · see Style Guide · QA test run · Perceived speed · Roadmap