Skip to content

Release v0.0.22 - #32

Merged
ackness merged 7 commits into
mainfrom
v0.0.22-dev
Jul 26, 2026
Merged

Release v0.0.22#32
ackness merged 7 commits into
mainfrom
v0.0.22-dev

Conversation

@ackness

@ackness ackness commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Release v0.0.22

11 commits since v0.0.21 — 2 features, 3 fixes, 2 CI, 1 release chore.

Highlights

Features (2)

  • Settings → Appearance: 45 adjustable design tokens. The panel offered a theme picker, a light/dark toggle, and a CSS file import; anything finer meant hand-writing a theme. The token system was already complete — every theme defines it, every atom consumes it — so this adds a control layer, not a new pipeline. Tokens are grouped the way a player thinks about the screen (narrative type, interface type, region backgrounds, accents, ambience, shape, layout) and driven by one declaration table. Overrides apply as inline styles on <html>, so clearing one restores the theme's value exactly. Colours store per scheme, sizes and fonts shared. Sliders paint to the DOM and persist only on settle. Ships save-as-theme (snapshots the whole token contract, not just the changes — otherwise "save what I see" would change what you see), data-turn / data-session on <html> so a theme can style what the game is doing, and aurora, a builtin reference theme for glassmorphism / @property gradients / turn-state effects that passes the same import validation a player's theme does.
  • The studio rail starts collapsed and remembers its layout. It held studio configuration a player never acts on while reading, yet opened at 20% every session, leaving the narrative column ~40% of a 1440px viewport. Collapsed it gets 69%, and the player's choice survives a reload via useDefaultLayout rather than hand-rolled localStorage.

Fixes (3)

  • Player messages render as markdown. A bare <p> meant markdown syntax showed as literal characters and real newlines collapsed into spaces — choice text arrived as one flat paragraph. Lone newlines are promoted to hard breaks first, since markdown otherwise folds them into a space. A literal backslash-n is deliberately untouched: that is over-escaped model output, and rewriting it in the view layer would hide a data bug.
  • The composer is locked until the adventure begins. The "begin adventure" hero and the composer's disabled state keyed off unrelated conditions, so the button sat waiting while the input stayed live — and sending opened a turn before any setup runtime had run, leaving the narrator to answer in a world with no character and no opening scene. Kept out of composerBlocked, which has its own meaning and is what e2e reads via data-blocked.
  • Author-facing detail stays out of the player view. A full 36-character turn UUID rode in every message header, a whole line on a phone, while every other author-facing detail in the same file was already gated on viewMode. Plus an accessible name and hover state for the stage dialog, which was clickable but announced as an empty button.

CI (2)

  • The test cache stopped invalidating itself. plugins/** as an input pulled each plugin's run artefacts into the hash, so running the suite guaranteed the next run missed — it never settled above 2/43. The build step also moves behind push (main only): it covers the bundling layer tsc --noEmit cannot see, but not at ~2 minutes per PR push.

Verification

  • pnpm lint — 21/21 tasks green
  • pnpm test — 43/43 tasks green (489 web tests, +16 this release)
  • pnpm check:i18n — OK, both locales cover every new key
  • pnpm deps:check (knip) — clean
  • pnpm release:preflight — 6/6
  • Security audit of main..HEAD — no key material, no local paths
  • Browser-verified end to end: token overrides apply and clear, colour overrides stay scheme-scoped across a light/dark switch, overrides survive a reload, save-as-theme reproduces the source look and keeps a dark-only theme dark, data-turn drives all four states, the composer gate locks and releases at the right moments

Release

Merge this with a merge commit (not squash), then tag v0.0.22 on main to trigger release.yml, which builds the desktop artefacts and publishes the GitHub Release. The release body is extracted from the ## [0.0.22] section of docs/CHANGELOG.md.


本次版本把「外观」从三选一的主题开关变成玩家真正能掌控的东西:45 个设计 token 按界面区域分组开放调节,调好后可以一键存成独立主题包导出分享;框架把回合状态写到 DOM 上,主题终于能对游戏正在发生的事做出反应(生成中呼吸、出错脉冲),并附带一个可直接照抄的 aurora 参考主题。其余是玩家视角的修正:玩家消息现在正常渲染 markdown 与换行;游戏尚未开始时输入框不再能发消息(那会让 AI 在没有角色和开场的空世界里作答);以及把只对作者有意义的回合 UUID 从玩家界面移走。

ackness added 7 commits July 26, 2026 00:40
Every message header carried its full turn UUID:

    玩家 · 19EE34CF-AE21-45BA-B1FE-2C69245EABEA

36 characters that mean nothing to a player, eating a whole line on a
phone. The same file already gates every other author-facing detail on
`viewMode` — the runtime source badge is `detailed`-only, system messages
and non-story assistant kinds are hidden in `parsed` — the turn id was the
one that slipped through. It now follows the same rule and stays in
`detailed` / `raw`.

Also, the stage dialog box is clickable (advance / skip the typewriter) but
had only `cursor: pointer` behind it: no hover state, and no accessible
name, so a screen reader announced an empty button. Adds `aria-label` and a
hover tint.
The left rail holds studio configuration — plugin toggles, model slots,
`deepseek·deepseek-v4-flash` badges — none of which a player acts on while
reading. It opened at 20% on every session, leaving the narrative column
about 40% of a 1440px viewport. Collapsed by default it gets 69%; the
header toggle brings the rail back.

Whatever the player does next now survives a reload. `useDefaultLayout`
ships with react-resizable-panels for exactly this, so the layout is stored
through it rather than through hand-rolled localStorage plumbing. Mobile and
desktop keep separate entries: the mobile group stacks vertically and
renders the right rail in a different slot, so one layout cannot describe
both. Until something is stored, each panel's own `defaultSize` applies —
so a first visit still opens collapsed.

Verified end to end: open → 216px, reload → 216px, drag → 356px, reload →
356px, collapse → 0, reload → 0, and switching viewport writes a separate
`:mobile` key. Entering and leaving immersive still leaves the rail
collapsed.
Player messages went through a bare <p>, so markdown syntax showed up as
literal characters and real newlines collapsed into spaces — choice text
carrying its own line structure arrived as one flat paragraph.

Route them through the same Markdown component the narrative already
uses. Lone newlines are promoted to markdown hard breaks first, since
markdown otherwise folds a single newline into a space and the player's
Enter key would silently do nothing. A regex rather than remark-breaks:
identical result outside fenced code, without the dependency.

This deliberately does not rewrite a literal backslash-n. That is
over-escaped model output, not a line break, and papering over it in the
view layer would hide a data bug and misrender any text discussing \n.
A test pins that boundary.
The "begin adventure" hero renders on `messages.length === 0 &&
!executing && isPreGame`, but the composer only disabled itself for an
unanswered interaction block. The two conditions were unrelated, so the
button sat there waiting to be clicked while the input stayed live.
Sending from that state opened a turn before any setup runtime had run —
the narrator answering in a world with no character and no opening scene.

handleSubmit already guards on composerDisabled, so folding the new
condition into it covers the Enter key as well as the disabled input.

Kept out of composerBlocked on purpose: that flag means "an interaction
is unanswered", drives its own placeholder, and is what e2e reads via
data-blocked. Pre-game gets its own flag and its own message instead of
overloading that meaning.

Lifts the pre-game predicate into selectors so the hero and the composer
share one definition — two copies of the same rule drifting apart is
exactly how the gap appeared.
Appearance stops being a three-way switch. The token system was already
complete — every theme defines it and every atom consumes it — so what was
missing was never the plumbing, only a way for the player to reach it. This
release exposes 45 of those tokens grouped by the region they affect, adds a
path from "I tuned it" to "here is a theme package", and publishes turn state
to the DOM so a theme can finally react to what the game is doing. The rest is
player-facing correctness: two inputs that accepted what they shouldn't, and
one that rendered less than it should.
@ackness
ackness merged commit dedb23d into main Jul 26, 2026
1 check passed
@ackness
ackness deleted the v0.0.22-dev branch July 26, 2026 05:35

@ackness ackness left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post-merge review — REQUEST CHANGES (follow-up required)

Reviewed after merge/release. Validation is fully green (lint 21/21, tests 43/43 · 489 web, build via release.yml, preflight 6/6), but one HIGH defect shipped in v0.0.22.

HIGH — a Chinese theme name silently deletes the previous theme

slugifyThemeId (theme-export.ts:246) strips all non-[a-z0-9], so any all-CJK name reduces to empty and falls back to the constant "custom-theme":

Player input Generated id
我的极光 custom-theme
我的纸本 custom-theme
夜读模式 custom-theme

ensureThemeId (theme-export.ts:261) only de-duplicates against builtin ids, so the second custom-theme passes through. saveCustomTheme writes via filter(entry => entry.id !== theme.id).concat(...) — the earlier theme is dropped with no warning and no undo.

In a Chinese-first product this is the main path, not an edge case. Manual verification missed it because the first save used a Chinese name and the second an English one.

Fix belongs in ensureThemeId: suffix against all existing theme ids, not just builtins. The current comment justifies ignoring custom collisions as "the player updating their own theme" — true for an identical name, wrong for two different names sharing the fallback.

MEDIUM

  • TokenControl.tsx:185aria-label="colour" is hardcoded English and identical across all 48 swatches; a screen-reader user cannot tell the page background from the danger accent. Should use spec.label.
  • AppearancePane.tsx:207 — the theme-name field has only a placeholder, which is not a reliable accessible name and disappears on input.
  • aurora/theme.css:144-156 — a full-viewport blur(90px) layer animating indefinitely. prefers-reduced-motion is honoured, but everyone else composites a very large blurred surface continuously. Consider pausing on visibilitychange or reducing the radius.

LOW

  • CHANGELOG / PR / published release body all say "45 adjustable tokens"; the actual count is 48.
  • overrides.ts:103 — module-level appliedProperties and TokenControl's direct drag-preview writes are two views of the same DOM state that disagree inside the 200ms commit debounce. Narrow window in practice, but the invariant is implicit.

What holds up well

Adding a knob is one row in the schema table rather than a new pipeline. buildThemeCss snapshots the full token contract instead of only the overrides — verified all 48 adjustable tokens are a subset of the 79 snapshot tokens, so lifting inline overrides before reading both schemes cannot leak a dark colour into the light block. composerBlocked was deliberately not overloaded for the pre-game gate. No hardcoded plugin ids; the turn-state hook exposes only kernel-owned state.

Full report: .claude/PRPs/reviews/pr-32-review.md

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