Skip to content

Release v0.0.21 - #29

Merged
ackness merged 14 commits into
mainfrom
v0.0.21-dev
Jul 25, 2026
Merged

Release v0.0.21#29
ackness merged 14 commits into
mainfrom
v0.0.21-dev

Conversation

@ackness

@ackness ackness commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Release v0.0.21

14 commits since v0.0.20. A reliability pass over the web client, from exercising the front-end the way a player does rather than the way its tests do. Nothing here adds capability — three of these fixes were destroying persisted data, four left the player unable to act, and two let a single bad plugin or theme take the whole screen down.

Highlights

Data loss (3)

  • Stored settings survive a failed read or writeset() had no rollback and every call site is void setValue(...), so a quota exception poisoned the in-memory map silently; separately a failed load() was indistinguishable from "nothing stored", and since save() writes a full snapshot the next single-setting change overwrote settings.json and keys.env with just that key. Also fixes a tokened desktop install 401'ing on every boot, which hit exactly this path.
  • A reload no longer clobbers state-patch historyaddStatePatch read the (post-reload empty) in-memory map and wrote it back over the persisted array. Play → reload → one more turn destroyed the whole session's history.
  • SSE events are scoped to their own session — switching sessions mid-turn let session A's envelopes apply to B, and narrative.completed persisted A's text under B's session id. Permanent in local/IDB mode.

Player blocked (4)

  • Suggestion panels no longer lock the composerdraftMessage / selectSuggestion were classified as must-answer interactions, so any turn offering shortcuts disabled free-text input for the rest of the turn.
  • Mobile navigation is wired up — the hamburger button had no onClick while the desktop nav is md: only, leaving the phone top bar decorative.
  • AI request headers encode as UTF-8btoa() throws above U+00FF and both headers carry free user text, with zh-CN as the default locale.
  • API failures are classified — everything collapsed to null, so an expired owner token was reported as "session does not exist".

Crash containment (2)

  • A broken plugin surface no longer takes down the game view — plugin UI specs reached the renderers unvalidated with only a root-level error boundary, and the offending data is persisted, so a reload crashed again.
  • Theme CSS scoping is enforced, and only the active theme is mounted — scope validation passed on a single compliant selector and ignored the rest, while every registered theme was mounted at once; an unselected imported theme could hide the settings dialog needed to remove it.

Performance & i18n (2)

  • First paint ships 580 kB less JavaScript — 1.99 MB → 1.41 MB raw (566 kB → 405 kB gzip). A manual chunk pulled 194 kB of graph code into every chunk, defeating its lazy(). Also deletes a 10 MB demo.gif that shipped in every deploy and installer while the 1.3 MB MP4 built for it sat unused.
  • i18n gaps closed — 7 keys defined in neither locale; browser language detection was dead code (English browser flashed English, then flipped to Chinese permanently). The coverage gate had been missing 73 of 745 keys.

Testing & docs (2)

  • Session E2E now waits for the turn it claims to wait for (data-executing / data-blocked instead of input:disabled, which is enabled mid-turn), with shared helpers and an expectPlayerCanAct invariant.
  • README gains a core-memory panel screenshot for the multi-agent highlight.

Verification

  • pnpm lint — full workspace, exit 0
  • pnpm test — all packages green (web 456, runtime 843, server 699, store 932, plugins/loader/utils passing)
  • pnpm release:preflight — 6/6
  • npx playwright test --list — 26 specs compile
  • Security audit over git log -p main..HEAD — no keys, tokens or local paths

Release

Merging this PR and pushing the v0.0.21 tag triggers .github/workflows/release.yml, which builds the Electron macOS arm64 .dmg / .zip, extracts release notes from the ## [0.0.21] section of docs/CHANGELOG.md, and publishes the GitHub Release.


中文摘要:v0.0.21 是一次 web 端可靠性清理,14 个提交、无新功能。修掉 3 条静默丢数据的路径(设置存储、状态补丁历史、跨会话 SSE 串台)、4 处让玩家无法操作的问题(建议面板锁死输入框、手机端导航失效、中文预设名导致请求失败、错误码全塌成"会话不存在")、2 处崩溃隔离缺失(插件 UI spec 无校验、主题作用域形同虚设),外加首屏 JS 减少 580 kB 与 i18n 补漏。门禁:全量 lint / test / preflight 6/6 / 安全审计均通过。

ackness added 14 commits July 25, 2026 21:45
`btoa(JSON.stringify(...))` throws DOMException on any codepoint above
U+00FF. Both payloads carry free user text — X-Slot-Config embeds the
user-typed custom preset name, X-Plugin-User-Settings embeds arbitrary
per-plugin setting values — and the app's default locale is zh-CN.

The throw happens inside the try around fetch, so it surfaced as a
misleading "Network error" (after burning all five GET retries) and the
request never left the browser. Naming a preset in Chinese therefore
broke every turn, action and world generation until the setting was
found and deleted.

Encode to UTF-8 bytes first, chunked to avoid a stack overflow on large
payloads. This matches what the server already assumes:
apps/server/src/lib/base64-json.ts decodes as UTF-8.
Nothing aborts the previous action stream when the player switches
sessions mid-turn, so envelopes for session A kept arriving while B was
loaded. Only the narrative-delta path was guarded; every other event
type was applied to B, and narrative.completed persisted A's text under
B's session id — permanent in local/IDB mode, still there after reload.

Two guards:

- createSseEventHandler drops an envelope whose sessionId differs from
  the loaded one. Fails open when either id is absent, so the resume
  path (which builds envelopes from event.sessionId) is unaffected.
- finalizeActionExecution takes the session the stream was opened for.
  Without it, the old stream's completion cleared the NEW session's
  executing flag and marked its live runtimes connection-closed.

ensureServerThenRun no longer fires the action when the context sync
fails: the kernel builds its prompt from server-side messages, so
running anyway gives the player a narrator that has silently forgotten
the story. sendMessage passes a resolve callback so the aborted path
still settles and the UI does not stick on "executing".
The only error boundary was AppErrorBoundary at the app root, and the
server validates just the spec envelope (view: z.record(z.unknown())),
so anything inside `view` reached the catalog renderers unvalidated.
Plugin UI specs are not gated by the server-code approval flow, so a
third-party typo — or an LLM-emitted ui-spec block — replaced the whole
game view, including a narrative still streaming. The offending data is
persisted, so a reload crashed again.

- PluginSurfaceBoundary around the panel renderer and ui.render blocks.
  Does not auto-reset on prop change (that would re-throw in a loop for
  a persistently bad spec); recovery is the Try again button or a tab
  switch.
- covelRegistry gets a null prototype so a spec naming "constructor"
  hits the unknown-component fallback instead of resolving to Object.
- Guards at the shared chokepoints: resolveI18n never returns a
  non-string, resolveIcon tolerates a non-string name, list props go
  through asArray/asOptionArray/toTextArray. toTextArray coerces
  numbers and booleans rather than dropping them.
- Depth caps in renderJsonValue and flattenStateValue.
- world-dimensions-panel guards its required arrays with Array.isArray;
  the data is LLM-generated, so `?.` alone would not cover `{}`.
Two independent data-loss paths, both silent:

1. set() mutated the in-memory map and then awaited the adapter with no
   rollback. Every UI call site is `void setValue(...)`, so a quota
   exception (e.g. importing a multi-MB CSS theme) left the map ahead of
   storage with no signal, and every later write re-serialised the same
   poisoned map and failed the same way.

2. A failed load() was indistinguishable from "nothing stored yet".
   Since save() always writes a full snapshot, the next single-setting
   change overwrote settings.json and keys.env with just that one key.

Rollback undoes only the key it touched, not a whole-map snapshot — a
snapshot rollback would also revert a concurrent write that had already
succeeded. It stays synchronous so read-after-write still works in the
same tick (applyThemeSelection relies on it); a write queue would fix
the remaining full-snapshot disk divergence but break that, so the
divergence is documented in the tests instead of papered over.

init() failure now records hydrationError and refuses writes, including
clearAll — a hydration failure shows defaults, which reads as "my
settings are gone", and the player's natural response is Reset.

The REST backend's GETs were missing the bearer header its PUTs already
send, so a tokened desktop install 401'd on every boot and hit exactly
this path.
addStatePatch read the in-memory map — empty after a page reload — and
wrote `[...list, patch]` back to IDB, overwriting the persisted array.
Playing, reloading, then taking one more turn destroyed the session's
entire state-patch history.

Read through IDB before appending, and re-read the map after the await:
one turn commonly commits several state.changed events in a single
flush and sse-handler appends fire-and-forget per event, so without the
re-read two appends in the same tick lose one.

Also here: syncToServer no longer swallows message-sync failures (the
next turn would run against empty server history and the narrator would
visibly forget the story), only a 404 counts as "world/session missing",
and the i18n helper comes from @covel/shared instead of reaching into a
component module.
request() threw a bare Error, so RemoteDataService could not tell 404
from 401 or 500 and mapped everything to null. A hosted player whose
owner token expired was told the session did not exist. ApiError carries
the status; only a genuine 404 becomes null, matching LocalDataService.

Other boundaries that trusted their responses:

- fetchServerHealth checked neither status nor shape, so a captive
  portal's 200 HTML surfaced as a bare SyntaxError far from the cause.
  It now validates both and carries a timeout so a wedged proxy cannot
  hold first paint on a blank page. main.tsx uses it instead of a second
  hand-rolled fetch.
- pingPreset skipped res.ok, yielding `{ ok: undefined }` with no reason
  shown.
- ensureServerSession swallowed sync failures, so the turn ran against
  empty server history with nothing on screen. It propagates now; the
  health probe failing on its own is still tolerated.

The SSE subscription retries client errors up to a bounded streak
instead of forever. It deliberately does NOT give up on the first one:
in local mode the subscription connects as soon as SET_SESSION
dispatches while syncToServer creates the server-side session several
round-trips later, so the opening attempts legitimately 404, and a
token stored after the first attempt turns a 401 into a success.

The bootstrap chain gained a catch: nothing in it may stop the app from
mounting. A rejection used to leave createRoot unreached and the page
permanently blank, which is strictly worse than booting on defaults.
Measured on a real build: eager JS on first paint drops from 1.99 MB to
1.41 MB raw (566 kB to 405 kB gzip), entry chunk from 1.21 MB to 906 kB.

- react-force-graph + d3 were given a manual chunk, and rolldown put a
  shared React CJS interop module in with them. Every chunk — react-vendor
  included — then statically imported 194 kB of graph code, defeating the
  lazy() in graph-canvas.tsx entirely. They are only reachable through a
  dynamic import, so deleting the rule lets rolldown isolate them by
  itself.
- yaml was welded to zod in forms-vendor for the same reason, though it
  is only reached via `await import("yaml")` in the dimension importer.
- Routes were not code-split at all; autoCodeSplitting peels the world
  editor and settings surface out of the entry chunk.
- Three manualChunks rules matched packages that are not installed.
- PluginShowcase, plugin-panel-tabs and session-canvas-hero imported one
  helper through the lib/catalog barrel, which dragged the whole ~90 kB
  catalog onto the landing route. Import the leaf module instead.

The landing page also loaded a 10 MB demo.gif while the 1.3 MB MP4 that
build-media generates for exactly this purpose sat unused. Vite copies
public/ verbatim, so the GIF shipped in every deploy and in the Electron
installer; it is deleted here. The README keeps its own copy under
.assets/images/.

The chunk-size warning now fires on every build, deliberately: raising
the floor to silence it would hide the regression it exists to catch.
The hamburger button had an aria-label and an icon but no onClick, while
the desktop nav and the language toggle are both `md:` only. On a phone
the top bar was decorative: worlds, sessions, plugins and debug were
unreachable and the language could not be switched at all. It opens a
Radix Dialog now, which brings the focus trap, Escape handling and
aria-modal a hand-rolled dropdown would have to reimplement.

session.tsx also subscribed to the `open-plugins` nav event with its own
useSettingsDialog instance. nav-events is a broadcast and GameView
already subscribes with the dialog it actually renders, so this second
instance was set open but never rendered — and then popped up unbidden
the next time the player navigated back to world select.
Validation only required that at least one scoped selector existed and
silently ignored the rest, so `html[data-theme="x"]{} * {display:none}`
passed. Worse, syncThemeStyles mounted every registered theme at once,
so an imported theme the player never selected still applied any rule
that escaped its scope — and kept applying it after a restart, hiding
the settings dialog needed to remove it.

- Only builtins plus the selected custom theme are mounted. This, not
  the scanner, is the actual containment: a theme the player selected
  can restyle everything through a perfectly scoped
  `html[data-theme="x"] * { … }`, so no selector rule can prevent that.
  The scanner exists to catch honest authoring mistakes early, which
  makes a false rejection worse than a miss.
- The scanner therefore blanks comments and neutralises braces inside
  string literals, skips keyframe/descriptor at-rule bodies, tracks
  at-rule nesting, ignores functional-pseudo arguments, and tolerates
  whitespace in the attribute selector. Each of those was a real defect
  found by probing it: two false rejections of valid CSS (@Keyframes,
  a comment mentioning @import) and three bypasses.
- @import is banned outright — a remote fetch from a theme pack doubles
  as a "this player opened the app" beacon.
- Custom themes may not claim a builtin id, which would silently replace
  that builtin's styling.
- stripAtImports runs at load time, covering the settings-import path
  that bypasses parseImportedThemeFile entirely (ui.customThemes is not
  a registered key, so SettingsStore.import skips validation for it).
Seven keys were used in code but defined in neither locale, so zh-CN
players read the inline English defaults. Three of them could never have
worked: plugin.invalidPanelSpec is a string and the code also used it as
a namespace, which i18next cannot resolve — they move to a sibling
namespace.

Browser language detection was dead code: resolveInitialLocale() ran but
ui.locale defaulted to a hardcoded zh-CN and main.tsx applied the store
value unconditionally, so an English browser flashed English and then
flipped to Chinese, permanently.

The gate could not see either problem. It now also flags CJK punctuation
(join("、") reached en-US players verbatim) and checks that every
statically-analysable t() key is defined in both locales. The key scan
reads whole files rather than single lines — prettier wraps long calls,
and a per-line regex missed 73 of the 745 keys.
Suggestion actions (`draftMessage` / `selectSuggestion`) were classified as
pending interactions alongside must-answer ones (`submitForm` /
`selectChoice`). Any turn where scene-prompts or guide offered shortcuts
therefore disabled the main composer for the rest of the turn — the player
could only pick a suggestion or use the plugin panel's own secondary input,
with the most prominent input on screen dead and captioned "finish the
current interaction first".

Queued drafts no longer block either: the composer stays usable while
selections sit in the draft bar, and submitting sends the selections and the
typed line together as one turn — the behaviour ui-panels.md already
documented for the input bar.

Also fold the per-turn runtime timeline once a turn settles. Runtime ids and
timings are useful progress feedback while a turn runs, not residue to leave
sitting in the story flow; an explicit toggle still expands it.
The session specs inferred turn state from `input:disabled`, which is wrong
in both directions: the composer stays enabled mid-turn (submitting steers
the running turn), so `waitForExecDone` passed within its 10s fast path
without ever waiting for the turn; and the composer was located via
`input[type=text]").last()`, which only worked as long as no plugin panel
rendered an input after it.

Expose the two states separately on the composer (`data-executing` /
`data-blocked`) and read those instead. Pull the helpers both specs had
copy-pasted into tests/e2e/helpers/player.ts, and add `expectPlayerCanAct`:
once a turn settles, the composer must accept free text unless a must-answer
block is on screen. A suggestion panel that starts blocking input again fails
that assertion.
The multi-agent highlight had no visual evidence in either README. Add a
screenshot of the core-memory panel over the stage view — running plot,
current scene, and player state, all accumulated by background agents rather
than hand-authored.

`.assets/` is gitignored, so the image is force-added like the existing
README screenshots.
A reliability pass over the web client, from exercising the front-end the way
a player does rather than the way its tests do. Nothing here adds capability.
Three of these fixes were destroying persisted data, four left the player
unable to act, and two let a single bad plugin or theme take the whole screen
down.
@ackness ackness changed the title fix(web): frontend audit remediation — data loss, crash containment, bundle Release v0.0.21 Jul 25, 2026
@ackness
ackness merged commit b5738ac into main Jul 25, 2026
2 checks passed
@ackness
ackness deleted the v0.0.21-dev branch July 27, 2026 03:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant