3.1.0
Released
Features
Roles, Personas, Quest Packs and Quest Jams are lists with create and edit in a drawer (#784). Each page used to open on an expanded create form and then render a full edit form for every item — 14 fields per persona, a task textarea per pack — so 15 packs ran to about 10,000px and moving one meant scrolling a screen to see its neighbour. Now each is a
DataTable: Roles (name · key · privileges · users · edit), Personas (name/key · provider · model · Free/Pro tier · speed · cost · recommended · active · order), Quest Packs (emoji · name · category · tasks · order arrows · active) and Quest Jams (title · theme · starts · ends · Live/Upcoming/Paused/Ended — Paused for a Jam switched off before it ended, which is neither listed nor joinable · entries · active). A header "New …" link and each row's "Edit" open anEntityDrawer, a right-hand sheet onSideSheet/useModalFocusuntil the shared Dialog from #648 lands; which drawer is open is URL state (?new=1,?edit=<id>), so the links work from Server Components, a reload keeps the drawer and Back closes it. A role's rename, privileges and delete all live in its drawer. Each field now has one control: availability is a switch on the row and pack order is the row's arrows, and the edit drawer no longer carries an Active box or an Order input that could undo them on save (a new item still chooses Active in the create drawer, and a new pack joins the end of the order). Every Persona field has a label tied to its input, Name, Provider and Model ID arerequired, and an existing persona's key is shown read-only as code.Destructive admin actions ask first (#784). Deleting a role, revoking a privilege from a role and revoking a role from a user now go through
ConfirmAction, the #773 inline two-step strip with focus on Cancel and Escape to cancel, behind a trigger with an accessible name and at least a 24px target. The role delete question says how many accounts hold the role ("3 accounts hold it and will lose it"), or that the count could not be read rather than showing zero. On Users your own SuperAdmin or Admin role chip is refused with a reason a keyboard or screen-reader user can still reach, instead of being one mis-click from locking you out.The admin pages share one shell (#782). The desktop side bar is now sticky and viewport-tall, so the destinations and "Back to app" stay in view on long pages like Quest Packs and Personas instead of stretching to the content's height and scrolling away.
AdminShellowns the document's only<main>, the padding and a singlemax-w-7xlframe — pages no longer bring their owncontainer max-w-{4,5,6}xl py-12, which left a wide screen half empty and spent ~170px before any data, and Quest Packs and Quest Jams no longer nest a second<main>. Every page now titles itself withAdminPageHeader, the app'sPageHeader(#743) without the redundant "Admin" eyebrow and with acountslot; Feedback detail uses its breadcrumb instead of a hand-built back button, and the "Dashboard" buttons on Quest Packs and Quest Jams, which duplicated "Back to app", are gone ("Browse" stays). Users search and the Audit Log filters and pagination are on theInputandButtonprimitives. Every admin form result now goes through oneActionMessage: success is a politestatusin the semantic success colour (replacing three different greens), a failure is analert, where before only the Quest Pack and Quest Jam panels announced a result at all. The feedback triage card's "saved" toast is unchanged.The admin lists share one table (#783). Users, Feedback, Audit Log, Privileges and AI Spend now render through a
DataTablewithscoped headers, a header that stays pinned while the rows scroll, right-alignedtabular-numsfigures, URL-driven sort links (?sort=&dir=) witharia-sort, explicit empty and error states, and stacked cards below a breakpoint so phones never scroll a table sideways. Users is a table instead of a ~180px card per account — Account · Plan · Last active · Quests · Tasks done · AI · Roles · ⋯ — where AI keeps investigations, coach sessions and quest narratives as three counts rather than a total, role chips revoke in place, and the ⋯ menu holds last sign-in, onboarding, join date and Assign role. Users is paginated against a true total ("Showing 101–150 of 1,204 users") instead of silently stopping at 50, its search also accepts a pasted user id, and it sorts by name. Feedback shows its table fromlgrather thanxl, with titles that wrap instead of a fixed 820px minimum, and its whole row links to the report. AI Spend renders cards belowmdinstead of a 48rem table, closing with a totals card so the aggregate figures the table's footer carries are still there on a phone. A failed read on Users, Audit Log or Privileges now says so instead of rendering as an empty list, and a failed role-catalog read on Users says roles cannot be assigned rather than "Every role is assigned".One pager for every admin list (#783): 1-based
?page=links with first / previous / next / last, a "Showing a–b of N" line and a rows-per-page select (?size=), replacing 0-based links on the Audit Log, client buttons on Feedback and a hand-built pager on AI Spend.Admin ids are copyable (#783).
IdChipshows the first eight characters with the whole id in the tooltip, copies the whole id, and links user ids to/admin/users?q=or/admin/audit-log?actor_id=; it replaces everyslice(0, 8)…and every bare 36-character UUID on the dashboard, Users, Audit Log, Feedback, the feedback detail page and AI Spend. Admin timestamps in those lists useRelativeTime("3 hr. ago" with the absolute time in the tooltip), the admin instance ofLocalDateTime.
Fixes
The AI Quest Coach's "X was unavailable, so Y answered instead" notice now describes only the turn it is about (#841).
CoachingInterviewset it when a reply came back withfallbackand never cleared it, so after one substitution it stayed pinned under the chat for the rest of the interview — through later turns the chosen model answered, beside a "Runs on GPT Luna" chip after the user picked another persona, and under the finished "Your quest is ready" draft, where on a phone it pushed Continue and Start over further down. The notice is now a small system line inside the chat directly under the reply the substitute wrote, so it scrolls away with that turn instead of holding a permanent slot in the card, and it is cleared when the next message is sent, on Start over, and when the user picks a different persona — including one picked while the reply was still on its way. A picker that follows the fallback to the served persona does not clear it, since the chip and the notice then agree. A substitution that repeats is announced again under the new reply; the "not charged" / "charged N credits" wording is unchanged.The AI Quest Coach's closing draft summary is readable in dark mode again (#840).
DraftSummaryincoaching-interview.tsxfilled the quest title, goal and task list with the brand gold at half strength (bg-secondary/50) while its text kept the default and muted foregrounds, so the goal, the task count, the list numbers and every task's details measured 2.58:1 in dark mode (and 4.44:1 in light) — on a phone the task details were barely legible. The summary is now a gold tint with a gold border (bg-secondary/15 border-secondary/40), which keeps it reading as the finished result rather than an ordinary assistant bubble and puts muted text at 6.5:1 in dark mode and 5.4:1 in light. A component test fails if the summary's gold fill goes back above /20.The AI Quest Coach composer no longer gives a phone a desktop keyboard (#839). On a touch-only device the placeholder read "Type your answer… (Enter to send)", the hint below it said "Shift+Enter for a new line · Enter to send" — a combination no phone keyboard has — and the return key sent the answer, so a multi-line reply was impossible.
CoachingInterviewnow branches on the input modality rather than the viewport width through a newuseCoarsePointer()hook ((hover: none) and (pointer: coarse)— the primary pointer is touch; false on the server and first paint, followed live): on a touch-primary device the return key breaks the line, the Send button sends, the placeholder is just "Type your answer…" / "What should change?", the hint is not rendered, andenterKeyHint="enter"labels the key with what it does. With a keyboard nothing changes — Enter sends, Shift+Enter breaks the line, the hint shows — andenterKeyHint="send"now says so on a touch screen beside a mouse. ⌘/Ctrl+Enter sends in both modalities. The hook reads only the primary pointer on purpose:any-pointer/any-hovercannot tell a trackpad from a stylus and nothing in CSS sees a hardware keyboard, and the two misreads are not equal — a phone with a hovering pen taken for a keyboard device would lose line breaks again, while an iPad with a trackpad or keyboard case taken for touch merely sends with a tap or ⌘/Ctrl+Enter.Focusing a form control on a phone no longer zooms the page (#838). iOS Safari zooms to make any focused control under 16px legible and never zooms back out, and every control in the app was 14px, so tapping into the AI Quest Coach answer box on
/quests/newscaled the page by 1.14×: the coach card then ran past the viewport, the Cancel button clipped to "Cance", the progress bar and chat bubbles were cut off, and the rest of the New Quest flow scrolled sideways. Controls are nowtext-base md:text-sm— 16px on phones, the same 14px as before frommdup — rather than disabling zoom withmaximum-scale=1, which is an accessibility regression and which newer iOS ignores. The fix lives in@questify/ui:Inputchanges its size, and two new primitives,TextareaandNativeSelect(the native<select>, named so a RadixSelectcan arrive later), carry the same tokens; the 50-odd hand-rolled<textarea>and<select>elements underapps/web/src, their per-fileTEXTAREA_CLASS/SELECT_CLASSconstants and the exportedQUEST_SORT_SELECT_CLASSare gone, along with the raw timezone, quiet-hours and recap share-link inputs. ThreeInputcall sites that overrode the size back totext-smlost the override, and the share-link field'stext-xsbecametext-base md:text-xs, sincecn()lets such an override win on phones. The coach composer also drops its fixedleading-5so its auto-grow follows the line height of whichever size is in effect.layout.tsxstill exports noviewport, so pinch zoom stays available.Saving a persona, Quest Pack or Quest Jam no longer resets its availability or order (#784).
updatePersonaAction,updateQuestPackActionandupdateQuestJamActionwrote the Active checkbox (and a pack's sort order) from the form, so a save could quietly undo the Activate button pressed since the form was rendered; they no longer write those fields, which belong to the list's switch and arrows.createQuestPackActionplaces a new pack after the last one instead of taking an order from the form.revokeRoleActionrefuses to revoke your own SuperAdmin or Admin role ("You cannot revoke your own admin role."), the rule the Users table now mirrors, so the admin area can no longer be locked by its own admin from the Users page (#784).
Technical changes· 7 entries for database, tooling, CI, tests and docs
Database
20261102120000_admin_quest_jam_entry_counts.sqladdsadmin_quest_jam_entry_counts(), asecurity definerread that returns, per Quest Jam, how many entries and completed entries it has, for the admin Quest Jams list's Entries column. RLS keeps entries private to the entrant andget_quest_jam_summaries()only covers active Jams that have not ended, so nothing an admin could read before counted a paused or ended Jam. It refuses (42501) any caller withoutquest_jams.write—quest_jams.readalone is not enough — is closed toanon, and returns counts only, no user or quest ids. Covered bysupabase/tests/admin_quest_jam_entry_counts.sqland anrls.sqlgrant assertion; listed insupabase/advisor-allowlist.txtwith the other signed-in admin reports (#784).
Tests
use-coarse-pointer.test.tspins theuseCoarsePointer()query, that it consults only the primary pointer (the stub throws on any other query), its false default wherematchMediais missing, and that it follows achangeevent and unsubscribes on unmount;coaching-interview.test.tsxgains the composer-by-modality cases — Enter sends and Shift+Enter does not with a keyboard, Enter is left to the textarea and no Shift+Enter or Enter-to-send copy renders on a touch-primary device, ⌘/Ctrl+Enter sends in both, theenterKeyHintmatches in both, and a mouse-primary touch screen keeps the keyboard behaviour (#839).scripts/phone-input-size.test.mjs(run bypnpm test:scripts) pins the #838 rule: the three@questify/uiprimitives settext-baseandmd:text-sm, nothing underapps/web/srcrenders a raw<textarea>or<select>, a raw<input>is only one of the text-free types (hidden, checkbox, radio, file, range, …), and no control's class list sets an unprefixedtext-xs/text-smwithouttext-basebeside it — the override that would bring the 14px phone size, and the zoom, back.Vitest covers
EntityDrawer(URL close, Escape refused mid-save and left alone when a confirm strip handled it, one close per success),ConfirmAction, the drawer URL helpers, the four rebuilt panels — including that the edit drawers carry no Active or Order field and that every Persona field is labelled — the self-revoke refusal on Users and inrevokeRoleAction, the update actions no longer writing availability or order,countRoleAssignmentsandgetAdminQuestJamEntryCounts(#784).e2e/admin-entity-drawer.spec.ts(live only) drives the drawers in a real browser as a SuperAdmin: a role is created from the header link, its edit drawer survives a reload and closes on Back and Cancel, Escape on the delete question cancels it without discarding the drawer, and the confirmed delete closes the drawer with "Role deleted."; a Quest Jam switched off reads as Paused and stays off after its drawer is saved (#784).app/admin/shell-contract.test.tspins the admin shell contract across every admin page: no page renders its own<main>,py-12, width cap orh1, every page usesAdminPageHeader, none links to/dashboard, and every admin panel imports the sharedActionMessagewith notext-emerald-*left.AdminShell,AdminPageHeaderandActionMessagehave their own unit tests (#782).Vitest covers
DataTable,Pagination,IdChip,RelativeTimeand the table query-string helpers, the Users, Audit Log, Privileges and Feedback lists through them, andlistAdminUsers' true total, id search and sorts;app/admin/shell-contract.test.tsnow also pins that the five admin lists render throughDataTableand that no admin page or component hand-truncates an id (#783).