Questify

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 an EntityDrawer, a right-hand sheet on SideSheet/useModalFocus until 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 are required, 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. AdminShell owns the document's only <main>, the padding and a single max-w-7xl frame — pages no longer bring their own container 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 with AdminPageHeader, the app's PageHeader (#743) without the redundant "Admin" eyebrow and with a count slot; 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 the Input and Button primitives. Every admin form result now goes through one ActionMessage: success is a polite status in the semantic success colour (replacing three different greens), a failure is an alert, 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 DataTable with scoped headers, a header that stays pinned while the rows scroll, right-aligned tabular-nums figures, URL-driven sort links (?sort=&dir=) with aria-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 from lg rather than xl, with titles that wrap instead of a fixed 820px minimum, and its whole row links to the report. AI Spend renders cards below md instead 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). IdChip shows 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 every slice(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 use RelativeTime ("3 hr. ago" with the absolute time in the tooltip), the admin instance of LocalDateTime.

Fixes

  • The AI Quest Coach's "X was unavailable, so Y answered instead" notice now describes only the turn it is about (#841). CoachingInterview set it when a reply came back with fallback and 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). DraftSummary in coaching-interview.tsx filled 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. CoachingInterview now branches on the input modality rather than the viewport width through a new useCoarsePointer() 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, and enterKeyHint="enter" labels the key with what it does. With a keyboard nothing changes — Enter sends, Shift+Enter breaks the line, the hint shows — and enterKeyHint="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-hover cannot 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/new scaled 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 now text-base md:text-sm — 16px on phones, the same 14px as before from md up — rather than disabling zoom with maximum-scale=1, which is an accessibility regression and which newer iOS ignores. The fix lives in @questify/ui: Input changes its size, and two new primitives, Textarea and NativeSelect (the native <select>, named so a Radix Select can arrive later), carry the same tokens; the 50-odd hand-rolled <textarea> and <select> elements under apps/web/src, their per-file TEXTAREA_CLASS / SELECT_CLASS constants and the exported QUEST_SORT_SELECT_CLASS are gone, along with the raw timezone, quiet-hours and recap share-link inputs. Three Input call sites that overrode the size back to text-sm lost the override, and the share-link field's text-xs became text-base md:text-xs, since cn() lets such an override win on phones. The coach composer also drops its fixed leading-5 so its auto-grow follows the line height of whichever size is in effect. layout.tsx still exports no viewport, so pinch zoom stays available.

  • Saving a persona, Quest Pack or Quest Jam no longer resets its availability or order (#784). updatePersonaAction, updateQuestPackAction and updateQuestJamAction wrote 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. createQuestPackAction places a new pack after the last one instead of taking an order from the form.

  • revokeRoleAction refuses 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.sql adds admin_quest_jam_entry_counts(), a security definer read 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 and get_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 without quest_jams.write — quest_jams.read alone is not enough — is closed to anon, and returns counts only, no user or quest ids. Covered by supabase/tests/admin_quest_jam_entry_counts.sql and an rls.sql grant assertion; listed in supabase/advisor-allowlist.txt with the other signed-in admin reports (#784).

Tests

  • use-coarse-pointer.test.ts pins the useCoarsePointer() query, that it consults only the primary pointer (the stub throws on any other query), its false default where matchMedia is missing, and that it follows a change event and unsubscribes on unmount; coaching-interview.test.tsx gains 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, the enterKeyHint matches in both, and a mouse-primary touch screen keeps the keyboard behaviour (#839).

  • scripts/phone-input-size.test.mjs (run by pnpm test:scripts) pins the #838 rule: the three @questify/ui primitives set text-base and md:text-sm, nothing under apps/web/src renders 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 unprefixed text-xs / text-sm without text-base beside 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 in revokeRoleAction, the update actions no longer writing availability or order, countRoleAssignments and getAdminQuestJamEntryCounts (#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.ts pins the admin shell contract across every admin page: no page renders its own <main>, py-12, width cap or h1, every page uses AdminPageHeader, none links to /dashboard, and every admin panel imports the shared ActionMessage with no text-emerald-* left. AdminShell, AdminPageHeader and ActionMessage have their own unit tests (#782).

  • Vitest covers DataTable, Pagination, IdChip, RelativeTime and the table query-string helpers, the Users, Audit Log, Privileges and Feedback lists through them, and listAdminUsers' true total, id search and sorts; app/admin/shell-contract.test.ts now also pins that the five admin lists render through DataTable and that no admin page or component hand-truncates an id (#783).