Dialogue · Design notes · Systemic check 2026-07-10
← Design notes

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

CheckResultNote
python lib/smoketest.pyPASSAll 39 asserts — persona roots, builder/studio states, publish & visibility, take-off/put-on.
Version syncin syncBUILD "live v349" = sw.js CACHE "mad-shell-v349".
GitcleanWorking tree clean; main == origin/main.
Parse-health (prod replay)skippedThe 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 driftdeployed2026-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 recordscorrectedThree 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)

SurfaceMeasuredWhy
Cold first open (new device / evicted SW)shell 1.76 s wire850 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/boot150–580 msChat-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 room748 KB/replay returns full history on first fetch (delta sync exists only after a cache is seeded).
Post-boot prefetch≈ 2 MB · 42 reqDeliberate warm-12-recent (room-ui.html:6137) — but the cap counts rooms, not bytes, so one 731 KB transcript ships every fresh session.
First diagram3.3 MBVendored mermaid, correctly lazy — just know the first diagram pays it.
login / admin / builder / md-viewer / share< 15 msAll fine (20–171 KB).
0s 1s 2s 3s 4s DNS + TLS ~0.4 s (est) shell HTML 1.76 s · 210 KB gz (measured) fonts ~130 KB · non-blocking (swap) parse + init ~0.3 s parse of 830 KB (desktop) /api/boot 150–580 ms server + RTT first paint of room usable ≈ 3.4 s prefetch 12 rooms ≈ 2 MB · background Cold first open, China → SG (measured wire + estimated composite). Repeat opens: SW shell instantly (FCP 84 ms) + boot deltas only.
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

#FixEffortGain
#1Room-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_disk run_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.MCPU · scales
#2Persona-popularity aggregate in SQLite ✓ done · memo_persona_usage run_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.MCPU · worst scaler
#3Windowed 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.Mwire · scales
#4LRU cap on the in-RAM room cache ✓ doneRoomManager.rooms run_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.SRAM · box health
#5Minify 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.Mwire + parse
#6Caddy: 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.Swire
#7Long-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).Swire · repeat
#8Byte-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).Swire · mobile data
#9Move 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.Stidy · 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)

ConceptWords in the UI todayKeepSample evidence
An AI participantpersona · character · voice · “AI”personacharacter: room-ui 2877/2900/7630/7705 · voice: 2803/3810/7221 · both in one block: 7431
The AI grouppanel · cast · crewpanelcast: 2877 · crew: 2671 (Vibes toggle)
Humanspeople · members · guests · friendpeople“people” header 2451 vs “New members…” 5330 — same panel; “N guests” 8923
A conversationchat · conversation · roomchat (room = brand only)“conversation” 7221/6924/8249 · “Live Room” manifest/login/share
The creation toolStudio · Build · “the builder”Studio + verb build“the builder works out who” builder-ui.html:417
A persona’s pageprofile · persona page · cardprofiletwo heading schemes too: Occupation/Essence vs At a glance/Background
Start the panelConvene a panel · Call the panelpick one2531 vs 2539 — same action, adjacent buttons
Feature namesNotes · Seen · Vibes · Energyalready consistent ✓

Fix points — language

#FixEffortGain
#10persona 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.)Mpolish · big
#11panel 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.Spolish
#12people — 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).Spolish
#13chat in-app — “room” stays brand-only ✓ v350Swap prose “conversation” (7221/6924/8249) → “chat”; leave “Live Rooms” to the manifest/login/share brand line.Spolish
#14Studio 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”.Spolish
#15profile — 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.decisionSpolish
#16One 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…”).decisionSpolish
#17Finish 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).Strivial
#18Align 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.Strivial
#19Invite (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.decisionSpolish
#20Write 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.Sleverage

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.

FamilyTodayShould 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 --surface1 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 buttons2 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 boxes1 rich component (clear-× + “Try searching”) for the 3 pickers; admin + personas hand-roll plain inputs over the same shared scorer1 chrome
Menus · avatars · chips · toasts5 popover engines · 10 avatar sizes · ~10 pill variants (radius 7/20/999) · two #toasts under the same id1 each
Filesmd-viewer-tts.html is byte-identical to md-viewer.html (47,110 B twins)one file
#21 shared tokens one :root #22 one close × + back chevron #23 · #24 search chrome md-viewer dedupe #25 dialog scaffold modal · vp · me #26 button system decide rect / pill #27–30 avatars · chips menus · toast Left → right: each merge makes the next cheaper. #21 is the unlock — after it, “improve one part” improves the rest for free.
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

#FixEffortGain
#21Shared 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.MDX · unlock
#22One 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.Spolish · visible
#23One 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).Spolish
#24De-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.SDX
#25One 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.LDX · biggest
#26One 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.decisionM–Lpolish · DX
#27One 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.MDX
#28One 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.Mpolish
#29One 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.MDX
#30One 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).Stidy

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

#FixEffortGain
#31One 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: localeMconsistency
#32One 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).Sconsistency
#33Font 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).MChina wire + fidelity
#34Dark-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).Sdark mode
#35Copy-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.Spolish
#36Vibes 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”.decisionSpolish · deferred ok

Fix points — housekeeping

#FixEffortGain
#37Remove 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.Stidy
#38Delete 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.Stidy

False alarms & already-solid

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