From 68d02cae1080599525cb99a30c030338e2e7331c Mon Sep 17 00:00:00 2001 From: Jeff Korzeniowski Date: Fri, 3 Jul 2026 03:56:56 -0400 Subject: [PATCH 1/5] Harden demo backend: lock down public mutations, fix org visibility, bound feed - seed:run is now internalMutation (was a public wipe-the-DB endpoint) - setProfileBySubject -> updateProfile: identity from ctx.auth, no client role escalation; users.list no longer exposes auth subjects - delete dead unauthenticated endpoints: reads.ts, posts.create, posts.counts - bound posts.feed to 200 most recent (was unbounded collect + N+1 reads) - add posts.orgId and fix feedForSpace org-visibility filter (checked a field that did not exist; org posts were invisible to everyone) - dedupe session-overlay participants (duplicate avatars / React keys) Co-Authored-By: Claude Fable 5 --- convex/_generated/api.d.ts | 2 -- convex/posts.ts | 68 ++++++-------------------------------- convex/reads.ts | 46 -------------------------- convex/schema.ts | 3 ++ convex/seed.ts | 6 ++-- convex/spaces.ts | 6 ++-- convex/users.ts | 27 ++++++++++----- src/lib/store.tsx | 6 ++-- 8 files changed, 42 insertions(+), 122 deletions(-) delete mode 100644 convex/reads.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index b8d0506..09aab87 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -13,7 +13,6 @@ import type * as ai from "../ai.js"; import type * as discussions from "../discussions.js"; import type * as flashExperiments from "../flashExperiments.js"; import type * as posts from "../posts.js"; -import type * as reads from "../reads.js"; import type * as replies from "../replies.js"; import type * as seed from "../seed.js"; import type * as spaces from "../spaces.js"; @@ -31,7 +30,6 @@ declare const fullApi: ApiFromModules<{ discussions: typeof discussions; flashExperiments: typeof flashExperiments; posts: typeof posts; - reads: typeof reads; replies: typeof replies; seed: typeof seed; spaces: typeof spaces; diff --git a/convex/posts.ts b/convex/posts.ts index d79c6fb..2893f8d 100644 --- a/convex/posts.ts +++ b/convex/posts.ts @@ -1,4 +1,4 @@ -import { query, mutation } from "./_generated/server"; +import { query } from "./_generated/server"; import { v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import type { QueryCtx } from "./_generated/server"; @@ -48,19 +48,22 @@ export const feed = query({ onlyUnread: v.optional(v.boolean()), }, handler: async (ctx, args) => { + // Bounded read: the feed only ever renders recent activity, and `enrich` + // costs a postReads lookup per post per viewer — don't scan the table. + const FEED_LIMIT = 200; let posts: Doc<"posts">[]; if (args.space) { posts = await ctx.db .query("posts") .withIndex("by_space", (q) => q.eq("space", args.space!)) .order("desc") - .collect(); + .take(FEED_LIMIT); } else { posts = await ctx.db .query("posts") .withIndex("by_activity") .order("desc") - .collect(); + .take(FEED_LIMIT); } if (args.priority) { @@ -120,58 +123,7 @@ export const get = query({ }, }); -export const counts = query({ - args: { viewerId: v.optional(v.id("users")) }, - handler: async (ctx, args) => { - const posts = await ctx.db.query("posts").collect(); - if (!args.viewerId) return { total: posts.length, unread: 0, urgent: 0 }; - let unread = 0; - let urgent = 0; - for (const post of posts) { - const read = await ctx.db - .query("postReads") - .withIndex("by_user_post", (q) => - q.eq("userId", args.viewerId!).eq("postId", post._id), - ) - .unique(); - const isUnread = post.lastActivityAt > (read?.lastReadAt ?? 0); - if (isUnread) { - unread++; - if (post.priority === "urgent") urgent++; - } - } - return { total: posts.length, unread, urgent }; - }, -}); - -export const create = mutation({ - args: { - authorId: v.id("users"), - title: v.string(), - body: v.string(), - space: v.string(), - priority: priority, - }, - handler: async (ctx, args) => { - const now = Date.now(); - const postId = await ctx.db.insert("posts", { - authorId: args.authorId, - title: args.title, - body: args.body, - space: args.space, - priority: args.priority, - pinned: false, - createdAt: now, - lastActivityAt: now, - replyCount: 0, - participantIds: [args.authorId], - }); - // Author has "read" their own new post. - await ctx.db.insert("postReads", { - userId: args.authorId, - postId, - lastReadAt: now, - }); - return postId; - }, -}); +// NOTE: write paths (create post / reply / mark read) intentionally have no +// public Convex mutations here. Visitor writes live in the client's +// session-only overlay (`src/lib/store.tsx`); the shared demo DB stays +// read-only. The auth-gated exception is `discussions.ts`. diff --git a/convex/reads.ts b/convex/reads.ts deleted file mode 100644 index 89bafa5..0000000 --- a/convex/reads.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { mutation } from "./_generated/server"; -import { v } from "convex/values"; - -/** Mark a post as read for a user (call when the detail view opens). */ -export const markRead = mutation({ - args: { userId: v.id("users"), postId: v.id("posts") }, - handler: async (ctx, args) => { - const now = Date.now(); - const existing = await ctx.db - .query("postReads") - .withIndex("by_user_post", (q) => - q.eq("userId", args.userId).eq("postId", args.postId), - ) - .unique(); - if (existing) await ctx.db.patch(existing._id, { lastReadAt: now }); - else - await ctx.db.insert("postReads", { - userId: args.userId, - postId: args.postId, - lastReadAt: now, - }); - }, -}); - -export const markAllRead = mutation({ - args: { userId: v.id("users") }, - handler: async (ctx, args) => { - const now = Date.now(); - const posts = await ctx.db.query("posts").collect(); - for (const post of posts) { - const existing = await ctx.db - .query("postReads") - .withIndex("by_user_post", (q) => - q.eq("userId", args.userId).eq("postId", post._id), - ) - .unique(); - if (existing) await ctx.db.patch(existing._id, { lastReadAt: now }); - else - await ctx.db.insert("postReads", { - userId: args.userId, - postId: post._id, - lastReadAt: now, - }); - } - }, -}); diff --git a/convex/schema.ts b/convex/schema.ts index 633d11c..a41e0f0 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -66,6 +66,9 @@ export default defineSchema({ // `spaceId` (it scopes cross-org readers); the two travel together. spaceId: v.optional(v.id("spaces")), visibility: v.optional(postVisibility), + // Author's org, required for "org"-scoped visibility to mean anything + // (the reader-side filter in `spaces.feedForSpace` compares against it). + orgId: v.optional(v.id("orgs")), // Group C — per-user walls. null/undefined = normal space post; set = a // post written on this user's wall. // diff --git a/convex/seed.ts b/convex/seed.ts index 07df504..9af4fe1 100644 --- a/convex/seed.ts +++ b/convex/seed.ts @@ -1,4 +1,4 @@ -import { mutation } from "./_generated/server"; +import { internalMutation } from "./_generated/server"; import type { Id } from "./_generated/dataModel"; const HOUR = 60 * 60 * 1000; @@ -12,7 +12,9 @@ const DAY = 24 * HOUR; * * Run with: bunx convex run seed:run */ -export const run = mutation({ +// internalMutation: the seed wipes tables, so it must never be callable from +// the public internet. `convex run seed:run` still reaches it from the CLI. +export const run = internalMutation({ args: {}, handler: async (ctx) => { // Wipe existing data so the seed is idempotent. diff --git a/convex/spaces.ts b/convex/spaces.ts index 20d1ded..03f0bd1 100644 --- a/convex/spaces.ts +++ b/convex/spaces.ts @@ -33,9 +33,9 @@ export const feedForSpace = query({ return posts.filter((post) => { // "org"-scoped posts are only visible to a viewer in the same org. if (post.visibility !== "org") return true; - if (!args.viewerOrgId) return false; - if (!("orgId" in post)) return false; - return post.orgId === args.viewerOrgId; + return ( + args.viewerOrgId !== undefined && post.orgId === args.viewerOrgId + ); }); }, }); diff --git a/convex/users.ts b/convex/users.ts index c0845f4..26da161 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,33 +1,42 @@ import { mutation, query } from "./_generated/server"; -import { v } from "convex/values"; +import { ConvexError, v } from "convex/values"; export const list = query({ args: {}, handler: async (ctx) => { - return await ctx.db.query("users").collect(); + const users = await ctx.db.query("users").collect(); + // The directory is public; the auth-identity mapping is not. Strip + // `subject` so visitors can't enumerate real members' auth identifiers. + return users.map(({ subject: _subject, ...user }) => user); }, }); -export const setProfileBySubject = mutation({ +/** Update the signed-in member's own profile (identity comes from auth, never + * from the client — and `role` is deliberately not editable here). */ +export const updateProfile = mutation({ args: { - subject: v.string(), name: v.string(), title: v.string(), - role: v.union(v.literal("admin"), v.literal("member")), initials: v.string(), }, handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) + throw new ConvexError({ + code: "UNAUTHENTICATED", + message: "Sign in to edit your profile.", + }); + const user = await ctx.db .query("users") - .withIndex("by_subject", (q) => q.eq("subject", args.subject)) + .withIndex("by_subject", (q) => q.eq("subject", identity.subject)) .first(); - - if (!user) throw new Error("User not found"); + if (!user) + throw new ConvexError({ code: "NOT_FOUND", message: "User not found" }); await ctx.db.patch(user._id, { name: args.name, title: args.title, - role: args.role, initials: args.initials, }); }, diff --git a/src/lib/store.tsx b/src/lib/store.tsx index 0c18fd5..0e2e4e6 100644 --- a/src/lib/store.tsx +++ b/src/lib/store.tsx @@ -146,8 +146,10 @@ export function StoreProvider({ children }: { children: ReactNode }) { const summary = summaries[p._id]; const lastActivityAt = bump?.lastActivityAt ?? p.lastActivityAt; const readAt = effReadAt(p._id, p.lastReadAt); + // Dedupe across backend + session: the replier may already be a backend + // participant (duplicates would double their avatar and collide keys). const participantIds = bump - ? [...p.participantIds, ...bump.addedParticipantIds] + ? [...new Set([...p.participantIds, ...bump.addedParticipantIds])] : p.participantIds; return { ...p, @@ -171,7 +173,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { const summary = summaries[sp._id]; const lastActivityAt = bump?.lastActivityAt ?? sp.lastActivityAt; const participantIds = bump - ? [...sp.participantIds, ...bump.addedParticipantIds] + ? [...new Set([...sp.participantIds, ...bump.addedParticipantIds])] : sp.participantIds; const readAt = effReadAt(sp._id, 0); return { From f1a2d27f6e0357c7a2632c4eb0bd9ad7af7aaa80 Mon Sep 17 00:00:00 2001 From: Jeff Korzeniowski Date: Fri, 3 Jul 2026 03:57:17 -0400 Subject: [PATCH 2/5] Add CLEANUP_PLAN.md: phased design/architecture cleanup handoff 38 verified audit findings (tokens, components, architecture, routes, docs) organized into 7 executable phases with per-step do/verify instructions for an autonomous agent. Co-Authored-By: Claude Fable 5 --- CLEANUP_PLAN.md | 516 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 CLEANUP_PLAN.md diff --git a/CLEANUP_PLAN.md b/CLEANUP_PLAN.md new file mode 100644 index 0000000..7fd1e1b --- /dev/null +++ b/CLEANUP_PLAN.md @@ -0,0 +1,516 @@ +# Postwork Cleanup Plan — Agent Handoff + +**Generated:** 2026-07-03. The audit ran against commit `2d60cc3`; the hardening changes described in Part 0 are committed as `16d70ab`. +**How this was produced:** one interactive hardening pass over the Convex backend, then a multi-agent audit (5 parallel auditors — design tokens, components, state architecture, routes/accessibility, docs/repo hygiene — with an adversarial verifier re-checking every falsifiable claim against the actual files). **38 findings survived verification, 0 were refuted.** Every quoted "Audit finding" block below is confirmed against the code unless marked *partial*. + +## Ground rules for the executing agent + +1. **Read [CLAUDE.md](CLAUDE.md) first**, and read `convex/_generated/ai/guidelines.md` before touching anything under `convex/` — it overrides trained Convex knowledge. +2. **Setup:** `bun install`, then `bun run typecheck` (`tsc -b --noEmit`) must pass before and after every step. There is no test suite; typecheck + the per-step **Verify** commands are the safety net. `bun run dev` needs a configured Convex deployment (`npx convex dev`) — don't assume you can run the app. +3. **Work one step at a time, one commit per step.** Steps within a phase are independent unless a dependency is called out. Phases are ordered by leverage: do Phase 1 first, and do Phase 2 (primitives) **before** Phase 3 (token sweep) — the primitives absorb dozens of call sites the sweep would otherwise have to touch twice. +4. **Line numbers are a snapshot.** They were verified on 2026-07-03 but will drift as you edit. Re-locate with grep before editing; never blind-edit by line number. +5. **Do not deploy, push, or publish anything** unless the repo owner asks. Local commits only. +6. **Design authority:** [DESIGN.md](DESIGN.md) is the spec ("The Kept Record": calm, durable, single wine accent, sentence case, one primary action per view). Where DESIGN.md contradicts the shipped code, Phase 7 says which side wins — for everything else, the spec wins. +7. Tick the checkboxes in this file as you complete steps, and delete this file when everything is done. + +## Progress checklist + +- [x] 0.1 Commit the hardening changes (done — `16d70ab`) +- [ ] 1.1 Add radius tokens to the theme +- [ ] 1.2 Replace `text-red-300` with the urgent token +- [ ] 1.3 Global `:focus-visible` + select focus styles +- [ ] 1.4 Resolve tracked-uppercase labels +- [ ] 1.5 Fix "home" nav active state +- [ ] 1.6 Make the "+ new post" CTA actually start a post +- [ ] 2.1 Extract `Button` component +- [ ] 2.2 Extract `Chip` primitive (+ `PostMetaChips`) +- [ ] 2.3 Extract `Dialog` shell + `PostForm` +- [ ] 2.4 Unify popover dismiss behavior (+ UserSwitcher keyboard/direction) +- [ ] 2.5 Dedupe `OrgSquare` +- [ ] 2.6 Dedupe the wine-framed agent-panel scaffold +- [ ] 3.1 Color-token sweep: kill the 272 `[var(--color-*)]` arbitrary classes +- [ ] 3.2 Tokenize the type ramp +- [ ] 3.3 One shared avatar/identity palette +- [ ] 4.1 Shared page-header/back-link scaffold +- [ ] 4.2 Consistent loading/empty states +- [ ] 4.3 Per-route document titles +- [ ] 5.1 Restructure the overlay store (memoized context, module-level hooks) +- [ ] 5.2 Single `Priority` type, typed search params +- [ ] 5.3 Honest local-id handling +- [ ] 5.4 Flatten/document the provider pyramid +- [ ] 5.5 Fix graduated flash experiments (incl. double-composer bug) +- [ ] 5.6 Reuse thread components in `ExperimentDiscussion` +- [ ] 5.7 Derive `ExperimentSlot` from `ExperimentSlots` +- [ ] 6.1 Decide: dead Group-B backend (convex/spaces.ts + 3 tables) +- [ ] 6.2 Decide: dead `agentTasks` table +- [ ] 6.3 Backend hardening follow-ups (subject leak, vote counting, codegen) +- [ ] 7.1 Reconcile DESIGN.md with the shipped shell +- [ ] 7.2 Purge the retired monospace aesthetic from AGENTS.md / PRODUCT.md +- [ ] 7.3 Refresh README +- [ ] 7.4 Regenerate OG images +- [ ] 7.5 Archive stale planning docs +- [ ] 7.6 Pin dependencies + +--- + +## Part 0 — Already done in the previous session (do NOT redo) + +These backend-hardening changes are already committed as `16d70ab` ("Harden demo backend: …"). **Step 0.1 is done** — just read that commit (`git show 16d70ab`) so you know the current backend shape before starting. + +What was changed and why: + +| File | Change | +|---|---| +| `convex/seed.ts` | `seed:run` was a **public** mutation that wipes every table — anyone with the deployment URL could erase the demo DB. Now `internalMutation` (CLI `convex run seed:run` still works). | +| `convex/users.ts` | `setProfileBySubject` accepted `subject` and `role` from the client — unauthenticated profile takeover + self-promotion to admin. Replaced with `updateProfile`: identity derived from `ctx.auth.getUserIdentity()`, `role` not editable. `users.list` now strips the `subject` field so visitors can't enumerate real members' auth identifiers. | +| `convex/reads.ts` (deleted), `posts.create`, `posts.counts` (removed) | Dead, unauthenticated public write/scan endpoints. The client's session overlay (`src/lib/store.tsx`) replaced them long ago; nothing in `src/` called them. `posts.ts` now carries a comment documenting the intentional absence of public write paths. | +| `convex/posts.ts` `feed` | Was `.collect()` (unbounded) with a per-post, per-viewer `postReads` lookup. Now `.take(200)` on both branches. | +| `convex/schema.ts` + `convex/spaces.ts` | `feedForSpace` filtered org-visibility posts on a field (`orgId`) that didn't exist in the schema — every `"org"`-scoped post was invisible to everyone. Added `posts.orgId: v.optional(v.id("orgs"))` and made the filter honest. | +| `src/lib/store.tsx` | Session-added participants are now deduped against backend participants (was: duplicate avatars + duplicate React keys after replying to a thread you were already in). | +| `convex/_generated/api.d.ts` | Hand-edited to drop the deleted `reads` module (codegen needs a configured deployment). Regenerate properly at the first opportunity — see step 6.3. | + +--- + +## Phase 1 — Foundation quick wins (~1 hour total, all user-visible) + +### Step 1.1 — Add radius tokens to the theme + +The design system's smallest radius renders at **double the spec** because the theme never defines radius tokens. + +> **Audit finding (confirmed · impact: high · effort: small) — rounded-sm renders 4px, not the spec's 2px — radius tokens never defined in the theme** +> +> DESIGN.md specifies rounded.sm = 2px (frontmatter line 50; prose: 'tags 2px' line 145, agent badge 'rounded-sm (2px). The smallest radius in the system' line 307-308). But the @theme block in src/index.css:3-28 defines no --radius-* tokens, so Tailwind v4's defaults apply and `rounded-sm` = 0.25rem = 4px. Every 'smallest radius' element therefore renders at double the spec: src/components/AgentTag.tsx:8, src/components/UserRoleTag.tsx:16, src/components/AgentSummary.tsx:45 (ai chip), src/components/AgentTasksPanel.tsx:67, src/components/SendAgentButton.tsx:48, src/components/RichText.tsx:10 (inline code), src/routes/AgentsPage.tsx:108, src/routes/SpacePage.tsx:182. Fix: add `--radius-sm: 2px;` to the @theme block in src/index.css (optionally also pin --radius-md: 6px and --radius-lg: 8px, which currently only coincidentally match the v4 defaults of 0.375rem/0.5rem). Alternative: swap those eight usages to `rounded-xs` (0.125rem = 2px), but tokenizing keeps the DESIGN.md name-to-utility mapping intact. +> +> _Files:_ `src/index.css`, `src/components/AgentTag.tsx`, `src/components/UserRoleTag.tsx`, `src/components/AgentSummary.tsx`, `src/components/AgentTasksPanel.tsx`, `src/components/SendAgentButton.tsx`, `src/components/RichText.tsx`, `src/routes/AgentsPage.tsx`, `src/routes/SpacePage.tsx` + +**Do:** Add to the `@theme` block in `src/index.css`: `--radius-sm: 2px; --radius-md: 6px; --radius-lg: 8px;` (md/lg currently only coincidentally match Tailwind v4 defaults — pin them). +**Verify:** `bun run typecheck`; then confirm no component needed changing: the existing `rounded-sm` classes now resolve to 2px via the token. + +### Step 1.2 — Replace `text-red-300` with the urgent token + +> **Audit finding (confirmed · impact: medium · effort: small) — Urgent queue count uses Tailwind default text-red-300 instead of the --color-urgent token** +> +> src/components/AppShell.tsx:91 renders the sidebar urgent count with `text-red-300` (Tailwind's default palette red), while every other urgent-state surface uses the design token #ff6b6b: the Urgent priority chip/dot in src/lib/format.ts:29-30, the failed StatusChip in src/components/StatusChip.tsx:10, and the task error note in src/components/AgentTasksPanel.tsx:135. DESIGN.md scopes red-300 to inline error notes only (line 333) and names Urgent Coral #ff6b6b as the urgent state color (line 182). A count of urgent posts is priority state, not an error. Fix: change AppShell.tsx:91 to text-urgent (or text-[var(--color-urgent)] under the current idiom). (src/components/AgentSummary.tsx:71's red-300-on-red-500/10 error note is correct per spec and should stay.) +> +> _Files:_ `src/components/AppShell.tsx`, `src/lib/format.ts` + +**Do:** In `src/components/AppShell.tsx`, change `text-red-300` → `text-urgent` (the `--color-urgent` token generates this utility). +**Verify:** `grep -rn "red-300" src/` returns nothing. + +### Step 1.3 — Global `:focus-visible` + select focus styles + +Two confirmed findings, one fix: + +> **Audit finding (confirmed · impact: high · effort: small) — No :focus-visible treatment anywhere; selects remove outline with no replacement** +> +> grep finds zero `focus-visible` usages in src/, and src/index.css defines no focus rule. Text inputs at least pair `outline-none` with `focus:border-accent/50` (per DESIGN.md:314), but the three `s (QuickPostBar, NewPostDialog, SpacePage). +**Verify:** `grep -rn "outline-none" src/ | grep -v focus` — every remaining hit must sit next to an explicit focus treatment or the global rule must cover it. + +### Step 1.4 — Resolve tracked-uppercase labels + +> **Audit finding (confirmed · impact: medium · effort: small) — Tracked-uppercase labels beyond the one sanctioned agent badge** +> +> DESIGN.md says labels use sentence case, never tracked all-caps (lines 223-224), names the agent badge 'the one sanctioned uppercase' (line 308), and its Don't list bans 'tracked all-caps eyebrow kickers' (line 370-371). Violations in app chrome: src/components/AppShell.tsx:80 ('your queue' header: text-[11px] tracking-wide uppercase), src/components/UserSwitcher.tsx:41 (section header: tracking-wide uppercase), src/routes/FlashExperimentsPage.tsx:108 (uppercase tracking-[0.2em] accent eyebrow — the exact banned pattern, on a real route not inside src/flashExperiments/) and :214 (uppercase tracking-wider meta row), and src/components/UserRoleTag.tsx:16 (role tag rendered uppercase+tracked, though DESIGN.md line 224 specifies role badges as sentence case: 'Owner'). Fix: drop `uppercase tracking-*` from these five sites and use sentence-case label styling (text-[11px] font-medium text-muted); keep uppercase only in src/components/AgentTag.tsx. +> +> _Files:_ `src/components/AppShell.tsx`, `src/components/UserSwitcher.tsx`, `src/components/UserRoleTag.tsx`, `src/routes/FlashExperimentsPage.tsx` + +**Do:** Either lowercase the "your queue" / UserSwitcher / FlashExperimentsPage eyebrow labels (spec-compliant), **or** add a sanctioned "rail label" style to DESIGN.md §5 and keep them. Pick one and apply it everywhere — don't leave the split. If you keep uppercase anywhere, coordinate with step 7.1. +**Verify:** `grep -rn "uppercase" src/` — every hit is either the agent badge or the newly sanctioned style. + +### Step 1.5 — Fix "home" nav active state + +> **Audit finding (confirmed · impact: high · effort: small) — Sidebar 'home' nav link shows as active on every route** +> +> In /Users/m3/Developer/postwork/src/components/AppShell.tsx:21-23 and :34-42, the 'home' link's active state is computed solely from `!feedPriority` (location.search.priority), with no pathname check. On /spaces, /agents, /orgs, /posts/$postId, etc., search.priority is undefined, so 'home' receives the ACTIVE class simultaneously with the router-driven `[&.active]` highlight on the actual current page (AppShell.tsx:56) — two nav items lit at once everywhere except the priority feed. Fix: also select `s.location.pathname` in useRouterState and apply ACTIVE only when pathname === '/' (home: pathname==='/' && !priority; priority: pathname==='/' && priority==='urgent'), or use TanStack Link's activeProps with `activeOptions={{ exact: true, includeSearch: true }}` so all six nav items share one active mechanism instead of the current split (manual class for home/priority, `[&.active]` selector for ROUTE_NAV). +> +> _Files:_ `src/components/AppShell.tsx` + +**Do:** Prefer the single-mechanism fix: use TanStack `Link` `activeProps`/`activeOptions={{ exact: true, includeSearch: true }}` for all six nav links, deleting the manual `ACTIVE` constant and the `useRouterState` priority selection. +**Verify:** typecheck; read the rendered logic — exactly one nav item can be active for `/`, `/?priority=urgent`, and each ROUTE_NAV path. + +### Step 1.6 — Make the "+ new post" CTA actually start a post + +> **Audit finding (confirmed · impact: high · effort: small) — '+ new post' primary CTA is a dead link — it only navigates to '/'** +> +> The shell's single primary action (AppShell.tsx:63-68) is a `+ new post` with no onClick and no composer trigger. Clicking it from any page just navigates to the feed; the user must then separately discover the QuickPostBar peek at the bottom. Meanwhile NewPostDialog (src/components/NewPostDialog.tsx) is a fully built new-post modal that the main app never mounts — it is only imported by two flash experiments (src/flashExperiments/experiments/wide-review-shell.tsx:4, centered-rail.tsx:4). Fix: either (a) have the CTA open NewPostDialog via local state in AppShell, or (b) keep the navigation but pass intent (e.g. search param `compose: true` or shared context) that FeedPage uses to call QuickPostBar's reveal() and focus the textarea. Per DESIGN.md 'one primary action per view', the wine-filled button must actually start a post. +> +> _Files:_ `src/components/AppShell.tsx`, `src/components/NewPostDialog.tsx`, `src/components/QuickPostBar.tsx` + +**Do:** Option (a) is cleaner given `NewPostDialog` already exists and is unused by the main app: hold `open` state in `AppShell`, render `NewPostDialog` there, and make the CTA a `