Questify

3.0.0

Released

Features

  • The auth flows after the sign-in form got their missing states (#781). Sign-up asks for email and password only: the required Name field is gone (the profile starts from the address's local part or the OAuth provider's name, and onboarding asks what to call the person), a password shorter than eight characters is stopped in the browser instead of after a round-trip, the length hint turns into a check once it is met, the terms sentence now sits above "Create account" instead of below the form, and an invitation's pre-filled address (?email=) is read-only with a "Use a different email" control, because editing it quietly produced an account the invitation would not accept. Forgot password swaps the field for a "Check your inbox" panel with "Back to sign in", "Use a different email" and a "Resend link" button on a 30-second cooldown, where before a success banner sat above a still-live form; the login page's "Forgot password?" link carries the typed address (?email=). Reset password checks the recovery session when the page loads and, without one, shows "This link has expired" with a "Request a new link" button instead of a form that only failed after both fields were filled; the page now has a footer, the in-form expiry error links to a new request, and a completed reset lands on /dashboard?password_updated=1 — or /onboarding?password_updated=1 for an account that has not finished onboarding, since the middleware would otherwise re-point /dashboard and drop the query — which shows a "Your password has been updated" status in place of the old silent redirect. /auth/callback now sends a recovery link it cannot use (a failed code exchange, a missing code, or Supabase's own ?error= for an expired or reused link) to /auth/reset-password?error=link_expired instead of the login form's generic error, and the page shows the expired state for that flag even when an older session is still signed in, offering "Change your password in Settings" there because the forgot-password page would bounce a signed-in visitor. Confirm email gains a MailCheck tile, says the link works for one hour, and offers "Resend the email" (new resendConfirmationAction → supabase.auth.resend({ type: 'signup' }), 60-second cooldown that starts on arrival) with "Already confirmed? Sign in · Wrong email? Start over" below; signup now passes next to the confirm page so a resent link keeps an invitation's destination. The resend answers the same whether or not the address is waiting on confirmation and reports only rate limiting, as the reset request already did. The dashboard greeting now uses the profile name ahead of user_metadata.full_name, which only the removed field and OAuth set — without that an email signup would have been greeted by its address even after naming itself in onboarding (#781)

  • The Privacy Policy and Terms of Service now share one layout, LegalPage in components/legal/legal-page.tsx (#779). An "On this page" list built from each page's sections array sits above the body on phones and tablets and becomes a sticky rail beside it from lg (lg:grid-cols-[14rem_1fr], lg:sticky lg:top-24), so the 14 Terms sections and 9 Privacy sections are one click away instead of a long scroll. Each <section> now carries its bare id as an anchor (/terms#plans-and-billing, /privacy#data-retention) with scroll-mt-24 to clear the sticky header; the heading keeps its ${id}-heading id for aria-labelledby. The body is capped at max-w-[34rem], measured in Chromium at about 72 characters a line (longest 78) where the old max-w-3xl column ran 95–100, while the header stays max-w-3xl. It is deliberately not the 72ch the issue proposed: ch is the width of a "0", about 10.6px in Geist at 16px against an average letter of about 7.5px, so a 72ch column still measured 97 characters a line. Under the "Legal" eyebrow a LegalNav row links Privacy Policy · Terms of Service · Pricing with aria-current="page" on the one being read; before this Privacy never linked to Terms. Pricing appears only with NEXT_PUBLIC_FEATURE_BILLING, like the header and the Terms billing paragraph, because /pricing 404s without it. Each page now keeps a newest-first CHANGES list of { date, summary }: the first entry is the "Last updated" date (so the date cannot move without a summary), and the list renders as a collapsed "Recent changes" disclosure under it, which is what backs the Privacy promise to highlight material changes. The policy wording itself is unchanged. The issue's signed-out profile link needed nothing: the Privacy page now links /settings/account, and /settings is in PROTECTED_PATH_PREFIXES, so the middleware already sends a signed-out reader to login with next (#744, #770).

  • /pricing shows one Pro price at a time (#778). A segmented Monthly · Annual toggle (PricingProCard, Annual by default) swaps the price and the one subscribe button that goes with it. Before, both prices sat side by side, wrapping to two rows on a phone, and a signed-in free account got two full-width subscribe buttons with nothing to tell them apart. Annual carries a "Save N%" badge worked out from Stripe's two amounts by annualSavingsPercent() in lib/billing/plans.ts. It rounds down, and is left off when the currencies differ or the annual price is not cheaper. The other interval's price stays on screen as a sentence ("Or $8.00 billed monthly."), because Stripe's activation review wants every price visible without a click. A deployment that sells one interval shows that price without a toggle, as before (#774). A signed-in free account now sees "Your plan" on the Free card and a disabled Current plan button, where it used to get a Free card with no action and no status. From sm the two prose feature lists give way to a five-row Feature · Free · Pro table (PlanComparison, rows in PLAN_COMPARISON_ROWS). Its allowance lines are the same constants as the lists, so the two can never state different allowances. Phones keep the lists. The billing terms are <details> items with anchors — /pricing#cancel, #refunds and a new #credits answer explaining how AI credits are spent, reset and not rolled over. They start open so the terms stay readable without a click, and the cancel and refund wording is unchanged. The heading is now the serif h1 under a "Plans" eyebrow, matching the legal pages. ProPlanCard takes its price row as a pricing slot instead of prices/intervals, and the settings page's card is unchanged. The branded 404 for a billing-off install is left to the loading/error issue.

  • The landing page was reworked for phones and trimmed of repeated content (#777). At 375×667 the hero demo now starts on the first screen instead of a scroll below it: the phone text stack is tighter (py-10, text-base body) and HeroQuestDemo is h-[17rem] with p-3 below sm. The "Feature highlights" section, which pitched Quest Packs, Challenges and Achievements a second time with the same card pattern as the product-path cards, was removed. What it added is now a compact two-column checklist under "How it works": steps and deadlines, Party Quests and AI investigations always, plus comments, deadline reminders, attachments, Guilds and the weekly report only where their NEXT_PUBLIC_FEATURE_* flag is on, so a deployment never advertises something a new account cannot find. The three "How it works" steps now describe the loop without naming those features again. The mid-page signup button was dropped. Below md, a new LandingStickyCta bar (Create a free account + Sign in) slides in once the hero has scrolled away and hides again when the closing section comes into view, so it never covers that section or the footer. The link-less "Remember the progress" card now ends in a RecapPreview thumbnail labelled as a sample, because recaps and achievements have no public page to link to. The product-path grid steps through two columns at sm, with the third card spanning the row. Where billing is on, a line under the hero buttons reads "Everything is free · Pro adds frontier AI models" and links to /pricing. The shared CTA labels and the header's Pricing link already landed with #746 and are unchanged here.

  • The guild invitation, Party Quest invitation and unsubscribe pages now share one card, components/public/token-card.tsx, instead of three hand-rolled copies that had already drifted (#776). Someone who opens an invitation while signed in to a different account now gets a Sign out button that brings them straight back to the invitation (/auth/logout follows an internal next form field), where before they had to sign out and dig the link out of their email again. Every link that no longer works — invalid, expired, withdrawn, already used, or a deleted Party Quest — offers Go to Questify and Sign in (or, signed in, Go to your guilds / quests) instead of a card with nowhere to go, and so does a broken unsubscribe link. A pending invitation now says how many members the guild or party has and who runs it, and a Party Quest invitation how many of its tasks are done. Once an unsubscribe link has changed something, the page lists the person's other Questify emails with a Turn off / Turn on button each — only the categories this deployment offers — and a "Stop all Questify emails" button while anything is still on, so stopping everything no longer takes eight separate links or signing in. A mail client's one-click unsubscribe still turns off only the one category its link names.

  • Onboarding was reworked for phones and for the wrong-account case. It now has its own (onboarding) route group, whose compact header carries Sign out and a quiet "Finish setup later" link (the old "Skip setup" footer button, which sat a mis-tap from "Skip for now"); PublicHeader gained a signedInAction slot for it. Each step ends in one footer row — Back · skip this step · Continue — with Continue the only primary button: it stays inside the step's form, while Back and skip are form=-attributed buttons that submit OnboardingNavForms' separate forms, so a half-filled step can still be left without tripping its validation. The Quest Pack step lists "Start from scratch" first, lays the packs out two-up from sm, and its skip now reads "Decide later" instead of repeating the scratch option's label. The avatar is an upload into the avatars bucket, the same flow as /settings/profile — AvatarPicker and saveWithAvatarDraft are now shared by both forms, and the server half lives in lib/profile/avatar-storage.ts — instead of an image-URL field few new users could fill; replacing a photo on a revisited step removes the earlier upload, an external URL stored by the old field is left alone, and when the step fails only after the profile already points at the new upload (the onboarding row could not advance) the action returns avatarSaved so the browser keeps the object instead of cleaning it up. The wizard mounts the app shell's ThemePreferenceSync, because it renders outside that shell: a theme picked on the profile step now repaints the rest of the wizard as soon as the step's revalidation brings the saved preference back, where before it waited for the dashboard. On a 375px phone the step card now starts within 160px of the top: py-6 sm:py-12, space-y-6, the welcome heading on the first step only (after it the step title is the h1 and the card keeps just the description), "Step N of 4" beside the heading, and the list of step titles hidden below sm. (#775)

  • /settings/billing is where a free account upgrades and a paying one checks its usage. Checkout used to be three pages away — the Plan card linked to /pricing, the only page with an UpgradeButton — and it now starts from the Plan card itself, with a button for each interval Checkout can actually sell, quoting Stripe's price (a failed price read keeps the button and drops the amount). listCheckoutIntervals() in lib/billing/stripe.ts decides which, so a deployment that prices only one interval no longer shows a button that answers 503 from /api/billing/checkout — on /pricing too, which had the same gap — and listProPrices() now quotes each of those intervals on its own instead of returning nothing unless both price ids are set, so that one button keeps its amount and /pricing no longer lists a price column for a plan nobody can buy. isStorefrontConfigured(), its both-or-nothing guard, is gone and Compare plans as a secondary link. The page is two columns from lg: the Plan card and a new AI usage card (AiUsageCard — a Pro balance against its allowance with a meter, or a free account's actions left today, never a credit count, and a plain "could not be loaded" rather than a zero on a failed read) on the left, and the Pro card from /pricing on the right, now shared as components/billing/plan-cards.tsx so the two pages cannot describe different products. A Pro account skips the two Stripe price reads entirely. The sentence about invoices, payment methods and cancellation living in the Stripe billing portal now appears only to an account that has a portal to open, matching the Manage billing button it describes. After checkout, CheckoutReturnStatus re-renders the page with router.refresh() every 2.5s for up to 15s until the webhook's tier lands, then says Pro is unlocked; past the window it offers Check again instead of telling the customer to refresh by hand. It never claims Pro before the server reads it. (#774)

  • Guilds are quicker to scan and harder to break by accident. /guilds lays each group out as a grid of compact cards (sm:grid-cols-2 lg:grid-cols-3). Each card used to be a full-width ~130px card with one button. A card's name is now a next/link stretched over the whole card, so opening a guild is a client navigation with a card-sized target instead of a full reload from a 36px button. Leave asks once more in place ("Leave Sunday Dinner? This is permanent — you can't be invited back to this guild.", focus on Cancel, Escape backs out, and focus returns to Leave if you back out) — it used to act on one click with no hint that a closed membership is never reopened and guild_members_guild_user_unique allows one row per account per guild, so the owner cannot invite a leaver back. A failed action is reported on the card it came from. A success that moves or removes the card (Accept, Decline, Leave) is announced once above the list and names the guild, since the card is gone by the time the page revalidates. A first visit is one card holding the explainer and the create form; creating the first guild announces it above the list and moves focus to "Guilds you run", since the form goes away as the guild arrives. With a guild, the explainer and the empty "You do not run a guild yet" lines are gone, an empty group is left out, and New guild in the header opens the create form in a dialog on useModalFocus, which hands focus back to that button however it closes. The claim-code form is a "Have a claim code?" disclosure under the list. /guilds/[id] puts the roster on the left and, for the owner, a 320px column on the right from lg (lg:grid-cols-[minmax(0,1fr)_320px]) with Add member, the waiting email invitations and Guild settings. Each roster row shows the name, its status, at most one inline action and a ⋯ menu (OverflowMenu) — before, it was up to four labelled buttons that wrapped to two lines on a phone. An accountless member's inline action is Invite by email, or Claim code while NEXT_PUBLIC_FEATURE_GUILD_INVITES is off. Claim code, Make owner and Remove are in the menu. Make owner opens a confirmation dialog (TransferOwnershipDialog). It used to transfer on one click, and the clicker lost the page's controls at once. A transfer that goes through is announced at the top of the roster ("Riley now runs Sunday Dinner.") and focus moves to the roster heading, since the row's ⋯ button goes with the clicker's ownership; Cancel or a refusal hands focus back to that button. Remove asks in place and says what it costs: an account-backed member cannot be invited back, and an accountless one added again by name starts a new place without the old history. A successful removal is announced at the top of the roster, since the row moves into the collapsed former-members group, and a withdrawn email invitation is announced from a line that outlives the invitations card. A row's result message, its one-time claim code and its invite form now open under that row instead of under the whole list. The claim code and the invitation link (InviteLink) each have a Copy button that says "Copied" and announces it, or says when the clipboard refused. Declined, removed and departed members move under a collapsed "Former members (n)". The two add-person cards are one Add member card with a segmented choice: "By email | By name" with email invitations on (an emailed invitation reaches someone with no account too), "Has an account | Doesn’t yet" with them off. The always-open rename form is gone. The owner renames the guild by selecting its h1 (EditableGuildName, on InlineEditableText like the quest title), still checked against renameGuildSchema before the request goes out. A member no longer sees the "Linking a second account?" claim form on every guild page, since it lives on /guilds. New shared pieces: components/ui/copy-button.tsx, components/ui/confirm-strip.tsx (the second step of a two-step inline confirmation), and an onValueChange prop on SegmentedControl. No Server Action, query or migration changed (#773).

  • A shared Well Played recap now opens for the person it was sent to. Share recap on /profile/recap/<year> used to hand out that same signed-in URL, which sent a signed-out recipient to the login page and showed a signed-in one their own recap. It now mints a revocable link to a new public page, /recap/<shareId> (app/(standalone), compact header), the first time it is used, and hands that out through the share sheet or the clipboard. The button always says what happened: "Link copied" for three seconds where the clipboard is used, a status line for a share sheet, and the link in a read-only field with a Copy button when the browser refuses both (a share sheet or clipboard write can be refused once the click's activation has gone on minting the link) — before this, a desktop browser without navigator.share appeared to do nothing. Stop sharing beside it revokes the link, and the page then says the link does not work. The public page is read-only: the owner's name and the stats, monthly activity and badges in the visitor's voice ("Ann crushed 3 quests… Their longest streak…"), a sign-up prompt (or, for a signed-in visitor, a link to their own recap), noindex and no-referrer, and a preview image from /api/recap/<year>/og?share=<shareId>; it shows quest titles to no one but the owner. /recap/* is disallowed in robots.ts and kept out of the sitemap and Lighthouse lists like the invitation links. The owner's page also gained previous/next year chevrons (RecapYearNav, from the cached years plus the current one — other years were reachable only from /profile), "Computed {date} · Refresh" above the card in place of a Refresh button at the top and the timestamp at the bottom, with the refresh done as a router refresh that says "Refreshing…" rather than a full GET with no pending state, and three new sections: a twelve-month Active days by month bar chart (each month readable as "March: 4 active days" to assistive technology, the busiest month labelled), Badges earned in {year} with AchievementBadge, and an owner-only Quests you finished list (up to 12, linked, then "and N more"). The hero gradient moved from the -500 to the -700 stops and every line of text on it is solid white: the old pink end measured 3.5:1 even for solid white and the 70% eyebrow 2.4:1; every stop is now 6:1 or better. The AI-written narrative stays with #542 (#772).

  • /profile is shorter and the email preferences save themselves. The badge gallery shows unlocked badges as compact cards up to four across (sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4), so the whole catalogue fits in four rows at xl, and folds every locked badge into one collapsed "N more to discover" row that opens to padlocks showing only each badge's rarity — it used to be one full card per locked badge, each reading "Mystery badge / Keep questing to discover this achievement.", about 1,300px of the same sentence on a phone. The header's badges are 32px links to their own card in the gallery (AchievementBadgeStrip's new size and linkToGallery props; quest cards keep the 28px size beside their 28px avatar), where before they were unnamed circles with only a hover tooltip. The three stats stay three across at every width with a smaller figure below sm instead of stacking into three full-width cards, the Archived Themes column appears only when there is an archived theme (the current-theme card spans the row otherwise), and the current year's recap is marked "New" only until a recap exists for it rather than every day of the year. On /settings/notifications the eight categories are grouped under Reminders (with the timezone and quiet hours), Collaboration and Product, each one a switch, and every change is saved as it happens — there is no Save button; a status line says "Saving…", "Saved" or the action's error. Saves post the whole form through the same updateNotificationPreferencesAction and flag-gated parser as before, one at a time, with a change made mid-save sent as soon as the first lands; quiet-hour times save when the field loses focus. The timezone is a <select> instead of free text over a datalist. It lists Intl.supportedValuesOf('timeZone') plus UTC (which V8 leaves out of that list, but the database accepts) and the saved and detected zones (timezoneChoices() in lib/notifications/timezones.ts). A browser that cannot list zones gets the text field back, saved on blur. Under it are "Detected: {zone}", "(not saved yet)" while a detected zone has only been filled in, and a "Use {zone}" button when the saved zone differs from the browser's. The Year Theme half of this issue had already landed with #769/#770: /profile shows the theme read-only and edits happen on /themes (#771).

  • Account configuration has one home: /settings, with Profile, Appearance, Notifications, Billing and Account sections under a sub-navigation that is a rail from md and a row of tabs on phones. Profile edits the display name and the avatar after onboarding — before this, the only write to either was the skippable onboarding step, so someone who skipped it saw their email as their name for good. The avatar is an upload (held in the browser until Save, then written to the new avatars bucket and handed to updateProfileAction as an object path; a failed save removes the upload, and replacing or removing an avatar deletes the old object). Appearance makes profiles.theme_preference do something: Light, Dark or Match my system now renders, through a questify-theme cookie that a tiny <head> script applies before first paint (lib/theme/), kept in step with the profile row by ThemePreferenceSync in the signed-in shell so the choice follows the account to new devices. A visitor with no cookie stays light; a signed-in account's default system follows the OS, so dark-OS users now see the dark theme. Notifications holds the email preferences that sat ~2,000px down /profile; the unsubscribe page, every email's preferences link and the sign-in return target for unsubscribe links now point at /settings/notifications. A failed profile read on Profile or Appearance shows an error rather than a form prefilled with guesses, which Save would have written over the real name, avatar or theme. Billing keeps its /settings/billing URL. Account shows the sign-in email and adds an in-place password change (changePasswordAction, which handles Supabase's secure-password-change reauthentication by emailing a code and asking for it; accounts with no password login see a note instead), sign out, and account deletion, which now also lists the account's avatar folder page by page and removes it. /profile is now the identity and progress page — header with an Edit profile button, stats, badges, recaps — and shows the current Year Theme read-only with a link to /themes, where it is edited. The account menu's Billing entry became Settings. Changing the sign-in email is not part of this (#770).

  • The Year Themes hub puts the theme beside its quests. From lg the page is a sticky 320px rail — the theme card with its colour border, progress, a default-size Create a quest for this theme (it was the smallest control on the page) and Edit theme, then the archive — next to the tagged quests at md:grid-cols-2 xl:grid-cols-3; below lg the rail dissolves into the single column as theme, quests, archive, so past years no longer push the quests a screen down on a phone. The always-open "Edit your {year} theme" card between the quests and the archive is gone: Edit theme opens the form in a dialog on useModalFocus (focus moves in, Tab stays inside, Escape or a backdrop click closes, refused while a save is in flight, focus returns to the button), and a save closes it and shows a transient Saved chip beside that button. YearThemeForm takes onSaved / onPendingChange and no longer scrolls the window to the top on success, away from its own message. Each archived year with tagged quests now links to them — "View all N quests" goes to /quests?theme=<id> — where it showed a progress bar over quests it gave no way to reach. The /quests index gained that theme param: a Year Theme id, owned quests only (the same rule as the theme's progress count), applied before the status and ownership chips are counted, shown as a removable "{year} · {title}" chip, named in the filtered empty state ("No completed quests tagged “Ship it” (2025).") and cleared by Show all quests; an id that is not one of the viewer's themes is redirected away like any unknown param, while a theme that merely fails to load keeps the filter under a generic chip. Accent-colour swatches are 44px targets named Sage, Slate blue, Rosewood, Heather, Ochre and Teal (yearThemeColorNames) for their accessible name and tooltip instead of "Use #6b8e7c as the Year Theme accent color"; the theme form and the onboarding step share them as YearThemeColorPicker (#769).

  • The Quest Packs catalogue shows three packs across at xl and enough of each pack to compare them from the grid. The grid is md:grid-cols-2 xl:grid-cols-3 in the page's default max-w-6xl (it was two ~490px columns for cards that need about 320px), and each card lists its first three task titles as a numbered list with a "+N more" link that opens the preview, in place of a bare "N tasks" count — the task list used to render only inside the preview dialog, so comparing two packs meant open, close, open the next. The bordered two-label filter panel is now one toolbar row: a search field and a category menu with visually hidden labels, and a live result count at the right ("3 of 12 packs" while a filter is on, "12 packs" otherwise). An empty catalogue ("No Quest Packs are active yet", with a link to start a quest from scratch) is told apart from a filter miss, which names the search and offers a Clear filters button; before this both were the same sentence with no way back. A read that fails is told apart from both: the page now carries the failure to the browser as loadFailed and it shows a "Quest Packs are unavailable right now" notice (role="alert", the challenges page's shape) instead of the toolbar and the empty-catalogue copy — it used to swallow the error into an empty list, which would have claimed nothing was active and sent the user off to start from scratch. The preview dialog moved onto useModalFocus like the app's other dialogs — it takes focus, keeps Tab inside, closes on Escape or a click on the backdrop and hands focus back to the control that opened it, none of which it did — and took the #557/#597 shape (pinned header and footer, scrolling body with min-h-0 flex-1 overflow-y-auto) so Start this Quest stays in reach however long the task list is; it should become a use of the shared Dialog primitive once #648 lands rather than grow a fourth hand-rolled focus trap. The filter itself moved to lib/quests/pack-filters.ts (filterQuestPacks(), packCategories(), packCategoryLabel()) so a pack picker elsewhere can reuse it. The New Quest page's pack select is unchanged (#767).

  • Quest Jams got a compact, grouped index. A card is title, pill, a two-line description, one meta row (theme, joined, finished, dates) and a footer — down from ~340px of pill-plus-sentence, two stat tiles and a "View" label inside an already-clickable card. The footer carries the one thing the viewer can do: a signed-in visitor joins a live Jam from the index in one click (the same quest title and goal the detail panel pre-fills), a signed-out one gets "Sign in to join", and someone already in sees "You're in" with a link to their quest. The list is sorted live → upcoming and grouped under "Live now" / "Upcoming" headings when both exist, with All · Live · Upcoming · Mine chips (?state=) that count what they show; "Mine" appears only signed in. The header is the single Quest Jams h1 — the eyebrow, "Sprint together." tagline and paragraph are gone. The page refreshes itself when a listed Jam starts or ends while it is open, so a card's pill and footer and the chip counts never go stale (#766).

  • The Start a Quest Jam form shows the card it will put on the index. The card chrome and second heading are gone, the page moved from narrow to default width with the fields on the left and a sticky preview on the right that follows the title, theme, description, duration and start as they are typed, with the computed end underneath. "Starts" is a new optional datetime-local beside Duration — blank means now; a chosen start is sent as UTC, previews the Jam as "Upcoming" and relabels the button "Schedule Jam"; the field is bounded to the next 30 days both in the form and in the validator, and a start that has just passed is sent through for the database to clamp — the input's floor follows a selection that ages past now rather than blocking the submit. The description has a live counter and the submit button is w-full sm:w-auto like every other form (#766).

  • The Quest Jam page keeps the join panel in reach and the leaderboard honest. The header is above the grid and the join panel is order-first on phones (right under the title, before the leaderboard) and lg:sticky on desktop; the three stat tiles are one meta line — joined, finished, window and a live countdown ("Ends in 2d 4h", "Starts in 3h 30m", "Ended 2 days ago") that ticks every minute, wakes at the Jam's start and end themselves, and when it crosses one while the page is open refreshes the page once so the pill and the join panel catch up. The leaderboard shows the top 10 with "Show all (N)", pins "Your position" above it for anyone with an entry (rank and time if finished, "not finished" if not, with a link to their quest), highlights the viewer's row, and lists everyone who joined but has not finished as a muted secondary list under it — worded that way, not "still sprinting", because an entry whose quest was failed or archived is unfinished too — a Jam with 37 joined and no finisher used to say "No completed entries yet". Rows are flex items-center at every width with the rank riding on the avatar, instead of a three-column grid that stacked to ~110px per row on a phone. QuestJamStatusBadge is one component now rather than a copy in each page (#766).

  • The New recurring Quest form shows what it will create before you submit it. The page moved from the narrow to the default width with the form on the left and a sticky 320px summary on the right (from lg), which now lists the concrete cycles — "Cycle 1: Sat, Nov 7, 19:00 → Sat, Nov 7, 20:00", then the next week — and updates as the start or duration changes. The preview mirrors ensure_quest_recurrence_cycles() (previewRecurrenceCycles() in lib/quest-recurrences/preview.ts): a start in the past shows the cycle containing now and the one after, and a cycle whose end instant has already passed is left out, as the database does — including an end inside the repeated hour of a fall-back DST change or a skipped spring-forward hour, which are resolved the way PostgreSQL resolves them. Each cycle is shown as the minted Quest will show it — its resolved start, and that instant plus the duration as the end — so a weekly start that falls in a spring-forward gap moves past it and a window spanning a DST change ends an hour earlier or later on the clock, matching the Quest page; the "Ends" line follows the same rule. While the form sits open, the summary re-reads the clock when a shown cycle ends or the week rolls over (msUntilPreviewChanges()), so it never promises a cycle the mint would skip. A start the validator refuses — one skipped by a spring-forward gap — shows a "that start time doesn't exist" hint instead of cycles or an end time. Duration gained 30 minutes, 90 minutes, 2 hours and 3 days plus a Custom minutes field (capped at one week, like the validator; the preview only uses a whole number of minutes in that range, so an out-of-range value typed there shows no cycles instead of breaking the page), with an "Ends …" line under the row; Goal is a three-row textarea like the main Quest form. After creating, createQuestRecurrenceAction sends you to the newly minted current-cycle Quest instead of the generic /quests list, falling back to the list only when no cycle could be read. The submit button is no longer disabled while the browser timezone is detected: a blank timezone is now defaulted on the server to the one saved in notification preferences, or UTC. The template, its cadence and what is minted are unchanged (#765).

  • The New Quest page puts the AI Coach beside the form. From lg the page uses the default width with the form on the left and the coach in a sticky 400px column on the right whose chat thread fills the column's height, so the draft can be watched landing in the fields; below lg the coach still stacks above the form, and with NEXT_PUBLIC_FEATURE_COACHING_MODE off the page keeps its narrow single column. Accepting a draft no longer unmounts the coach: it collapses to a "Quest drafted by AI Coach" card whose Ask for changes reopens the same conversation (same interview, so no new charge), and a follow-up re-drafts the quest, with Continue then saying it replaces what is in the form — the coaching prompt now tells the model to answer a message after its draft with the complete updated draft rather than a new interview. The model persona is a Runs on {model} chip in the coach card's header that opens the existing picker dialog directly, replacing the separate "Model persona" section and the "change it above" line (the section remains when the coach is off). The answer field starts at three rows, grows to about eight and can be resized, and the send button is labelled "Send" from sm and a 44px target on phones. In the form, a Quest Pack's tasks and the coach's tasks now share one Tasks this quest starts with section directly under Goal, with the pack select behind a Change pack link once a pack is chosen; Public quest is a switch like the Year Theme one, and Save as is a Draft | Active segmented control when creating (editing keeps the full status select). The submit footer is pinned to the bottom of the viewport from lg on this page (QuestForm's new stickyFooter prop; the Edit Quest sheet does not set it) and now carries the result of the last submit: a failed save's message, or for a validation failure — which returns field errors and no message — a line naming the rejected fields, while focus moves to the first of them, opening its section if collapsed. Inputs with an error are aria-invalid and point at their message with aria-describedby (FieldError ids, and DueDateField's new errorId prop). (#764)

  • /tasks has a real quest list. The 260px column that held a <select> and a three-line progress card is now a sticky, independently scrolling list of every quest the viewer owns or has joined, each row showing its title, done/total and a thin progress bar (a quest's count moves as its tasks do, and keeps that after switching to another quest), a party mark on a Party Quest, and a small filter field. A draft is greyed with "Draft · start it to work on tasks"; a completed or failed quest says so. The page's heading is now the selected quest's title, linked to /quests/{id} and followed by its status badge, replacing the "Quest tasks" eyebrow, the "Task management" heading and the title repeated as a subtitle. Below lg the list is not shown and the quest <select> sits in the header instead. On a Party Quest the workspace now loads the quest's party itself, afresh on every selection, from a new party-only route GET /api/quests/[id]/party (signed-in only; a failed read is a 500, never an empty party), so an owner gets the assignee picker here as on the quest page, and a member's controls start locked — the list says the quest is theirs as a member before the party arrives — and open only on the tasks assigned to their own seat, as #576 requires; Add waits for that seat too, since a member's new task is always assigned to it. If the party cannot be read, or holds no seat of theirs any more, the page says so and the quest's tasks stay read-only. An assignee picked while adding a task is cleared on switching quests, since a seat belongs to one quest. The database (user_can_write_quest_task()) remains the boundary (#763).

  • Tasks can now be selected several at a time and changed together, and the task list answers to the keyboard. Each row on /tasks and on the quest page has a checkbox that appears on hover or focus, stays visible once anything is selected, and on a touch screen (where nothing hovers) is shown by a new "Select" toggle beside the sort control; turning the toggle off clears the selection. While tasks are selected, a bar pinned to the bottom of the viewport (components/tasks/task-bulk-bar.tsx) offers Complete, Set due, Assign, Delete and Clear. Set due and Assign take their date or seat in the bar itself, and Delete always asks "Delete N tasks? This can't be undone." there before sending anything. Each action is one request per task through the existing PATCH/DELETE /api/tasks/[id] routes, settled together with Promise.allSettled (lib/tasks/bulk.ts), so the tasks that saved stay saved, only the failures are put back, and the bar says how many failed and why while leaving them selected for a retry. The bar only sends an action for the tasks the viewer could change from the row: a party member completes and dates only their own seat's tasks, never sees Assign and cannot Delete (#576), a challenge task is never deleted (#477), and the rest are reported as skipped rather than sent to be refused. A batch and other task writes never overlap, by two rules: while a batch runs (the bar's, or "Complete all"), no other write to an existing task can start — status, the edit form, the inline title, delete, reorder and location check-in are disabled on every row and refused by their handlers — and a batch only starts on an idle list, with no row write (a location check-in included) or reorder still saving and no task open in the full edit form (the bar then says "Save or cancel the task you are editing first.", because that form's Save sends every field it holds and would undo a batch's due date or assignee). Adding a task stays open during a batch, because a new row is in no batch. Every batch, "Complete all" included, folds in only the field it changed rather than the whole row its response carries, and lets an inline rename already out on one of its tasks land first, so a failed bulk delete restores the title that saved. Each lock belongs to the quest it was started on and holds only that quest's list, so a slow or stalled write on one quest never leaves another quest on /tasks unusable. While a batch runs, the investigation poll starts no read and drops any list it was already reading, which could otherwise undo the batch or bring deleted rows back. Clear, the Select toggle and Esc wait while a batch is out, and a task that failed is selected again when the batch settles even if it was unticked meanwhile, so its report and the retry are never lost. A selected task that the search, a status filter or the collapsed completed group hides stays selected, and the bar counts it as "not shown"; actions only ever reach the tasks on screen. Every row write in flight is tracked, not just the latest, which also fixes two rows saving at once clearing each other's busy state. Switching quests on /tasks now closes an open edit form, which used to linger unseen, and a refused location check-in now reports its error instead of failing silently. Keyboard: n focuses the quick-add field, j/k move between rows, x or Space selects the focused row, Enter advances it to its next status, e opens it for editing, Esc clears the selection (or, with a row's menu open, just closes the menu: OverflowMenu now claims the Escape it handles) and ? (or the new keyboard button in the header) shows a cheat sheet (components/tasks/task-shortcuts-dialog.tsx). The shortcuts never fire while a text field, select or editable region has focus, ignore keys aimed outside the task section, leave Space and Enter to whatever button has focus, and pause while any dialog is open — the manager's own, an attachment preview or the quest page's Edit Quest sheet (lib/tasks/shortcuts.ts). "Complete all" in the header stays, under the same write lock. (#762)

  • Each task row's controls were reworked. Status is now a split control: the main button still advances the task in one click as before, and a chevron beside it offers To Do, In progress, Blocked and Done as the shared status badges, so Blocked can be set by hand for the first time and any status is one click away rather than up to three cycles; a choice the quest's status does not allow (progress on a draft quest) is disabled. The quest owner can reorder tasks by dragging a grip at the left of the row (desktop widths) or with Move up / Move down in the row's "…" menu, which is the keyboard path; the move is saved through the new PATCH /api/tasks/reorder and works on a filtered or searched list without disturbing the hidden tasks; at the edge of the list a row is shown in, Move up / Move down step past the quest's next task, so an open task can still be moved past finished ones folded into the "N completed" group (dragging stays within the shown list). A task write never moves a task on screen: a status change, edit or Complete all response keeps the row's current position, and a failed Delete or Complete all restores only what it changed, so a write that was in flight across a reorder cannot put the old order back; adding a task is idempotent, so a reload that already brought the new task in does not show it twice, and a successful Delete removes the task again in case such a reload put it back. Reordering is offered only while the list is in the quest's order, not sorted by due date, and the sort option formerly labelled "Order added" is now "Quest order". The #n badge follows that order. Investigate is now an "Ask" button with a sparkles icon, labelled from md up, instead of a magnifier that read as search, and Delete moved from beside it into the "…" menu, which a party member, who can never delete, no longer gets at all (#761).

  • The task list can now be narrowed and searched, and finished work no longer sits between the live tasks. On /tasks and the quest page, a toolbar above the list has a search over each task's title and details (every word must appear, in any order; case and accents are ignored; Escape clears it), the existing sort, and status chips — All, Open (todo), In progress, Blocked and Done — each with a count of what choosing it would show under the current search. Under All, done tasks fold into a collapsed "N completed" group below the list instead of standing as full-height struck-through cards; Done lists them directly. The chip and the search are written to the URL as ?taskStatus=&taskSearch= with replaceState, so a reload or a shared link lands on the same view and leaving the quest leaves them behind; whether the completed group is open is remembered per browser in localStorage, for every quest. A task's #N badge is now its place in the order added, so it names the same task however the list is sorted, filtered or grouped (it used to be its position on screen), and the workspace's Progress box no longer divides by the "Near me" subset. Blocked hides while it would say 0, like the Quests index's rarely used statuses; a filter that matches nothing says so with a Clear filters button (#760).

  • A task card ends in one row of count chips instead of two or three standing rows. Every card used to close with an "Investigations" toggle and a "Comments" toggle, each in its own bordered row and present at a count of zero, and with attachments on, an "Attach a file · {types}, up to {size}" row as well: on a ten-task list about 700px of empty controls and twenty rules. The foot of the card now shows a small chip, an icon and a count, for each of investigations, comments and attachments only when the task has at least one, and a task with none of them has no footer at all. If the quest's comment counts cannot be read, every task shows a comments chip without a number instead, so a failed count never reads as "no comments" or leaves an archived quest's threads unreachable (CommentCounts.failed). Each chip opens its panel under the row with one click and closes it with a second; one panel is open at a time, so opening comments closes the investigations. The investigations chip still opens itself while one of the task's investigations is pending or running, and when a question fired from the card lands, and shows a spinner while it runs. Starting something new is a + button beside the task's Edit, Investigate and Delete buttons (the same disclosure as the quest toolbar's overflow menu, now shared as components/ui/overflow-menu.tsx; in both, Escape pressed on an item now hands focus back to the menu's button instead of dropping it to the top of the page) holding "Comment", which opens the task's thread with the cursor in the composer, and "Attach a file", which opens the file picker and, once the file is recorded, the attachments panel; the accepted types and size limit are the menu item's description. Each item appears only where it would work: Comment while comments are on and the quest is not archived, Attach a file while attachments are on and the quest takes task writes, and no + at all when neither applies. Asking the model keeps its own Investigate button. An upload in progress or a refused file shows in the footer. Long task details are clamped to three lines with a "Show more" / "Show less" control that appears only when the text actually runs past the clamp, measured at the card's width (TaskDetailsText). The quest's own Discussion moved from below the last task into the quest page's details rail, under the party panel, as a card like the Details card; the task threads stay on their tasks, and archiving still closes both (describeCommentsClosed() in lib/comments/comments.ts is now the one place that says so). Uploading moved out of QuestAttachments into useAttachmentUpload() (components/attachments/attachment-upload.tsx) so a task can upload without rendering the strip; the quest-level strip above the task list is unchanged. The separate TaskComments and TaskInvestigations toggles are gone; TaskInvestigationList is the investigations panel's content (#759).

  • Adding a task takes one 40px row instead of a panel of mostly optional fields. The create form on /tasks and on the quest page used to show Title and Details side by side, the assignee picker, the "Add location" link, the due date with its standing "Your local time. Due soon means within seven days." line, and an Add button alone on a bottom row at the far right: about 260px on a desktop and 380px on a phone before the first task, on every visit. It is now a title field ("Add a task…") with Add beside it, so Enter in the title creates the task, and between them four icon toggles, one per optional field: due date, details, assignee and location. Each reveals its one field below the row with one click and moves focus into it, and stays highlighted, with "(set)" in its accessible name, while that field holds a value, so a due date or assignee that was filled in and collapsed again is not submitted unseen. On a phone, Add shows only its icon so the row still fits. The location toggle opens the picker straight onto its map (TaskLocationPicker's new defaultExpanded), and "Remove location" hides it again (onRemove). The assignee toggle appears only where the old picker or note did: a quest owner with a party gets the picker, and a party member gets "Tasks you add are assigned to you." The "due soon" rule moved behind an info button beside the due-date label, the same native <details> popover as the cycle-streaks help, and is still the input's accessible description (DueDateField's new hint="popover"; the quest form and the task editor keep the inline line). Every field closes again after a task is created. Validation, the closed-list notice and the party write gates are unchanged (#758).

  • A quest's owner edits it without the page moving, and fixes a title without opening a form. "Edit Quest" now opens the full editor in a sheet docked to the right edge (components/ui/side-sheet.tsx, max-w-lg, full width on a phone) instead of rendering QuestForm inside the header, where it pushed the links, party and tasks down by the height of an eight-section form and scrolled the owner away from what they were looking at; the sheet takes focus, keeps Tab inside it, closes on Escape or its ✕ and hands focus back to the button, and refuses both while a save is running (QuestForm reports its useFormStatus through a new onPendingChange prop for this, because a state update made inside a form action renders only once the action has finished). A click on the backdrop deliberately does not close it, so a stray click cannot discard a half-edited form. The quest's title and goal are now edited where they stand: clicking either, or Enter on it, swaps it for a field that saves on Enter or when focus leaves and puts the old text back on Escape (in the goal, Shift+Enter adds a line, and goals now display their line breaks); a refused save keeps the draft open with the reason under it, in plain words rather than the database's error text, which is logged instead. An owner whose quest has no goal sees "Add a goal" in its place. Clicking "Edit Quest" or a task's pencil straight from an inline field saves the field and opens the full form on the new value, not the old one, and the form's save waits for that inline save to settle before it sends, so neither the old title nor an older inline request can land over what the form saves (QuestInlineDraftsProvider carries a title or goal still in flight to the sheet and tracks its request; TaskManager does the same for a task title). The sticky mini-header now follows the page's h1 when the inline editor replaces and re-creates it, instead of staying bound to the old node. Task titles in the task list work the same way, sending only { title } to PATCH /api/tasks/:id; the pencil still opens the full form for details, due date, assignee and location. The shared control is components/ui/inline-editable-text.tsx, and PageHeader takes a new heading slot so the editor replaces the h1 rather than sitting inside it. The title and goal save through a new updateQuestInlineFieldAction() rather than updateQuestAction(), which reads the whole edit form and would have written a title-only payload as an unshared quest with no goal or context; it applies the same owner and archived-quest checks. Nothing is editable inline where the full form would refuse it: an archived quest, a challenge task's fixed wording, or, for a party member, a task not on their own seat. Not done here: the sheet is built on useModalFocus like the other dialogs and should become a variant of the shared Dialog primitive once #648 lands (#756).

  • The quest page's party panel is one row of avatars by default instead of 500–700px of roster administration. PartyQuestPanel used to give every member a bordered box with a role chip, a progress line and a bar, then put the owner's three forms (invite by email, bring in a guild, hand over this quest) on screen expanded on every visit, so "Hand over this quest", a rare and consequential action, carried the same weight as the task list. The default view is now the heading, a "7 members · 1 invitation pending" line, and a strip of avatars (the profile picture or initials) with a ring that fills with the share of each member's assigned tasks that are done, a crown on the owner, and a "+N" chip for the rest once there are more than six (PARTY_STRIP_LIMIT), which keeps the strip on one row in the quest page's 320px rail. Each avatar and the chip is a button: a tap, click or Enter opens one detail line under the strip with that member's name, role and progress (the chip lists the members it hides), pressing it again or Escape closes it, and the same facts are its accessible name and hover title, so a member on a phone or a keyboard, who has no other roster on the page, can still tell who is in the party. Everything else is behind a collapsed "Manage party" section, the same FormSection disclosure the New Quest form uses: a compact member list with an icon-only Remove for each member, the pending invitations with Resend and Revoke, the invite form, the guild picker and the handover. Handing the quest over is now two steps: choosing a member enables "Make owner…", which shows "Make {name} the owner?" with Confirm handover and Cancel (focus moves to Cancel), and only the confirm button submits; changing the selection withdraws the confirmation. A party member's "Leave Party Quest" is a small button in the card header, and every control in the card is size="sm", with the selects and inputs matched to its 36px height. The card keeps id="party", so the owner's "invitation accepted" emails still land on it. Not changed: the server actions, including transferQuestOwnershipAction, and who can see or do what (#754).

  • The quest page is two columns from lg (1024px) and its tasks start in the first screen. The main column holds the title, a new "3 of 8 tasks" progress bar under it, the goal, the owner's toolbar, then the tasks and the discussion; a 320px rail (360px from xl) beside it holds a new Details card, the reference links (two across instead of a scrolling strip, through QuestImageStrip's new layout="grid"), the party, context and constraints, attendance and the chronicle, and sticks under the app bar with its own scroll. The Details card (components/quests/quest-details-card.tsx) is one definition list of owner, last update, schedule, due date, theme, model, challenge and streak: the dot-separated meta line, the schedule pill, the challenge row and the cycle-streaks box (each a framed block with its own gap, all above the tasks) are gone, and the due-date badge moved out of the title row into it. Once the title scrolls out of view a mini-header pins under the top bar (QuestStickyHeader, an IntersectionObserver on the page's h1; for a signed-out visitor on a phone, whose public header is sticky from md only and scrolls away, it pins at the very top instead, through the new topBarStickyFrom prop) with the title, status, a 120px progress bar with "N/M", and for the owner the status's first transition (Start, Mark Complete, Reopen or Unarchive, through QuestLifecycleActions' new primaryOnly; an attendance quest that cannot be completed yet gets no button rather than "Mark Failed"). The header and the mini-header read the counts from QuestProgressProvider, which the embedded TaskManager updates as tasks change, because the task list never re-renders the page and server-rendered counts would go stale on the first tick; the task section's own progress card is removed, since the header states the same thing. CompleteQuestDialog and NarrativePanel's chronicle offer now render into <body> through a portal, so neither the mini-header's nor the rail's sticky stacking context can put a modal under the app bar. Below lg the page is one column with the rail after the main column, so the tasks come straight after the toolbar instead of below the party panel's forms. The #party anchor in owner emails still resolves, with a scroll margin that clears the app bar and the mini-header; the loading skeleton has the same two-column shape. Not done here: a compact party panel, which is tracked separately, and the back button, which signed-in visitors already lost to a breadcrumb in #743 (#753).

  • /quests/public can now be searched, filtered by status, sorted and paged, and every public quest is reachable: the page used to read no query params and stop at the 50 most recently updated, so the 51st was unreachable. The header keeps its eyebrow and title and drops the description that restated both; its action slot holds a search field (title or goal, case-insensitive, literal text, so "50%" and "run, walk" find exactly that) and a sort menu (Recently updated, the default; Most progress; Newest). Status chips with counts sit below, reusing the Quests index's QuestFilters in a new index="public" mode that drops the ownership chips and hides any status with no public quests, since nearly all of them are active. Pages are numbered rather than "Show more": 24 quests each, "Showing 25–48 of 60", Previous and Next, and a page past the last redirects to the last. All of it lives in the URL (?q=&status=&sort=&page=, parsed by the new lib/quests/public-index-params.ts), and an unknown or redundant value redirects to the canonical URL as on /quests. The read is the new listPublicQuestIndex(), which replaces listPublicQuests(): because the catalogue spans every user's quests, the filtering, ordering and paging happen in Postgres (one range() read per page, one head count per status chip), and task counts and owner profiles are read for the page's quests only; "Most progress" alone reads every matching quest's id and task counts to rank them, quests with no tasks last. The grid goes four across at xl. Each card clamps its goal to three lines, so one long goal no longer sets its row's height, states its task counts once ("3 of 8 tasks · 38%", or "No tasks yet") instead of three times, and shows "Updated 3 days ago" beside Copy in its footer. A search or filter with no match says what was filtered and offers "Show all public quests"; a failed read now says so instead of showing "No public quests yet." (#752).

  • /quests can now be searched, sorted and narrowed, and holds four columns of cards or a compact list. A toolbar above the chips has a title search (case-insensitive; the list follows the field after a 300 ms pause, and Enter searches at once, including before the page has hydrated) and a sort menu: Next scheduled (the default), Recently updated, Progress and Title. Beside the status chips sit three ownership chips: Owned, Party (any quest with more than one member, including ones you own, the same rule as the card's "Party Quest" line) and Public, each toggling off when chosen again. Every chip shows how many quests choosing it would list, counted under the other filters, and is at least 36px tall instead of 28px; Failed and Archived are hidden while they would show 0, unless one is the active filter. All of it lives in the URL (?q=&status=&scope=&sort=&page=, parsed by lib/quests/index-params.ts), so reload, back and a shared link show the same list; an unknown or redundant value (?status=bogus, ?page=1, a padded search) redirects to the canonical URL rather than being ignored. The list shows 24 quests at a time with "Showing N of M" and a "Show more" link that extends it in place (?page=N renders the first N×24). The read behind it is the new listQuestIndex(): one narrow select of every quest row the viewer can list (no task data) does the search, the counts and every sort but Progress, and only the quests actually shown go through the per-quest task, party, persona, theme and due-date reads, so a long history no longer pays for task counts on every quest ever made; Progress has to count every matching quest's tasks to rank them, and does, with quests that have no tasks last. The grid goes sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4, and from lg a card puts its status badge above the title with tighter padding (QuestCard's new compactFromLg), because three- and four-up cards are about 240px wide. A Cards | Rows toggle switches to one line per quest (components/quests/quest-row.tsx: status, title, progress bar with a done count rather than a percentage, scheduled date, party icon, updated time, and a "No activity" marker for a stalled quest, whose Check in and Snooze controls stay on the card layout and the dashboard); the choice is kept per browser in the questify-quest-density cookie, so the server renders it on first paint. A filtered list that comes back empty now says what was filtered ("No completed party quests match “run”.") and offers "Show all quests" instead of "Create a quest", which could not change the result (QuestList's new emptyAction); "Create a quest" remains for an account with no quests, which also no longer shows the toolbar or chips. The header lost its "Your adventures" eyebrow, the page's vertical rhythm tightened from space-y-8 to space-y-5, and "Repeat weekly" is a small outline button like the dashboard's actions (#751).

  • The dashboard now lists what is due. A "Due this week" section at the top of the main column shows the open tasks due within the next seven days or already overdue, overdue first, each with its quest (linked), the DueDateBadge, and the task row's own status button (Todo → In progress → Done, Blocked → Done) so a task can be advanced or completed without opening its quest. The first eight show; "Show N more" opens the rest in place (up to 50, the function's limit, with a "Showing the 50 soonest" note when it comes back full), because /tasks loads only quests the user owns and could not show a party member the tasks on their own seat. The list reads only tasks on active quests the user can actually write, the same line user_can_write_quest_task() draws: every task on a quest they own, and on someone else's Party Quest only the tasks assigned to their own seat, so the dashboard never shows a button that can only fail. The read is listDueTasks() in lib/dashboard/due-tasks.ts, one call to the new get_my_due_tasks(), and it joined loadDashboardBody(); a failed read shows an error line rather than an empty week, and a quiet week renders nothing. The status button goes through the existing PATCH /api/tasks/:id, so its quest-status and permission gates are unchanged, shows any achievement unlock in the usual toast (kept on screen even when that completion empties the list), and refreshes the page afterwards so a finished task leaves the list and the statistics catch up; until that refresh lands, a finished row stays disabled, because one more click would cycle it from Done back to Todo. The Completion tile now links to /tasks, with the quest card's hover shadow, whenever the user has an active quest: it counts tasks on their own active quests, and /tasks given no ?questId= now opens the most recently updated active quest (pickInitialQuestId() in lib/tasks/types.ts) instead of whichever non-archived quest was touched last, which could be a draft or a finished one. The other three stay unlinked because no page lists exactly what they count: no page lists a streak; Tasks done is a lifetime count that includes archived quests, which /tasks leaves out; and Active quests counts owned quests while /quests?status=active also lists the Party Quests the user belongs to, so a link could open a list that disagrees with the number on the tile. QuestCard no longer repeats its task ratio as a percentage beside the bar: it shows "N open · next due {date}" (or "All done"), from a new next_due_at on QuestSummary that listQuests() reads through the new get_quest_next_due_dates(); a failed read leaves the date off rather than failing the list. Both reads go through functions because tasks.due_at has been withdrawn from direct client reads since #570, and selecting it would have failed every quest list. Not done here: the card's "Open tasks" and "Start" quick actions, which need the title-only link from #750 so the buttons are not nested in an anchor (#749).

  • The dashboard is two columns from xl (1280px) up, and its greeting is a bar instead of a panel. The greeting spans the top; below it, the "Recently updated" preview takes the main column as one row of four cards (from xl a card puts its status badge above the title and uses tighter padding, through a new QuestCard compactFromXl prop, because at 1280px each card is only about 154px wide), and the statistics (back to 2×2), Up next, cycle streaks and the theme/challenge slot move into a 20rem rail on the right, so a returning user sees their recent quests without scrolling. Below xl the page is the same stack in the same order as before: the rail is display: contents there, so nothing is reordered and tab order matches what is on screen. The grid classes live in components/dashboard/layout.ts, shared with the loading skeleton so the placeholder has the same shape. DashboardBody is split into DashboardRail and DashboardQuests, two Suspense boundaries reading the one promise the page starts, so they resolve together. The greeting bar (WelcomeHero) dropped its "Dashboard" eyebrow and its padding, puts the subtext beside the heading from sm, and is about 64px tall instead of 170; it still owns the page's h1. "Up next" is a small uppercase label beside a calendar icon instead of an eyebrow plus a large title, still an h2 naming its region; the "Recently updated" header lost its "Quests" eyebrow. The cycle streaks' two-line explanation moved behind an info button beside the heading, a native <details> popover. QuestList takes columnsClassName (default two-up, as before), showStartSteps and compactCardsFromXl; its empty state is p-6 sm:p-8 instead of p-12 everywhere it appears, and on the dashboard it adds a "1. Create a quest → 2. Add its tasks → 3. Start it" hint. Not done here: the greeting bar's "N tasks due this week" summary, which needs the due-date read #749 adds (#748).

  • Every page outside the signed-in app shell now has a site header. components/layout/public-header.tsx is a Server Component that reads the session and draws two variants. The full one has the wordmark linking to /, the public catalogue (Public quests, Quest Packs, Challenges, Quest Jams, and Pricing only where NEXT_PUBLIC_FEATURE_BILLING is on), and Sign in plus Create a free account; a signed-in visitor gets Back to dashboard and an avatar link to /profile instead. It is sticky with a backdrop blur from md up; on a phone it shows the wordmark and Sign in only, and the catalogue links appear from lg, where they fit. The compact one has the wordmark and one action: Sign out for a signed-in visitor, otherwise Sign in (Create a free account above the sign-in form itself). A signed-out visitor's Sign in and signup links carry ?next= back to the page they are on, so signing in from the header of an invitation link or a shared quest returns there; on an auth screen they forward that screen's own next, on the landing page they carry none, and on /notifications/unsubscribe they return to /profile, because that page only works with its ?token=, which is never copied into the login URL. The landing page, /pricing, /privacy and /terms moved into a (public) route group whose layout renders the full header; onboarding, both invitation links and /notifications/unsubscribe moved into a (standalone) group with the compact one; app/auth/layout.tsx renders the compact one in place of its small-caps "Questify" link; and the (app) layout's signed-out branch renders the full one, replacing the Sign in / Create account / Home buttons that /quests/public, /quest-jams and /challenges each drew in a different size. URLs are unchanged. Before this, a signed-out visitor had no way to sign in from the landing page, the word "Questify" first appeared in section two, pricing and the legal pages had only browser-back and a footer as exits, and someone signed into the wrong account during onboarding had no exit but "Skip setup". The legal pages' bottom "← Back to Questify" link is gone in favour of the wordmark. Signup, sign-in and browse buttons now share one vocabulary from PUBLIC_CTA in lib/navigation/public.ts: "Create a free account", "Sign in" and "Browse public quests" replace "Start your quest", "Create your free account", "Start your free quest", "Create account", "Create an account", "Browse quests" and "Explore public quests". Not changed: the Pro card's "Sign up to subscribe", the sign-in card's inline "Create one", and the footer links, which #504 tracks. Rendering is unchanged: every page was already dynamic because the root layout's feedback button reads the session (#746).

  • Navigating between signed-in pages no longer leaves a blank content column, and a failed page read no longer drops anyone on Next's unbranded error screen. app/(app)/loading.tsx and app/admin/loading.tsx show a skeleton of the shared page header and a card grid inside the shell, so only the content column pulses; the dashboard, quest detail and task workspace get their own loading.tsx that mirrors their real layout. app/(app)/error.tsx and app/admin/error.tsx keep the shell and navigation, show the error digest as a reference (never the raw message) and offer "Try again", which refreshes the route before calling reset() so a failed server read is actually retried. app/not-found.tsx replaces the bare "404 This page could not be found" — /pricing with billing off, a quest that does not exist — with a branded page that links home, under the shared footer. The dashboard now paints its welcome hero and statistics first and streams the theme row and quest preview in behind <Suspense>: the reads moved into loadDashboardBody() (lib/dashboard/body.ts), which the page starts before awaiting the statistics so nothing runs later than it did. Not done here: the quest detail page still awaits its party and attendance reads up front, because the header's owner controls and the task list both depend on them. Sign-in redirects stay real 307s: /profile, /settings and /quests/recurring joined PROTECTED_PATH_PREFIXES (they redirected only from the page, which under a loading boundary can only stream a client-side redirect inside a 200, and a signed-in visitor with onboarding pending is now sent to the wizard from them like from every other signed-in page), and the middleware probes a logged-out /quests/<uuid> request with a one-column RLS read, sending a quest the visitor cannot see to login before the page streams; a failed probe falls through to the page. What remains streamed: a page's own notFound() after its skeleton — a quest or Quest Jam that does not exist for a signed-in viewer, a flag-off page — answers 200 with a noindex tag rather than a 404 (#744).

  • Every page in the (app) route group now uses one width scale and one header. components/layout/page-container.tsx renders the page's <main> at width="default" (max-w-6xl), "wide" (max-w-7xl) or "narrow" (max-w-2xl, used only by New Quest, New recurring Quest, Start a Quest Jam and the yearly recap) with the same px-4 py-8 padding. Before this, pages picked their own cap anywhere from max-w-2xl to max-w-6xl, and the quest detail page left about a third of the content column empty. components/layout/page-header.tsx replaces five hand-rolled header variants with one serif h1 plus optional eyebrow, description, right-aligned actions and breadcrumb slots. The task workspace on /tasks uses it too. Signed-in visitors to quest detail, Quest Jam detail, a guild page and the yearly recap now get a breadcrumb in place of the back button, because the side bar already links every section that button pointed at. Signed-out visitors to a public quest or a Quest Jam have no app shell, so they keep the back button. /tasks and /quests/public no longer set min-h-screen inside a shell that already fills the viewport, so short pages stop scrolling past a dead strip. The dashboard keeps its greeting hero as the page title for now; the dashboard rework replaces it (#743).

  • The Challenges page's History is a compact, filterable, pageable list. Each run is one ~52px row — challenge title over "Weekly · Week 40, 2026" (or "Daily · Sep 30"), status over its date, and a ghost "Open quest" — instead of a ~170px card that repeated the prompt and the Challenge Runner pill on every attempt; 24 runs went from ~4,000px to ~1,250px. The subtitle no longer leaks the machine period_key ("Weekly challenge - 2026-W40 - …"): formatChallengePeriodKey() beside getCurrentChallengePeriod() says it as a week number or a UTC date, and returns nothing rather than the raw key for one in neither shape. History was silently capped at the newest 24 runs; it now has All · In progress · Completed tabs that count what they hold (?history=), "Showing 25–48 of 60" with Newer / Show older (?page=), an empty-tab message with a way back to every run, and redirects a non-canonical or past-the-end URL — for signed-in visitors only, since the page is public. listChallengeHistory() takes { filter, offset, limit } and pages by range with an id tie-break, and countChallengeHistory() supplies the tab counts. On "Open now", a challenge the viewer has started or finished folds its constraints and task list behind a "3 tasks" disclosure and shows In progress / Completed in place of the Challenge Runner pill, so the card is mostly its way back to the quest; Start challenge now says what it will create ("Creates a quest with these 3 tasks."), and a refused start renders under the button in the card footer instead of above it (#768).

Fixes

  • The unsubscribe page's error state now lets the action that failed be tried again (#776). A failed "Turn on" or "Turn off" of another category, or a failed "Stop all Questify emails", sent the person back to a page that offered only a "Stop {the email's own category}" button and no list, because the redirect carries only the email's own category — so the one thing on offer was not what they had asked for, and a failed "turn back on" of the email's own category got a "Stop" button too. The page now reads the current preferences on status=error the way it does after a change, lists every category (the email's own included, with no headline button to duplicate its row) with the button its current state calls for, and keeps "Stop all" while anything is on. The headline button remains only when that read fails, so a page with nothing to retry from never appears. The route is unchanged.

  • The sign-in and sign-up pages were tidied up (#780). A failed sign-in that comes back through /auth/callback now says what happened: the callback's three ?error= codes (oauth_denied, missing_code, callback_failed) each show their own message in a destructive alert on /auth/login, where before the page never read error and cancelling Google consent landed on a blank form. The codes and their copy live together in lib/auth/callback-errors.ts, and the route now takes its codes from the same table. The auth tabs read "Sign in | Questify" rather than "Sign in · Questify | Questify": the five auth pages dropped their own "· Questify" suffix and leave the brand to the root title template. Every password field (sign-in, sign-up, both reset fields) is now a shared PasswordInput with a show/hide toggle, a toggle button whose name stays "Show password" while aria-pressed carries the state. Each form focuses its first field on load. Google and Facebook buttons now sit above the email form on sign-in as well as sign-up, under an "or continue with email" divider, so the two pages match; OAuthButtons lost its position prop. The auth column is top-aligned and only as tall as the card, so the footer follows it instead of sitting a full screen below with empty page above it. The legacy /login is a permanent (308) redirect that keeps a safe next, and it is now in AUTH_ONLY_PATHS (as LEGACY_LOGIN_ROUTE), so a signed-in visitor goes to the dashboard in one hop instead of two. Fields only reference their error element in aria-describedby while the error is on screen, and are marked aria-invalid then (fieldA11y in components/auth/field-error.tsx), which clears the dangling IDREFs axe reported; the sign-up password hint stays described throughout. The in-app pages that still carry "· Questify" titles are left for a separate change.

  • The light-only status chips — achievement rarity badges, Quest Jam status pills, the AI credit warnings and the completed-challenge card — gained dark variants, since dark mode can now be chosen and was never rendered before (#770).

  • A visitor reading someone else's public quest now sees each task's status the way signed-in views show it. The read-only task list (QuestTaskList) labelled status with a hand-rolled coloured dot and a plain grey word; it now uses the shared TaskStatusBadge, clamps a task's details to two lines (keeping their line breaks) so one long description no longer pushes the rest of the list off the screen, and wraps long unbroken titles instead of overflowing on a phone. Its heading no longer repeats "N/M done", because the quest header's progress bar already states it to every viewer, which is also why TaskManager dropped its own count in #753. Deliberately not added: task due dates, which have belonged to collaborators even on a public quest since #570 (20260914120000_due_date_privacy.sql), so get_quest_with_tasks() never returns them to a visitor. The issue's other half, the header's badge column squeezing a long title on a phone, needed no change: since #743 and #753 the title sits in PageHeader, which puts the status badge on its own row under the title below sm, and the due-date badge moved out of the header into the Details card, so the date-only DueDateBadge variant the issue proposed has nowhere to go (#757).

  • A draft, completed, failed or archived quest's page now says once why its tasks are locked, instead of twice (three times when archived). The header's lock notice sits beside the Start, Reopen or Unarchive control that lifts it, and the embedded task list repeated the identical sentence about 400px lower; it is now told not to, through TaskManager's new showLockAlert prop (default true), which drops only the notice — the progress and edit controls stay exactly as locked. For an archived quest the header now reads "This quest is archived. Unarchive it to make changes." (describeQuestWriteBlock(), the sentence the server action already refuses with), since the freeze covers the quest's own fields as well as its tasks, and the owner toolbar's separate "Archived quests cannot be edited. Unarchive this quest to change it." line is gone. /tasks is unchanged: it has no quest header, so its task list keeps stating the lock (#755).

  • Quest lists no longer under-count tasks or party members for someone with a lot of them. listQuests() read every listed quest's tasks, and their party seats, in one request, and PostgREST stops at max_rows (1000) without saying so, so past the thousandth task the cards' counts and progress bars silently came up short. The viewer's own seats were read the same way, so past a thousand of them joined quests dropped out of the list and owned ones lost their role and assigned-task counts. All three reads now page until a short page comes back, and the two per-quest ones send quest ids 100 to an in.(…) filter, which also keeps a long list from producing a request URL too long to send. For the same reason the quest reads no longer list the quests the viewer owns in their id.in.(…) party filter (they already match owner_id), /quests reads joined quests by id 100 at a time instead of in one filter, and next due dates are asked for 100 quests per call. This applies to every caller: /quests, the dashboard preview, the profile page, the theme hub and GET /api/quests (#751).

  • Quest cards no longer put buttons inside a link. QuestCard wrapped the whole card in an <a>, so the stall banner's Check in, Snooze, suggestion and dismiss controls (and its snooze menu) were interactive content inside a link: invalid HTML, announced by screen readers as one long link, with a click-trap div that had no keyboard equivalent and a check-in dialog whose backdrop click fell through to the link. The title is now the only link, and a stretched ::after overlay (lib/a11y/stretched-link.ts) keeps the whole card clickable. The banner is a sibling raised above that overlay (relative z-10), so Tab reaches the title and then each banner control separately. Its buttons are now 32px tall (h-8, up from 24px), the dismiss target is 32×32 (from about 18px) and snooze menu items are at least 32px. The Quest Jam card on /quest-jams (now components/quest-jams/quest-jam-card.tsx) has the same shape, which leaves room for an inline action later; no Join button is added here. On /quests/public, PublicQuestCard drops the second "View details" link to the page its title already opens. The title is stretched over the card instead, and "Copy to my quests" is outlined (CopyPublicQuestButton's new variant prop), so a grid of a dozen cards no longer shows a dozen solid primary buttons. The quest page keeps the solid button. (#750)

  • The feedback button no longer covers primary buttons on phones or appears on pages with nothing to give feedback on. It was mounted in the root layout, so every signed-in route drew a fixed bottom-4 right-4 button over the bottom-right of the page, including every onboarding step's full-width Continue, the full-width Join on both invitation pages and the Delete/Cancel row on the profile page, and it showed during onboarding before the user had seen the product. FeedbackButtonGate is gone; a new client FeedbackProvider (components/feedback/feedback-provider.tsx) owns the one dialog and its confirmation toast and is mounted only by the (app) shell (AppShell now takes userId) and the /admin layout, so /onboarding, /guild-invites/*, /party-invites/*, /notifications/unsubscribe and the (public) pages render no feedback control. The floating button is now md-and-up only; below md "Send feedback" is a row in the top bar's account popover (at every width) and in the mobile drawer, and in the admin drawer, all opening the same dialog through useOpenFeedback(). The dialog now takes focus on its Title field when it opens, and on close focus returns to a control that is still on screen (the account-menu button or the drawer toggle, since the menu row that opened it is gone by then, or the floating button) instead of falling to the page behind the overlay. Inside the dialog, the read-only Page URL field became a one-line muted "Sent from …" caption (still submitted with the report), and the Type radios became 44px chip radios in the onboarding profile step's style. Not changed: a signed-in visitor on /pricing or the legal pages, which sit outside both shells, no longer gets a feedback control there (#747).

  • Dates and times now render in the viewer's own locale and timezone everywhere an instant is shown, and the due badge no longer paints a raw ISO string before hydration. Before this, DueDateBadge first rendered Due · 2026-10-03T18:00:00.000Z and then reflowed; Quest Jam windows, "Updated" on quest cards and the quest header, the yearly recap's "Stats last computed", billing renewal dates and the admin dashboard, audit log and feedback pages were formatted on the server, so an EDT viewer saw UTC; and guild and party invitation expiry was pinned to en-US in UTC. The new client component components/ui/local-datetime.tsx provides <LocalDateTime value dateStyle timeStyle format relative /> and <LocalDateRange start end />: both server-render a deterministic, hand-built UTC string (2026-10-03 18:00 UTC, so it is identical in Node and every browser and never mismatches on hydration), swap to the viewer's Intl rendering after mount, and keep the exact instant on a <time dateTime> element throughout; relative shows "3 hr. ago" with the absolute time as its title and refreshes every minute. Every call site listed in the issue moved onto it, as did the comment thread, task investigations, the admin feedback list and GitHub-issue card (whose prop is now the associatedAt timestamp rather than a pre-formatted label), the narrative panel, the admin user list and the Quest Jam join and admin panels. formatQuestJamDateRange() is gone in favour of QUEST_JAM_WINDOW_FORMAT in lib/quest-jams/queries.ts. Deliberately unchanged: QuestScheduleWindow still renders in the quest's own pinned timezone; labels for UTC calendar periods (the daily and weekly challenge dates, the admin AI-spend month names) stay in UTC because they name a UTC day or month rather than an instant; invitation emails keep their fixed format because there is no viewer zone to read; and numbers formatted with toLocaleString() are out of scope (#745).

Security

  • safeInternalPath() now refuses a path containing ASCII control characters (#776). The WHATWG URL parser strips tab and newline before it resolves, so /\t/attacker.example passed the old // check and then resolved to https://attacker.example/ in new URL() and in the browser — an open redirect through /auth/callback?next=… and the login and signup next parameters. Spaces are still allowed, because a decoded search query can carry one.

  • The recap Open Graph image no longer renders any user's stats by user id. /api/recap/<year>/og?uid=<id> read the recap and display name through the service-role client for anyone who had (or guessed from another page) an id, and the owner's recap page put that id in its metadata. The image is now served for a share link through get_shared_well_played_recap(), so it exists only while the owner shares that year and stops when they revoke it, or for the signed-in owner from their own session; the route uses no service-role client, and a ?uid= request is treated as an unauthenticated one (401). A preview already cached by a chat app from an old ?uid= link keeps its image until that app refreshes it. A recap's share link can only be written by the sharing function, so a revoked link can never be re-pointed at someone else's recap (#772).

Technical changes· 47 entries for database, tooling, CI, tests and docs

Database

  • 20261101120000_token_page_context.sql gives the emailed-token pages what they need from one page (#776). get_guild_invitation_preview() now also returns owner_name and member_count (accepted roster places, accountless ones included), and get_party_invitation_preview() returns owner_name, member_count (every seat, the owner's included) and tasks_done / tasks_total — counts only, so task titles stay private until the person joins; both previews fill the new columns for a pending token only, exactly like the old ones, and are dropped and recreated because their result shape changed. Two new definer functions, callable signed out and authorised by the unsubscribe token alone like set_notification_category_by_token(): get_notification_preferences_by_token() lists the eight email categories with their state, and set_all_notification_categories_by_token() turns every one off (or on) in a single statement so a failure cannot leave the switches half-moved. Both are in supabase/advisor-allowlist.txt and the anon set pinned by supabase/tests/security_advisors.sql; behaviour is covered by the new supabase/tests/token_page_context.sql.

  • 20261031130000_recap_share_id_guard.sql makes set_well_played_recap_sharing() the only way to write well_played_recaps.share_id. The owner policy on well_played_recaps is FOR ALL, so a signed-in user could PATCH any column of their own row through PostgREST, share_id included: anyone who knew another person's revoked share token could write it onto their own row, and the link the victim had already handed out would resolve again — showing the attacker's name and stats under it. A new BEFORE INSERT OR UPDATE trigger, well_played_recaps_guard_share_id(), now refuses (42501) any anon or authenticated write that sets or changes share_id unless set_well_played_recap_sharing() has marked it with the transaction-local questify.recap_share setting — the shape questify.task_reorder already uses — and that function only ever mints fresh tokens. Recomputing a recap never touches the column, other columns stay writable as before, and trusted roles are not checked. The trigger function is not callable as an RPC. Covered by four new assertions in supabase/tests/well_played_recap_sharing.sql, including the reclaim attempt, and one in rls.sql; without the trigger the reclaimed link resolves again (#772).

  • 20261031120000_well_played_recap_sharing.sql makes a Well Played recap shareable with someone who is not signed in. well_played_recaps gains share_id (a nullable unique uuid: null means not shared) and active_days_by_month (twelve counts, January first, of distinct UTC days with a completed task — the same population and bucketing as active_days, so they sum to it). compute_well_played_recap() fills the new column and leaves share_id alone on recompute, so refreshing a recap never breaks a link that has been sent; rows computed earlier hold zeros until their next recompute. set_well_played_recap_sharing(p_year, p_enabled) mints the caller's own link (calling it again returns the link already in force) or clears it; it is SECURITY INVOKER, signed-in only and keyed on auth.uid(), so the existing owner policy is the boundary and it raises no advisor finding. get_shared_well_played_recap(p_share_id) is the only read path for a shared recap: SECURITY DEFINER, callable by anon, returning that one recap's cached stats, highlights, monthly counts, the owner's display name (or "A Questify player" — never the email) and the badges unlocked that year. It never returns the user id, the email or quest titles, never recomputes, and well_played_recaps itself stays owner-only — sharing does not open the table to anyone. Listed in supabase/advisor-allowlist.txt and pinned in security_advisors.sql; behaviour in the new supabase/tests/well_played_recap_sharing.sql, exposure in rls.sql; docs/schema.md gains a well_played_recaps section (#772).

  • 20261030130000_avatar_reaper.sql adds find_orphaned_avatar_objects(_older_than, _limit) (service role only), and the worker's attachment reaper now sweeps the avatars bucket with it. An avatar object is an orphan when it is past a 24-hour grace and is not the current avatar of the profile whose {user_id}/ folder it sits in — a deleted account's avatar whose removal failed, a replaced avatar, an upload whose Save never landed. The app's own removals are all best effort, and without this a miss left the file public by URL for good. The match is the exact public-URL path suffix rather than LIKE, so a user-chosen object name cannot wildcard-match the live avatar and keep junk alive. The function is in both apps/web/src/types/supabase.ts and the worker's types. Covered by supabase/tests/avatar_reaper.sql and a grants assertion in rls.sql (#770).

  • 20261030120000_profile_avatars.sql adds a public avatars storage bucket (2MB, JPEG/PNG/WebP/GIF — no SVG, which can carry script from a public URL) for the avatar upload on /settings/profile. Signed-in users may insert, read and delete only under their own {user_id}/ folder; there is no update policy, because every upload is a fresh {uuid} object, and no anon policy, so an avatar is fetchable by its exact public URL but no one can list another person's folder. Public because avatars already render to other users and to signed-out visitors on public quests, Jam leaderboards and rosters. profiles is unchanged: avatar_url holds the bucket's public URL, which the server builds from the object path rather than taking a URL from the client. Covered by nine new assertions in supabase/tests/rls.sql (#770).

  • 20261029120000_quest_jam_scheduled_start_participants.sql lets create_public_quest_jam() take an optional _start_at, so an organizer can schedule a public Jam instead of starting it on submit. Null or a start already in the past means now, as before; a start more than 30 days ahead is refused (22023); the end is the start plus the chosen duration; a scheduled Jam counts toward its creator's three open Jams from creation. The four-argument signature is dropped rather than overloaded, because two overloads differing only by a defaulted trailing parameter are ambiguous to PostgREST. It also adds get_quest_jam_participants(_jam_id): who has joined a Jam and not finished — display name, avatar, when they joined — in joining order, because the leaderboard function ranks only completed entries, so a Jam with thirty participants and no finisher read as empty. Deliberately nothing about the unfinished quest (no id, title or task counts): joining a public Jam is not consent to publish a work in progress, and the entries/quests policies already keep it to its owner. Same visibility rule as the leaderboard, callable by anon like the other get_quest_jam_* readers, listed in supabase/advisor-allowlist.txt and pinned in security_advisors.sql. Proven in supabase/tests/quest_jam_scheduled_start.sql, with the grants in rls.sql (#766).

  • 20261028120000_tasks_quest_list_party.sql makes get_my_active_quests(), which feeds the /tasks quest list, list the quests the caller holds a party seat on as well as the ones they own — before this a Party Quest the caller had joined never appeared on /tasks. A seat counts under the same rule as user_can_access_quest() (user_is_quest_party_member(): a guild-backed seat only while its membership is accepted). It adds two columns, viewer_role (owner / member) and party_member_count (every seat, the owner's included), so the function is dropped and recreated; it stays SECURITY INVOKER and signed-in only, and still lists drafts, completed and failed quests while leaving archived and deleted ones out. A member's task counts are the whole quest's, as on the quest page. Proven in supabase/tests/tasks_quest_list.sql, with its grant in rls.sql (#763).

  • 20261027120000_owner_task_reorder.sql lets the quest owner reorder a quest's tasks again, reversing the part of #576 that removed reordering. It adds reorder_quest_tasks(_quest_id, _task_ids), SECURITY DEFINER and granted to authenticated only, which refuses (42501) anyone but the owner as user_can_manage_quest_party() defines it, checked again once its locks are held so an ownership transfer during the call counts (20261027160000_task_reorder_recheck_owner.sql), takes the quest's task rows and the quest row with NOWAIT, all or nothing, retrying with a short backoff (20261027140000_task_reorder_lock_order.sql, 20261027150000_task_reorder_nowait_locks.sql), so it never waits while holding a lock and cannot deadlock with a task edit or a multi-row task update such as removing a party member, gives up after two seconds with 55P03 (answered as a retryable 409), and holds the quest lock next_task_sort_order() takes so a reorder and a new task serialise, and refuses (22023) any list that is not exactly the quest's task set, so a stale tab cannot leave a task someone just added at a position nobody chose. It writes positions from 0 and touches only the rows that move, and 20261027130000_task_reorder_keeps_updated_at.sql makes tasks_set_updated_at skip those writes, so a reorder never changes a task's updated_at — which the weekly digest, dashboard stats and Well Played recap read as a done task's completion time — and does not count as quest activity. tasks_guard_placement() now admits a sort_order change made under that function's transaction-local questify.task_reorder setting; a direct update tasks set sort_order is still refused for every signed-in caller, owner included, and party members still cannot reorder. The quest-status guard still applies, so a completed, failed or archived quest's order is fixed, while a challenge's locked tasks can be reordered. Covered by the new supabase/tests/task_reorder.sql and supabase/tests/task_reorder_concurrency.sql (#761).

  • 20261026120000_dashboard_deadline_reads.sql adds two signed-in-only SECURITY DEFINER reads for deadlines across many quests at once, because tasks.due_at is withdrawn from authenticated (20260914120000_due_date_privacy.sql) and the existing get_tasks_with_deadlines() serves one quest per call. get_my_due_tasks(_due_before, _limit) returns the open, dated tasks due by _due_before (overdue ones included) on the caller's active, undeleted quests that user_can_write_quest_task() lets them write — every task on a quest they own, and on another owner's Party Quest only the tasks on their own seat — soonest first, with the limit clamped to 0–50. get_quest_next_due_dates(_quest_ids) returns the earliest open-task deadline for each listed quest that user_can_access_quest() admits; an inaccessible quest is absent, the same answer as a quest with no deadline, so it cannot probe ids. Neither exposes a deadline to anyone get_tasks_with_deadlines() would not; anon cannot call either, and both are listed in supabase/advisor-allowlist.txt as signed-in API (#749).

Tests

  • auth-forms.test.tsx pins the leaner signup (email and password inputs only, terms before the button, a short password cancelled before the action runs and cleared once fixed, the met-length hint, the read-only invited address and its unlock), the login form's ?email= hand-off, the forgot-password panel (focus moved to it, the 30-second cooldown under fake timers, "Use a different email", errors keeping the field), the reset form's expired-link link, and the confirm resend's 60-second arrival cooldown, hidden email/next and its no-address fallback. New page tests cover the reset page's expired, callback-rejected (signed in) and live states, the onboarding page's password-updated status, the confirm page's copy, links and sanitised email/next, and the forgot page's prefill; actions.test.ts covers resendConfirmationAction (redirect target, off-site next, the uniform answer, rate limiting, validation), signup's dropped metadata and next on the confirm URL, and the reset's expired_link reason and password_updated landing on the dashboard or, with onboarding unfinished, the wizard; callback/route.test.ts sends the three failure branches of a recovery link to the reset page; lib/auth/prefill.test.ts covers the shared address guard; the dashboard test covers the profile-name greeting and the password-updated status. The e2e smoke suite drops the Name field from signUpViaUI and checks the short-password stop, the expired reset page and the confirm page's resend (#781)

  • password-input.test.tsx pins the toggle (hidden by default, type="button", aria-controls, aria-pressed flipping with a constant name). auth-forms.test.tsx checks all four auth forms: OAuth above the email field on sign-in and sign-up, first-field focus, one toggle per password field, no aria-describedby pointing at an absent element, and the error/invalid wiring once there is an error. field-error.test.tsx covers fieldA11y. The login page test renders each callback code's message and ignores unknown, inherited (toString) and repeated values; app/auth/titles.test.ts pins each auth title against the root template; (auth)/login/page.test.tsx covers the 308 target and the next hand-off; the redirect and middleware tests pin /login as signed-out-only. e2e/auth.spec.ts asserts the 308, the cancelled-OAuth message and tab title, and the focus and reveal behaviour, and the password locators across the e2e suite are now exact (/^password$/i) so they do not also match the "Show password" button (#780).

  • components/legal/legal-page.test.tsx pins the shared legal layout: the contents list is built from the sections and links each to its bare anchor, every <section> carries that id (so /terms#plans-and-billing resolves), the body is capped at max-w-[34rem] with the contents sticky only from lg, the legal nav marks the current page and drops Pricing when billing is off, and "Last updated" comes from the newest change. The Privacy and Terms page tests now also check the cross-link, the current-page mark, and that the contents list has one link per section heading (#779).

  • /pricing gained a page test (app/(public)/pricing/page.test.tsx: one subscribe button per selected interval, the current-plan states for free and Pro accounts, the table/list split, open anchored billing answers) and unit tests for PricingProCard, PlanComparison, annualSavingsPercent() and the table-versus-list allowance pairing. e2e/pricing.spec.ts now reads the allowance from the comparison table on desktop and the card lists at 375px, and checks the three anchors. It also scopes its legal-entity check to <main>: the site footer names 12529572 Canada Inc. too, so the old locator matched two elements and failed whenever the spec ran with billing on. CI runs it with billing off, which is why that went unnoticed (#778).

  • page.test.tsx for the landing page now pins that no concept from the product-path cards reappears in "How it works", that every heading is distinct, the checklist's contents with every optional flag off and on, the tablet grid classes, the sample recap in the third card, the billing-gated hero line, and the ids the sticky bar watches. landing-sticky-cta.test.tsx drives a fake IntersectionObserver through hero-passed, closing-section-reached and scrolled-back states, and the missing-hero and no-observer fallbacks. recap-preview.test.tsx pins the sample label and that the thumbnail is hidden from assistive tech. e2e/home.spec.ts now checks the checklist instead of the removed "Feature highlights" headings, and a new phone test at 375×667 asserts the demo is in the first viewport, the page does not scroll sideways, and the sticky bar is hidden at the top, shown past the hero and hidden again at the closing CTA (#777).

  • #776 pins the shared token card (token-card.test.tsx), the logout route's next handling including the control-character redirect (app/auth/logout/route.test.ts, the first tests for that route), the unsubscribe route's stop-all and other-category paths and that a one-click POST can never stop everything, the unsubscribe page's category list (feature-flag filtering, a hidden category still counting toward stop-all, a failed read leaving the list out), lib/notifications/sections.ts and token-preferences.ts, the invitation pages' facts line and dead-link actions, and the previews' handling of a database that predates the new columns. The e2e specs check a broken invitation or unsubscribe link offers somewhere to go, and a live run stops every email from one unsubscribe page and reads the eight columns back.

  • Onboarding (#775): onboarding-step-actions.test.tsx pins one footer row per step with Continue as the only button submitting the step and Back/skip pointing at forms that exist on the page; profile-step.test.tsx covers the avatar upload on Continue, the cleanup when the step fails to save, and that no URL field remains; finish-setup-later.test.tsx, public-header-layouts.test.tsx and public-header.test.tsx pin "Finish setup later" to the onboarding header only, before Sign out and for a signed-in visitor; the onboarding action tests cover an uploaded path becoming the stored URL, a path in another user's folder being refused, removal of the replaced upload and avatarSaved on a failure after the profile write (with profile-step.test.tsx keeping the upload then); the wizard test re-applies the theme when the saved preference changes; and the wizard and quest-pack tests pin the h1 hand-off and scratch-first order. (#775)

  • /settings/billing has its own page suite (settings/billing/page.test.tsx): flag off, signed out, free checkout without /pricing, no portal copy without billing history, no Stripe price reads for Pro, a failed price read, and the post-checkout banner. checkout-return-status.test.tsx pins the poll under fake timers — it refreshes while waiting, stops the moment the tier prop turns Pro, gives up after the window and re-checks on demand — and ai-usage-card.test.tsx covers both tiers, the low and exhausted states and a failed read. isLowBalance() moved to lib/ai/credits.ts so a Server Component can call it; the top-bar chip re-exports it unchanged. (#774)

  • The guild e2e specs (guilds.spec.ts, guild-invitations.spec.ts) now go through e2e/helpers/guilds.ts. createGuildAndOpenRoster() creates the guild from the empty state or the New guild dialog, whichever the test user sees, and opens it by its name link. chooseAddByName() picks the Add member card's name-only segment. issueClaimCode() finds the claim code inline or in the row's ⋯ menu, depending on NEXT_PUBLIC_FEATURE_GUILD_INVITES. The rename spec edits the heading in place. Unit tests pin the two-step Leave and Remove, the Make owner dialog, per-row and per-card results, the Copy button's success and refusal, the empty state and the Former members group (#773).

  • The recap sharing work is pinned in supabase/tests/well_played_recap_sharing.sql (22 assertions: the monthly breakdown and its zero-fill, minting and its idempotence, a recompute keeping the link, another user's call reaching only their own rows, the signed-out read returning the trimmed name, only that year's badges and no user id, the neutral-name fallback, revocation, and that no client — owner or not — writes share_id directly or claims a revoked token) and four new rls.sql assertions that a shared recap still exposes no well_played_recaps row to anon or another user and that share_id is not client-writable. Vitest covers the new query helpers and setRecapSharingAction, the share button's every outcome (share sheet, dismissal, refusal falling back to the clipboard, clipboard refusal falling back to the visible link, "Link copied" resetting, revoke, failure), the year navigation, the refresh row, the monthly chart, the card's two narrative voices, both pages (a signed-out visitor, a signed-in one, a malformed, revoked or unreadable link) and the OG route's share and session paths, including that ?uid= no longer renders anything. The live Playwright recap spec (e2e/recap.spec.ts) follows the renamed "Computed · Refresh" controls and gains a scenario that shares a recap, opens the link in a signed-out context, stops sharing and sees the dead-link page. The apps/web coverage ratchet moved to 89.4 / 90.5 / 92.9 / 89.4, measured 89.74 / 91.06 / 93.24 / 89.74 against develop's 89.19 / 90.98 / 92.88 / 89.19 (#772).

  • achievement-gallery.test.tsx and achievement-badge.test.tsx are new: they pin the gallery's anchors, its four-across grid and the collapsed locked row, which leaks no locked badge's name, plus the strip's link and size options. profile/page.test.tsx adds the archived column appearing only with archived themes, the one-row stats, the recap's "New" mark and the badge links. email-preferences-form.test.tsx now finds the categories by role="switch" and adds the three groups, a save posting the whole form on change (including the quiet-hour times a switch reveals), the timezone select and its detected-zone hint and button, a refused and a throwing save, a change made mid-save going out after the first save with the latest state, no repeat save when nothing changed, UTC being offered, and the text-field fallback. timezones.test.ts pins timezoneChoices(). The signed-in scenarios in e2e/notification-preferences.spec.ts now flip switches by their labels and wait for each change's own Server Action POST and "Saved" line instead of clicking Save. The timezone scenarios pick from the select, and quiet-hour times are checked after saving on blur (#771).

  • The settings hub is pinned: avatar path ownership and URL round-tripping (lib/profile/avatars.test.ts), the profile and theme actions including a foreign object path being refused and the old upload being removed (lib/profile/actions.test.ts), the password change's reauthentication round trip (lib/auth/change-password.test.ts), the <head> theme script run in jsdom including restoring the class after a root re-render (lib/theme/theme.test.ts), the forms, the section pages and /profile's first test. supabase/tests/avatar_reaper.sql pins which avatar objects the reaper may delete, and the worker's reaper tests cover the new bucket. e2e/settings.spec.ts covers the signed-out redirects in smoke mode and name editing and the theme round trip against a live project; the notification-preference specs now open /settings/notifications. The coverage ratchet rose to 88.6 / 90.4 / 92.4 / 88.6 (#770).

  • The new app/(app)/themes/page.test.tsx pins the hub's rail-and-grid layout, the form kept out of the page behind Edit theme, the default-size Create button, the archive's links to /quests?theme= (none for a year with nothing tagged) and the three error and empty states; year-theme-editor.test.tsx pins the dialog's open, Escape, Close and backdrop paths, its refusal to close mid-save and the Saved chip that fades; year-theme-color-picker.test.tsx pins the named, 44px swatches. index-params.test.ts, queries.test.ts, quest-filters.test.tsx and the Quests page test cover the theme param, the owned-only filter and its chip, redirect and failed-read paths. The apps/web coverage floors rise to 87.8 / 90.4 / 92.3 / 87.8 (#769).

  • components/quest-packs/quest-pack-browser.test.tsx pins the catalogue's grid classes, the three-title card list and its "+N more", the toolbar's labels and result count, the two empty states and Clear filters, the failed-read notice, and the preview dialog's focus move, Tab wrap, Escape, backdrop close and #597 panel bound (header, heading and footer outside the scroll region). lib/quests/pack-filters.test.ts covers the search, category and "all" matching, and app/(app)/quest-packs/page.test.tsx pins that a rejected listQuestPacks() reaches the browser as loadFailed rather than as an empty catalogue (#767).

  • quest_jam_scheduled_start.sql (27 assertions) covers the scheduled start — the single remaining signature, now / past / future / over-30-days / exactly-30-days starts, the quota counting a scheduled Jam, its appearance as upcoming and refusal to admit a join — and the participants list: who is in it, its order, that its result type carries no quest id, title or task counts, the finisher's absence, the Adventurer fallback, anonymous reads and the inactive-Jam cut-off. On the web side index-filter.test.ts, quest-jam-state-filter.test.tsx, quest-jam-inline-join.test.tsx, quest-jam-countdown.test.tsx, quest-jam-leaderboard.test.tsx and quest-jams/page.test.tsx are new, and the card, start-form, detail-page, validator and action tests were rewritten for the new shapes (#766).

  • preview.test.ts pins the recurring-cycle preview against the mint's rules (future and past starts, an ended current cycle, "ended" judged in the template timezone, as an instant at full precision and across both DST transitions, cycles displayed as the minted Quest shows them across a DST change, the next-change wait, starts skipped by a spring-forward gap or in an unknown zone, invalid dates, and durations outside the schema's whole-minute, one-week bounds); quest-recurrence-form.test.tsx pins the live preview, the "Ends" echo, the custom-duration field owning the single submitted duration_minutes, the Goal textarea and an enabled submit button in the server markup the summary refreshing when a shown cycle ends while the form is open, the hint for a skipped start, and the form still rendering with an out-of-range custom duration; actions.test.ts pins the redirect to the lowest minted cycle, the /quests fallback, and the blank-timezone fallback to the saved notification timezone and then UTC; the live quest-attendance.spec.ts now expects to land on the cycle-0 Quest after creating (#765).

  • coaching-entry-lifecycle.test.tsx pins the coach card after a draft: it collapses rather than unmounting, Ask for changes reopens the same mounted conversation, Close returns to the collapsed card while Cancel before any draft leaves the coach, and the persona chip renders in one place at a time. coaching-interview.test.tsx adds the answer field returning after Continue, a follow-up re-draft sent on the same interview id, no focus taken while collapsed, the three-row resizable composer and its growth cap, the labelled 44px Send button and the full-height thread at lg. coaching-quest-creator.test.tsx covers the persona chip (opening the picker dialog portalled to <body>, following a pick), the side-by-side lg layout with the coach first in the DOM, and the persona section and unpinned footer with the coach off. quest-form.test.tsx adds the unified task section under Goal, Change pack revealing (and focusing) a select that still submits while hidden and that steps back behind Change pack again once a pack is picked from it, the Public quest switch, the Draft | Active control and the opt-in sticky footer, and the new quest-form-feedback.test.tsx renders submit results with a stubbed form state: messages in the footer, the named-fields line, aria-invalid / aria-describedby wiring and focus moving to the first invalid field. The apps/web coverage floors rise to 85.9 / 89.8 / 91.2 / 85.9 (statements / branches / functions / lines). (#764)

  • task-quest-list.test.tsx pins the /tasks quest list: done/total per row with the selected quest's live counts, the party mark only on a Party Quest, a greyed draft and its lock note, the title filter and its empty message, and the sticky lg column. task-manager.test.tsx pins the workspace heading (the quest title linked to its page, with its status, and no "Task management"), the small-screen switcher in the header, switching quests from the list, no party read for a solo quest, the owner's assignee picker on a Party Quest, a member's quest starting locked (Add included) until its party arrives and staying locked, with a notice, when it cannot be read, a quest's live count surviving a switch to another quest, a picked assignee cleared on a switch, the notices for an unread party and a missing seat, a revisited Party Quest offering nothing from its earlier party while it is re-read, a failed task load never counted against the quest loaded before it, and GET /api/quests/[id]/party (party/route.test.ts) refusing anonymous callers and answering a failed read with a 500 (#763).

  • The new lib/tasks/bulk.test.ts pins which selected tasks each bulk action may touch (a party member's own seat only, assigning for the owner alone, never deleting a challenge task, nothing once the quest is closed), that a batch reports each task's outcome on its own with every request started at once, and the bar's failure and skip wording; lib/tasks/shortcuts.test.ts pins the typing-target guard, the key map (Space and Enter only on the row itself, no modifier chords) and j/k stepping. components/tasks/task-manager-bulk.test.tsx drives both /tasks and the embedded manager: selecting, completing with a partial failure that stays selected, setting and removing due dates, assigning, the delete confirmation and its partial restore, the Select toggle, a task a filter hides staying selected but out of the batch, a member's skipped and unsent tasks, and every shortcut, including that none fires while typing; it also pins that a batch still out when the user switches quests is dropped rather than folded into the new quest's list, that the finished-quest re-read waits for a batch of completions ("Complete all" included) to settle, the list-wide write lock (every existing task locked while a batch runs, the keyboard included, adding a task still open; any row write or open edit form, selected or not, holding every batch and "Complete all"), that a batch's stale snapshot cannot undo an inline rename, that a batch's failures stay selected through Clear, the Select toggle, Esc and an untick, that every row write in flight is tracked so a second row cannot free the bar early, that a location check-in holds batches like any row write and reports a refusal, that a quest switch drops an open edit form, that a bulk delete waits for a pending rename and restores its title on failure, that the shortcuts stand down under any open modal, that Esc in an open row menu leaves the selection alone, that an investigation poll read across a batch is dropped, that a row write, "Complete all" or a batch stalled on one quest leaves another quest free, that the inline title is locked while a batch runs, and that a failure hidden by a filter mid-batch keeps its report and its selection. The new e2e/task-bulk-actions.spec.ts drives the real routes: keyboard selection and a bulk due date on /tasks, then the Select toggle, the delete confirmation and a bulk complete on the quest page, each checked after a reload, plus the bar at phone width. The apps/web coverage floors rise to 85.6 / 89.5 / 90.8 / 85.6. (#762)

  • Covered the task row rework: lib/tasks/reorder.test.ts pins moving a task relative to another in the full order, Move up / Move down and drops across a filtered list, and no-op moves; app/api/tasks/reorder/route.test.ts covers the route's success (including a list of 1,200 tasks, since a quest's task count is uncapped and the list must name every task), 400, 401, 404, 403, stale-list 409 and quest-status 409 paths, including a quest that closes or is deleted between the route's check and the RPC (409 / 404 rather than the owner-only 403) and a reorder that could not get its locks (retryable 409); task-manager.test.tsx replaces "offers no reordering controls" with owner reordering, the disabled ends of the list, a filtered move, the reload after a stale-list refusal, no reordering under the due-date sort, on an archived quest or for a party member, Move up / Move down across the boundary between the open list and the completed group, a status write or an investigation poll that was in flight across a reorder not restoring the old order, a created task not appearing twice and a deleted one not coming back after a stale-list reload, and a failed reorder that resolves (or whose recovery reload resolves) after switching to another quest on /tasks leaving the new quest's list alone, plus the status picker's one-click Blocked and Done and its draft-quest gating. The signed-in Playwright quest flow now finds the status button by name rather than as the row's first button (#761).

  • The new lib/tasks/list-filters.test.ts pins the task list's URL contract (chip slugs, unknown values falling back to All, other params such as questId kept, defaults dropped, round-tripping), the search's word, case and accent matching over title and details (case folding that does not follow the browser's locale, so a Turkish default cannot turn "I" into dotless "ı", and that equates Greek final and medial sigma and "ß" with "SS"), the status counts, and the completed-group preference including storage that throws. task-manager.test.tsx covers the chip counts and the collapsed group, the group's open state surviving a remount, status filtering with the URL and the stable #N badge, search narrowing the counts and Escape clearing it, filters restored from the URL on the embedded manager, the no-match state and Clear filters, and no toolbar on a quest without tasks; the tests that read a done task now open the group first (#760).

  • task-manager.test.tsx pins the quick-add row on both /tasks and the embedded quest-page manager: one row of title, toggles and Add with nothing optional on screen, Enter creating the task, each toggle revealing and focusing its field and marking a filled one, a collapsed due date still submitted, every field closing after a create, the location picker opening on its map and closing on remove, and no location or assignee toggle where those fields are unavailable. due-dates.spec.ts opens the due-date field through its toggle before each fill (#758).

  • quest-task-list.test.tsx now pins the shared status badge for all four statuses with no marker dot, the strike-through on finished tasks only, details clamped to two lines, a heading with no done count or progress bar, and that no deadline renders even if a task row carries one. page-header.test.tsx pins that PageHeader stacks its actions under the title below sm with no width cap, and the quest page's source test pins that only the status badge sits in its title row while the due date stays in the rail. The apps/web functions floor rose to 90.0 (#757).

  • inline-editable-text.test.tsx pins the inline editor's keyboard contract: Enter and blur save the trimmed draft once (also when Enter is followed by the field's blur), Escape cancels without saving and without reaching a document-level listener, an unchanged value sends nothing, an empty required value and a refused or failed save keep the draft open with the reason, an IME composition's Enter is ignored, and the heading's accessible name stays the text itself. actions-choices.test.ts pins that updateQuestInlineFieldAction() writes only the field it was given (never is_public), stores a cleared goal as null, and refuses an unknown field, a quest the caller does not own and an archived quest without writing. quest-form.test.tsx pins that onPendingChange reports and settles on unmount. quest-owner-actions.test.tsx and task-manager.test.tsx pin that a blur-save followed straight away by Edit Quest or the pencil opens the full form on the saving value, that the full form's write is not sent until the inline one has settled, and that a refused, failed or superseded draft is dropped; quest-sticky-header.test.tsx that the bar re-binds to a re-created h1; and the inline action's test that a failed write returns fixed copy and logs the database error. quest-owner-actions.test.tsx pins the sheet: a portalled aria-modal dialog, focus in and back, Escape and ✕, no dismissal mid-save or on an outside click. page.render.test.tsx and task-manager.test.tsx pin who gets the inline editors and that a task rename sends only the title. The two e2e flows that edit a quest now scope to the sheet (#756).

  • The quest page gets its first render test, app/(app)/quests/[id]/page.render.test.tsx, which runs the Server Component with its reads and heavy children stubbed and pins that a draft, completed, failed or archived quest shows exactly one lock notice, in the header, to the owner and to a party member, that the embedded task list is passed showLockAlert={false}, and that an active quest or a visitor to a public quest sees none; it fails against the previous page. The new app/(app)/tasks/page.test.tsx pins that /tasks leaves its task list to state the lock. task-manager.test.tsx pins that showLockAlert={false} removes the notice but leaves the controls disabled, and quest-owner-actions.test.tsx that the archived toolbar no longer repeats it (#755).

  • Pinned the quest page's two-column layout (#753): quest-progress.test.tsx covers the meter's "N of M tasks", empty and compact forms and the provider taking reported and refreshed counts but never an unloaded list's "0 of 0"; quest-sticky-header.test.tsx drives a fake IntersectionObserver to show the bar pins only once the h1 has scrolled above the app bar (not when it is below the fold), stays out of the accessibility tree while hidden, and offers the owner one transition and everyone else none; quest-details-card.test.tsx covers each row and the failed streak read; task-manager.test.tsx asserts the embedded list reports its counts to the page; QuestLifecycleActions, formatQuestScheduleWindow and the reference grid gained cases, and both portalled dialogs assert they render at <body>; the sticky-header test also covers the signed-out offset (zero below md, 56px from it, re-measured when the viewport crosses md), and the new live-gated e2e/quest-detail.spec.ts checks where the bar pins for a signed-out visitor at 390px and 1280px and a signed-in one at 390px, with no sideways scroll; and app/(app)/quests/[id]/page.test.ts reads the page source to pin the column order, since the async Server Component has no render test (#753).

  • lib/quests/public-index-params.test.ts pins the /quests/public URL contract (canonical parsing, the shapes that redirect, unknown keys left alone, round-tripping) and the search filter's escaping, each case of which was first checked against a real PostgREST 12. lib/quests/queries-reads.test.ts replaces the listPublicQuests() cases with listPublicQuestIndex(): per-status head counts under the search, the search carried into every read, the status, sort and range on the page read, no page read past the end, the progress ranking with one task read and a quest unpublished between reads dropping out, owner attribution, and which failures throw. public-quest-index-toolbar.test.tsx and public/page.test.tsx are new; quest-filters.test.tsx covers the public mode and public-quest-card.test.tsx the clamp, the single progress line and the updated time. The search provider behind both toolbars is now one createIndexSearch() factory (components/quests/index-search.tsx), and #751's toolbar tests pass through it unchanged (#752).

  • lib/quests/index-params.test.ts pins the /quests URL contract: canonical parsing, every non-canonical shape that must redirect, the search and page caps, and that a written href parses back as canonical. lib/quests/queries.test.ts pins listQuestIndex(): that the party read lists only joined quests and checks member seats of owned quests only, case-insensitive search, each chip counted under the other filters, 24 quests a page with the per-quest reads seeing only those, the quest read paging past 1000 rows, joined quests read 100 ids at a time and merged in by recency, next due dates asked for 100 quests per call, natural title order, progress ranking across every match with one task read, upcoming schedules first, a failed member read degrading to joined quests only, and a failed quest read throwing. quest-filters.test.tsx, quest-index-toolbar.test.tsx and quest-row.test.tsx are new: counts, 36px chips, hidden empty Failed/Archived, links that keep the other params and reset paging; the debounced replace, Enter without a second navigation, a cleared search or the back button adopted (Back even when its entry has the search just sent) while text typed during a loading search is kept, and the sort menu, the chips, "Show all quests" and the density toggle all carrying the search still being typed rather than cancelling it, a search typed while a chip or sort is still loading keeping that chip or sort, the no-JS form, and the density cookie; and the row's single stretched link, done count without a percentage, and stall marker. page.test.tsx now covers the redirects, the params passed to the read, the grid and rows layouts, both empty states, Show more and the failed read. e2e/quests-index.spec.ts adds a live scenario: search narrows the list through ?q=, a search with no match offers "Show all quests" and no "Create a quest", and Rows survives a reload. The apps/web coverage floors rose to 82.8 / 89.1 / 89.2 / 82.8 (#751).

  • quest-card.test.tsx, public-quest-card.test.tsx and the new quest-jam-card.test.tsx assert that no button, menu, input or other link sits inside a card's anchor (with the snooze menu open, for the quest card), that the link is named by the title alone and stretched over a positioned card, that the stall banner's tab stops run title → suggestion → Check in → Snooze → dismiss, and that the banner controls carry the 32px classes. The quest and public-card assertions fail against the previous components. (#750)

  • The new supabase/tests/dashboard_deadline_reads.sql (23 assertions) pins both deadline functions against real fixtures: the owner's list (overdue first; done, undated, past-the-week, draft-quest and deleted-quest tasks left out), a party member's list holding only their own seat's tasks plus their own quest, a stranger's empty list, the limit clamp, a quest's next due date ignoring done tasks, an inaccessible quest left out, and anon refused; rls.sql adds four assertions that an outsider gets nothing from a public quest through either function and anon cannot execute them. The new lib/dashboard/due-tasks.test.ts pins the seam: the week-ahead horizon and the 50-row request, no direct tasks read, the truncated flag on a full read only, and that a failed read throws rather than reporting nothing due. due-this-week.test.tsx pins the empty and failed states, the quest link, the first eight shown and the rest opened and closed in place by an aria-expanded toggle with no link away, the "50 soonest" note on a full read, the status cycle through PATCH /api/tasks/:id with a page refresh, and a refused write surfaced inline without changing the row. due-this-week.test.tsx also pins that the achievement toast survives the refresh that empties the list, and that a finished row stays disabled while the refresh is out. dashboard-stats.test.tsx pins Completion as the only linked tile, and unlinked with no active quest; lib/tasks/types.test.ts pins pickInitialQuestId(): a visible requested quest wins, then the newest active quest, then the newest quest; quest-card.test.tsx pins the open count, next due date and "All done" in place of the percentage; lib/quests/queries.test.ts pins that next_due_at comes from get_quest_next_due_dates(), that the task-count select never names due_at, and that a failed next-due read keeps the list; the dashboard body tests pin the list above the preview and a failed read kept distinct from none. e2e/due-dates.spec.ts adds a live scenario: with an active quest holding an overdue, a tomorrow and an undated task, /dashboard lists the two dated ones overdue first; advancing the overdue one through In progress to Done over the real PATCH /api/tasks/:id drops it from the list after the refresh, moves Tasks done to 1 and Completion to 1 of 3, survives a reload, and leaves the quest card reading "2 open · next due". The apps/web statement and line floors rose to 82.0 (#749).

  • dashboard-body.test.tsx now covers DashboardRail and DashboardQuests separately: the rail's stacked order, the theme/challenge row going back to one column at xl, and the preview asking for four columns and the start steps. The new dashboard-skeleton.test.tsx pins the loading skeleton to the same grid, rail and main-column classes as the page and keeps the rail placeholder silent beside the quest placeholder's single announcement. The dashboard page test pins the stats inside the rail, the preview in the main column and the stacked DOM order; quest-list.test.tsx pins the default and caller-supplied columns, the compact empty state and the start steps; quest-cycle-streaks.test.tsx pins the explanation behind a closed, labelled disclosure; the new quest-card.test.tsx pins the card link and progress and that the compact layout applies only from xl; the welcome hero, Up next and statistics tests pin the compact bar, the small heading and the xl 2×2. The apps/web coverage floors rose to 81.8 / 88.5 / 88.5 / 81.8 (#748).

  • components/feedback/feedback-provider.test.tsx pins that the floating button and any useOpenFeedback() trigger open the same single dialog for the shell's user, that a submission closes it and shows a toast that clears itself, and that useOpenFeedback() is null outside a provider. feedback-button.test.tsx pins the button as hidden below md; feedback-modal.test.tsx pins the Page URL caption (no form field) and the Type chips (real radios, min-h-11); app-top-bar.test.tsx and admin-nav.test.tsx pin the account-popover and drawer rows that both close their overlay before opening the dialog, and that focus moves into the dialog and returns to the menu button or drawer toggle on close; feedback-provider.test.tsx also pins the focus return after a close, after a submission, to the floating button, and not to a trigger that has since unmounted. app/feedback-placement.test.tsx pins the placement itself: the root, (standalone) and (public) layouts import no feedback component, and the app shell and admin layout each mount exactly one floating button. e2e/feedback.spec.ts now picks the Type by its chip, and a new spec opens feedback from the account menu at 390px with the floating button hidden and checks that the invitation and unsubscribe pages show none. The apps/web coverage floors rose to 81.7 / 88.5 / 88.4 / 81.7 (#747).

  • components/layout/public-header.test.tsx pins both variants: the signed-out and signed-in actions, Sign in kept on phones and signup dropped there, the catalogue order and the billing-gated Pricing link, aria-current on a section and its descendants, sticky only from md, the compact Sign out form posting to /auth/logout, the sign-in screen pointing across to signup, the ?next= return target (current page, forwarded on auth screens, none on the landing page, /profile from the unsubscribe page with no token copied, off-site values dropped — also in lib/navigation/public.test.ts), the profile read skipped for the compact variant and for a viewer the caller already resolved, and the signed-out fallback when the session read throws. app/public-header-layouts.test.tsx pins which layout mounts which variant. The landing page test now checks that every signup and browse link on the page has the same label. In e2e/, home.spec.ts checks that a signed-out visitor gets from / to /auth/login in one click, that the catalogue shows on desktop, and that a phone keeps Sign in with no sideways scroll; auth.spec.ts checks the compact header on the auth pages; the landing, Challenges and invitation specs scope their links to the hero, the header or the card. The apps/web coverage floors rose to 81.5 / 88.4 / 88.0 / 81.5 (#746).

  • components/ui/local-datetime.test.tsx pins the server rendering (UTC date, time or both depending on the requested styles or fields, a Date value, an unparseable string shown as-is), the post-mount swap to the viewer's Intl output, the relative label with its absolute title and its minute refresh, and LocalDateRange's two <time> elements. The new due-date-badge.test.tsx pins that the badge's server markup carries no ISO text outside the datetime attribute and that it reads "Due soon"/"Overdue" once mounted. The guild roster, party panel and feedback GitHub-issue tests now derive their expected dates with Intl in the runtime zone instead of hard-coding UTC renderings, so they pass under TZ=America/Toronto and TZ=Asia/Tokyo as well as UTC (#745).

  • page-skeleton.test.tsx, route-error.test.tsx, not-found.test.tsx and route-boundaries.test.tsx pin the skeletons' single live region and landmarks (no second <main> inside the admin layout), the digest-not-message rule and the refresh-then-reset retry; lib/dashboard/body.test.ts pins that loadDashboardBody() never rejects and keeps a failed theme read distinct from "no theme"; the dashboard page's first test pins that the header and stats render while the quest grid is still suspended. e2e/route-boundaries.spec.ts checks the boundaries in a real browser: an unknown URL answers 404 with the branded page and links home, a slow page streams its skeleton and then replaces it with a single <main>, and a failing page shows the error boundary and comes back on "Try again" only because the retry re-runs the server render. The slow and failing pages are *.e2e.tsx fixture routes under app/(app)/e2e-fixtures/, which next.config.mjs adds to pageExtensions only when E2E_ROUTE_FIXTURES=1 — set by the Playwright web server and nothing else — so no other build has those URLs. home.spec.ts now gives the Challenges heading the same 15s allowance as the URL, because the URL commits when the skeleton streams, before the page's reads finish. The apps/web coverage floors rose to 81.1 / 88.3 / 87.6 / 81.1 (#744).

  • page-container.test.tsx and page-header.test.tsx pin the width mapping, the single max-w-* class, the rhythm override, the eyebrow, description and actions slots, and the breadcrumb's linked crumbs and unlinked current page. The first quest-jams/[id]/page.test.tsx pins that a signed-in visitor gets the breadcrumb and no back button, and that a signed-out visitor keeps the button. The apps/web statement and line floors rose to 80.7 (#743).

Documentation

  • The add-public-route skill now says where a new public page gets its header — the (public) or (standalone) route group, or the (app) layout's signed-out branch — and that its signup, sign-in and browse buttons take their labels from PUBLIC_CTA (#746).

  • reports/ux-audit-2026-09-30/ records a page-by-page UX and screen real-estate audit of all 44 apps/web routes on develop @ 8f20ea1e: five reports (core pages, create flows and catalogs, the account cluster, admin, public and auth) with file:line evidence for each finding, an index, and create-ux-issues.sh, which files the findings as one epic and 51 issues with the GitHub CLI (DRY_RUN=1 lists them without creating anything). The audit read source only; nothing was rendered in a browser. (#741)