Skip to content

feat(tempvoice): rebuild the config page as a lifecycle flow card - #62

Merged
Abdulkhalek-1 merged 30 commits into
mainfrom
feat/tempvoice-config-redesign
Jul 28, 2026
Merged

Abdulkhalek-1 merged 30 commits into
mainfrom
feat/tempvoice-config-redesign

Conversation

@Abdulkhalek-1

Copy link
Copy Markdown
Member

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:

  • Empty — the flow as a worked example with dashed chips, teaching the mechanic before anything is configured.
  • Collapsed — each saved hub as a one-line chain reusing the same step numbers and channel chips.
  • Editing — expands in place into the full vertical flow. No modal; one card open at a time.

TempVoiceForm.tsx (282 lines holding the list, the form, fetching, validation, submit and delete) is replaced by TempVoiceHubList (orchestrator) + HubCard + HubFlow + HubSummary + ChannelChip.

Scope

Deliberately narrow, per the spec:

  • No new settings. Channel defaults, access limits and panel-button toggles were all considered and declined.
  • The 13-button in-Discord owner panel is untouched.
  • No DB migration, no Prisma change, no dashboard API change. useTempVoice.ts and the Fastify routes are byte-identical.

Shared-component changes are additive only: DiscordSelect gains excludeIds, id, noneLabel and ref; SearchableSelect gains ref, aria-describedby and aria-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 in manager.ts with resolveChannelName() + 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

  • The tempvoice namespace is rewritten and translated across all 48 locales — no English placeholders. A new parity test guards key shape, untranslated values, and placeholder survival.
  • The four step labels are whole translatable sentences, never concatenated fragments, so word order and RTL work per language.
  • The counter interpolates {{used}}/{{max}}, never count — which i18next would treat as a plural trigger.
  • The flow is a real <ol>, so step position is announced; the step numbers are decorative.
  • Focus moves into the card on expand and returns to the Edit button on cancel.
  • Delete offers an Undo toast, matching the Automation rules pattern, instead of deleting silently as before.

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/dashboard has no @types/node and both its tsconfigs are src-only, so no dashboard test file is typechecked by anything. The no-any/no-as rule currently holds by review attention alone. Worth a follow-up.

🤖 Generated with Claude Code

Abdulkhalek-1 and others added 30 commits July 27, 2026 19:43
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>
…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>
@Abdulkhalek-1
Abdulkhalek-1 merged commit d2c85e2 into main Jul 28, 2026
7 checks passed
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