feat(tempvoice): rebuild the config page as a lifecycle flow card - #62
Merged
Merged
Conversation
Restructures the dashboard TempVoice config page so the three existing settings become the steps of the join-to-create lifecycle. The page never explained the mechanic it configures; that is the problem being solved, not the field count. Scope is deliberately narrow: dashboard client only, no new settings, the 13-button in-Discord owner panel untouched. Bundles a one-word bot fix for template substitution parity, which this design makes user-visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine TDD tasks covering the flow-card components, the additive excludeIds prop on DiscordSelect, the 48-locale namespace rewrite with a new parity test, and the bot template-substitution fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HubCard reaches usePreviewContext, which calls the react-query useAuth and useGuilds hooks. The Task 5 and Task 6 test files neither wrapped in a QueryClientProvider nor mocked them, so every test would have thrown "No QueryClient set" on dispatch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Global Constraints forbid `as` in test files, but the ResizeObserver stubs copied the cast from the existing discord-select test. Replaced with a class typed against the DOM lib interface, and Task 1 now fixes the pre-existing cast in the file it already edits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… remove dead aria-hidden prop
…s on re-entry Fixes 6 issues from Task 5 review, all in code that shipped from the implementation brief: - CRITICAL: summary mode derived the resolved name from the editor's draft state instead of the saved config, so a cancelled edit could show a name that exists nowhere on the server while the bot kept using the real one. Summary now always resolves from `config.nameTemplate`. - The orchestrator toggles `mode` on a long-lived card instance rather than unmounting it, so a mount-only useState left stale drafts and failure banners behind on re-entry. Re-seed hubChannelId/categoryId/nameTemplate and clear fieldError/submitError whenever the card transitions into editor mode (React's mid-render state-adjustment pattern, no useEffect). - excludeHubIds and categoryId seeding had zero test coverage — added tests that fail without the corresponding wiring. - The step-2 live preview had zero assertion coverage — added one. - Threaded aria-describedby/aria-invalid through DiscordSelect and SearchableSelect (mirroring the existing required -> aria-required pattern) so the validation error is announced when focus reaches the invalid picker, not just visually rendered. - Save/Cancel buttons now meet the 44px touch-target constraint already applied to Edit/Delete in the same file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…state Ties Tasks 2-5 together: fetches guild hubs, owns which single HubCard is expanded (toggling `mode` on a stable key, never remounting), holds the create/update/delete mutations with Undo-on-delete, enforces the 10-hub cap, and teaches the hub mechanic via an inert HubFlow worked example when nothing is configured yet.
The plan told implementers to expect ~5 pre-existing guildMemberAdd/ready bot failures. That is conditional, not universal: those tests reach a real Postgres, so they fail only where the reachable DB has no migrations applied - a fresh worktree or checkout. This repo's dev DB is migrated and the full gate is green (dashboard 903, bot 372, systems 328, typecheck 14/14), so the guidance was training implementers to wave away real regressions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n edge cases Fix round 1 for the hub list orchestrator: - Delete and Undo no longer swallow rejected mutations; both now surface toast.error with the server message instead of failing silently while claiming (or implying) success. - Undo's own test now actually invokes the captured handler and asserts the recreate payload, instead of only checking the toast label. - The empty-state CTA and header Add button share the id "tv-add-hub" (now mutually exclusive per below) so focus restoration works regardless of which one opened the "new" card; collapse() falls back to the section heading when neither is mounted (e.g. the save that fills the last cap slot removes the Add affordance entirely). - The header Add button no longer renders alongside the empty-state CTA.
Task 6's error handling calls toast.restored on the ordinary Undo success path, plus toast.deleteFailed and toast.undoFailed on rejection. None existed in en/tempvoice.json or in the block Task 7 pastes, so every successful Undo would have rendered the literal key to the admin. Task 8's parity test compares each locale against en, so a key missing from en too is invisible to it - nothing would have caught this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replace `categoryId: null as string | null` with a shared SeedConfig type and an explicit return-type annotation on the Array.from mapper — no cast needed, same runtime shape. Reduces duplication in the createMutate mock by reusing SeedConfig via Omit<SeedConfig, "id"> for its parameter type.
Author the full 31-key tempvoice namespace in all 47 non-English locales to
match the flow-card redesign, replacing the old form.*/hubs.* shape.
The four flow.step* strings form one sentence broken across the four numbered
steps of a temp-voice channel's lifecycle. The English chains them with leading
ellipses; that device is kept only where the language chains the same way. It is
dropped for ja/ko/zh-CN/zh-TW, which are verb-final, and the clause content is
reordered for the SOV locales (hi/ur/bn/ta/tr/fa) so each fragment stays
grammatical beside its own form control. et/fi/lt/lv/el gain an overt head noun
in step 3, where a bare "placed in" has nothing to attach to.
fields.templatePlaceholder stays byte-identical everywhere: it is a literal
template value the admin saves and must match DEFAULT_TEMPLATE in HubCard, not
display copy. list.counter keeps {{used}}/{{max}} with the connecting word
translated in 41 locales; et/eu/fi/hu/zh-* use a separator instead, because
"of N" in those languages suffixes the numeral (vowel harmony / final sound) and
no fixed suffix is correct for an interpolated number. No locale introduces a
`count` variable, which i18next would treat as a plural trigger.
Add a parity test guarding key-shape equality with English, that no value is
left as English outside the allow-list, that every placeholder survives, and
that no locale interpolates `count`.
The plan's parity test allow-listed two keys and dropped an assertion the
reference test (command-palette) makes. None of the 47 translations violate
any of this today; these are guards against the next translation pass.
fields.templatePlaceholder was only exempted from the must-differ rule, never
asserted. Nothing in the commit enforced its byte-identity. A translator seeing
"{user}'s Channel" sitting in an otherwise fully-translated fr file would
reasonably localize it to "Canal de {user}", suite still green — and that now
contradicts DEFAULT_TEMPLATE in HubCard.tsx, so a French admin accepting the
placeholder saves a template that is not the default. Now asserted positively,
sharing one TEMPLATE_KEY constant with the exemption so the two cannot drift.
list.counter needed no exemption: zero of 47 locales are byte-identical to
English on it, so the slack protected nothing and only left room for locale 48
to copy "{{used}} of {{max}}" through and ship English "of" forever. Removed.
Restore the empty-value assertion. "empty.cta": "" previously passed every
check — it differs from English and expects no placeholder.
Each assertion verified to fire by deliberate breakage before committing.
Expanding a hub card destroys the control the user just activated: Edit lives in HubCard's summary branch, which the form branch replaces, and the header Add button is suppressed while any card is expanded. Either way the activating button unmounts, focus falls to <body>, and a keyboard user's tab position resets to the top of the document. The deleted TempVoiceForm kept the list rendered alongside the form, so its Edit button stayed mounted — this is a regression, not just the missing half of the spec's focus contract (spec line 207 requires focus to move to step 1's picker on expand as well as return to Edit on cancel; only the return half shipped). Focus is moved from an effect rather than the render-phase re-seed block: hubRef is only attached after commit, and .focus() must not run during render. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The empty state's worked example carried `inert`, which removed the entire subtree from the accessibility tree. It guarded nothing: every step renders a <p> plus ChannelChip <span>s, so there is not one focusable node inside that <ol> for `inert` to keep out of the tab order. What a screen-reader user got on an unconfigured page was the heading, then "Example — this is how a voice hub works:", then the CTA — a caption ending in a colon followed by nothing, on the page whose entire purpose is to teach the mechanic. The spec is self-contradictory here: it lists `inert` among the non-interactivity signals (line 85) but justifies empty.exampleCaption on the premise that the example IS announced (lines 88-89). Resolved in favour of the caption's rationale, which serves the branch's stated goal. Non-interactivity is now signalled the way the spec also asked for and Task 6 never built: dashed chip borders. Same border token, full opacity, so the teaching copy still meets 4.5:1 — the cue is stroke style, not dimming. `example` survives as `data-example` on the <ol>. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"{user}'s Channel" lived in HubCard as its own literal, a third copy beside
@fluxcore/systems/tempVoice/constants and the locale placeholder. HubCard now
imports DEFAULT_NAME_TEMPLATE from the same module TempVoiceHubList already
imports the config cap from.
An empty nameTemplate is reachable, not theoretical: POST defaults it, but PUT
spreads body.nameTemplate straight through and TempVoiceFormSchema has no
.min(1) — so create a hub, Edit, clear the name, Save, and "" is persisted.
The summary chip then rendered blank and the editor previewed blank, while the
bot's own `template || DEFAULT_NAME_TEMPLATE` really created "Ahmad's Channel".
Both render sites now apply the same fallback, mirroring resolveChannelName.
The schema and the PUT handler are deliberately untouched: that is the API
surface this branch froze.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four step labels were translated; the content they teach with was not. "Join to Create" / "Voice Channels" were .tsx literals, so a Japanese or Arabic admin saw translated instructional prose wrapped around English strings — in the empty state that is the redesign's centrepiece. Because they were literals rather than locale keys, i18n-parity.test.ts structurally could not see them. empty.exampleHub and empty.exampleCategory added to en and translated into the other 47 locales, following each file's own established terminology for a voice channel (fr "salon vocal", th "ห้องเสียง", sw "chaneli ya sauti"). Inserted as text ahead of the existing "cta" line, so no file was re-serialised — the diff is exactly +2/-0 on all 48 tempvoice.json and nothing else under locales/. The step-2 name is derived instead of translated: it runs DEFAULT_NAME_TEMPLATE through the same resolver the editor preview and the summary use, so it shows the signed-in admin's own display name, stays honest about what creating a hub produces, and avoids asking 47 translators to render a possessive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were structurally untestable where they lived.
The Edit -> Save update path had zero end-to-end coverage: all four
TempVoiceHubList test files mocked useUpdateTempVoice with an anonymous inline
vi.fn() nobody held a reference to, and no test ever clicked Edit -> change ->
Save. handleSubmit's update branch, its { configId, data } argument shape (the
configId goes in the PUT path, so a flattened payload would PUT to the wrong
URL) and toast.updated were all uncovered — swapping the create/update branches
shipped green.
"Submit failure keeps the card expanded" was asserted only in HubCard.test.tsx,
which renders HubCard directly with mode="editor" hardcoded, so that card
cannot collapse and the assertion cannot fail. The real contract is that the
orchestrator lets the rejection propagate: one `.catch(() => {})` on the
mutateAsync call collapses the card and discards the admin's typing, with every
existing test still passing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both parity regexes matched only i18next's bare interpolation. Its formatted
variant — `{{count, number}}` — slipped past the placeholder-parity check
(reported as no placeholder at all) and past the count ban, which is the one
guard that exists specifically to stop a variable named `count` switching
i18next into plural mode where a locale short a CLDR category renders nothing.
Verified against a temporarily injected de/list.counter: the old patterns saw
['max'] and no count; the widened ones see ['count','max'] and fail the ban.
No locale uses the formatted form today, so this is inert against the current
48 files and load-bearing against the next translator. de restored
byte-identical afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`expanded` holds a hub id, and that id goes stale whenever the hub disappears from a background refetch — another admin deletes it, or this admin deletes it in a second tab. `expanded !== null` then reads as "a card is open" while no card is rendered, hiding the Add button with no way to get it back short of a page reload. The empty-state branch had the same latent bug from the other direction: with every hub gone and a stale id, it fell through to the else branch and rendered an empty <div> — no worked example, no CTA. Both now ask the list rather than trusting the id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Why
The TempVoice config page never explained the mechanic it configures. An admin landed on a field labelled "Hub Channel *" with no way to learn from the page that joining that channel is what spawns a temporary voice channel, or that the spawned channel deletes itself when it empties. The concept had to be known in advance.
That comprehension gap was the problem — not the field count. The page still has exactly three settings.
What changed
The three settings are now the three steps of a temporary channel's lifecycle, plus a fourth step with no control that simply states the channel auto-deletes — the half of the model that was previously invisible.
One component renders three states:
TempVoiceForm.tsx(282 lines holding the list, the form, fetching, validation, submit and delete) is replaced byTempVoiceHubList(orchestrator) +HubCard+HubFlow+HubSummary+ChannelChip.Scope
Deliberately narrow, per the spec:
useTempVoice.tsand the Fastify routes are byte-identical.Shared-component changes are additive only:
DiscordSelectgainsexcludeIds,id,noneLabelandref;SearchableSelectgainsref,aria-describedbyandaria-invalid. Every existing call site renders identically.Bundled fix
The bot resolved name templates with
String.replace("{user}", …)— first occurrence only — while the dashboard preview replaced all of them.{user} & {user}previewed as "Ahmad & Ahmad" but really created "Ahmad & {user}". Harmless while the preview was an ignorable grey line; this PR promotes it to a prominent chip, so the divergence would have become user-visible. Fixed inmanager.tswithresolveChannelName()+replaceAll.Also brought into parity: an empty saved template now falls back to the bot's default in both the summary and the live preview, rather than rendering blank while the bot creates
"<name>'s Channel".i18n & accessibility
tempvoicenamespace is rewritten and translated across all 48 locales — no English placeholders. A new parity test guards key shape, untranslated values, and placeholder survival.{{used}}/{{max}}, nevercount— which i18next would treat as a plural trigger.<ol>, so step position is announced; the step numbers are decorative.Verification
Full gate green on this tree: dashboard 1207, bot 374, systems 328, typecheck 14/14.
Built via subagent-driven development: nine tasks each behind an independent review gate, then a whole-branch review plus one fix wave. The reviews found real defects — a summary that rendered a cancelled draft instead of the saved value, swallowed mutation rejections that made a failed Undo look like success, and several tests that could not fail.
Known gap, not addressed here:
apps/dashboardhas no@types/nodeand both its tsconfigs are src-only, so no dashboard test file is typechecked by anything. The no-any/no-asrule currently holds by review attention alone. Worth a follow-up.🤖 Generated with Claude Code