# Implementation Guide — Multiple Blogs per Name (Plan A) _Generated 2025-08-21_ > **AAA mode:** Actionable, artifact-oriented, and accessible. This guide defines exact files to create or modify, contracts, a11y rules, redirects, and tests. --- ## Contracts & Routing ### Entities (unchanged) - **Name**: can own multiple **Blog** records. - **Blog**: immutable link to its **Name**; addressed by `blogHandle` (aka blog id). - **Post**: 1..1 Blog (unchanged). ### Routes (final form) - `/{name}` - If **hasExactlyOneBlog(name)** → redirect (replace history) to `/{name}/{blog}/posts` (or current posts index). - Else → route to **User Blogs** page (`/{name}/blogs`). - `/{name}/blogs` → **User Blogs** page (new). - `/{name}/{blog}/*` → **existing blog-scoped routes** (unchanged). **Assumption:** Existing default blog landing is `/{name}/{blog}/posts`. Adjust if your index differs. --- ## UI Changes ### 1) Header/Nav — “My Blogs” dropdown **File:** `src/components/HeaderNav.tsx` (or equivalent container for auth controls) - Replace single “My Blog”/“Create Blog” button with a **dropdown** (same pattern as the Authenticate/Names dropdown). - **State inputs:** - `currentUser?: NameRef` - `userBlogs: BlogSummary[]` — list of blogs for current user (title + `blogHandle`). - `onCreateBlog: () => void` - `onSelectBlog: (blog: BlogRef) => void` → navigates to `/{name}/{blog}/posts` and updates active blog in store. - **Label logic:** - `userBlogs.length === 0` → show **Create Blog** (primary button), hide dropdown caret. - `userBlogs.length >= 1` → show **My Blogs** with caret; open menu on click. - **Menu content (for ≥1):** - One item per blog: `Title · blogHandle` (truncate cautiously). Current active shows a ✓. - Divider - **Create new blog** (button-like menu item). - **A11y:** - Use proper `role="menu"`/`menuitem"`, `aria-haspopup="menu"`, `aria-expanded`, `aria-controls`. - Focus returns to the trigger on close. ESC closes. - Ensure high-contrast tokens; respect `prefers-reduced-motion` (no aggressive animation). > **Note:** If a reusable `BlogSwitcher` exists, consider refactoring the above as a `UserBlogSwitcher` variant that consumes the current user’s blogs. ### 2) User Blogs page (new) **File:** `src/pages/UserBlogs.tsx` (or `src/routes/UserBlogs.tsx`) - **H1:** “{name}’s Blogs” - **List:** Card per blog with title, `blogHandle`, optional description/meta. - **Actions:** - View → pushes route `/{name}/{blog}/posts`. - If `currentUser.nameId === params.nameId` → buttons: **Edit**, **Create Blog** (page-level CTA). - **Empty states:** - Self (0 blogs): “You don’t have any blogs yet.” + prominent **Create Blog**. - Other user (0 blogs): “No public blogs yet.” **A11y:** One H1, list semantics, buttons with explicit labels (include blog title in `aria-label`). --- ## Store & Data ### Selectors - `selectCurrentUser()` → NameRef / undefined. - `selectUserBlogs(nameId)` → BlogSummary[] (RTK Query / slice selector). - `selectActiveBlog()` → BlogRef | null (unchanged). ### RTK Query (if missing) - **Endpoint:** `listBlogsByName(nameId)` - Cache key: `['blogs', nameId]`. - Normalized response: `BlogSummary[]` (include `blogHandle`, `title`). - Consumers: - Header dropdown fetches `listBlogsByName(currentUser.nameId)` when authenticated. - `UserBlogs` page fetches `listBlogsByName(params.nameId)`. > If an endpoint already exists, reuse it. Otherwise add it in `src/store/api.ts` with blog-scoped cache keys documented in Q-Blog. --- ## Routing Changes **File:** `src/router.tsx` (or where routes are defined) 1. Add route for `/{name}/blogs` → `UserBlogs` component. 2. Update `/{name}` route element to perform **smart redirect**: - On mount: - Fetch `blogs = listBlogsByName(params.nameId)`. - If `blogs.length === 1` → `navigate('/{name}/' + blogs[0].blogHandle + '/posts', { replace: true })`. - Else → `navigate('/{name}/blogs', { replace: true })`. - **Loading state:** show a lightweight spinner/skeleton with polite live region (“Loading blogs…”). > Keep deep links to `/{name}/{blog}/…` working as today. --- ## Permissions - Editing controls on `UserBlogs` are visible only when viewing your own Name (`currentUser.nameId === params.nameId`). - All blog/post routes retain existing guards (no change). --- ## Edge Cases & Behavior - **0 blogs (self):** Header shows **Create Blog**. `/{name}` goes to `/blogs` with the “create” empty state. - **1 blog:** Header shows **My Blogs**; menu has one item + “Create new”. Name root redirects to that blog. - **N blogs (large):** Menu scrolls; consider collapsed ids or optional search later (not part of this change). - **Anonymous viewer:** Can see another user’s `/{name}/blogs`, no edit/create controls. - **Broken blogHandle:** If a listed blog 404s, show error boundary as today; menu item should not break the app. --- ## Accessibility Requirements - Dropdown follows WAI-ARIA menu pattern; keyboard: ArrowUp/Down, Enter, ESC. - Focus management: opening menu moves focus to first item; closing returns to trigger. - Single H1 on `UserBlogs`. Buttons have informative `aria-label`s (“View blog Alpha (alpha)”). - Live region only for the brief loading text on Name root redirect; no chatty updates. --- ## Testing Plan (Vitest + RTL + user-event + MSW + jest-axe) > If MSW/jest-axe aren’t present yet, include unit tests now and add MSW/a11y smoke later. Aim to cover behavior, not visuals. 1. **Header/Menu** - 0 blogs → shows “Create Blog” button, no dropdown. - 1+ blogs → shows “My Blogs”; opens menu; selecting a blog navigates to proper route and sets active blog. - Keyboard navigation: items reachable via arrows; ESC closes; focus returns to trigger. 2. **Name Root Redirect** - 1 blog → navigates to blog posts route (replace history). - 0 or >1 → navigates to `/{name}/blogs`. - Loading state renders politely with an `aria-live="polite"` region text. 3. **UserBlogs Page** - Renders list matching `listBlogsByName` response. - Self-view shows Edit + Create; other-view hides them. - Empty states render correct CTAs/messages. 4. **A11y/axe Smoke** - Header and `UserBlogs` produce no critical axe violations. --- ## Step-by-step Changes (files) 1. **Create:** `src/pages/UserBlogs.tsx` - Functional component rendering list of blogs by `params.nameId`. - Uses RTK Query selector/hook to fetch `listBlogsByName`. - Buttons: View, (conditional) Edit; page-level Create when self. 2. **Modify:** `src/router.tsx` - Add `/{name}/blogs` route. - Update `/{name}` route element to perform smart redirect based on fetched blog count. 3. **Modify:** `src/components/HeaderNav.tsx` - Replace single button with dropdown: - When 0 blogs → primary **Create Blog** button. - When ≥1 blogs → **My Blogs** dropdown listing blog items + “Create new blog”. - Wire `onSelectBlog` to navigate and set active blog; wire `onCreateBlog` to current flow (dialog/route). 4. **Add (if missing):** `src/store/api.ts` - Endpoint: `listBlogsByName(nameId)` with `providesTags: (nameId) => ['blogs', nameId]` or equivalent. - Types: `BlogSummary` (title, blogHandle). 5. **Tests:** - `tests/routes/UserBlogs.test.tsx` - `tests/components/HeaderNav.multiblog.test.tsx` - `tests/routes/NameRootRedirect.test.tsx` 6. **Docs:** - Link this guide from `docs/USER_JOURNEYS.md` and `docs/ARCHITECTURE.md` after merge. --- ## Acceptance Criteria - Header shows **Create Blog** when user has 0 blogs; shows **My Blogs** dropdown when ≥1. - Selecting a blog from the dropdown navigates to `/{name}/{blog}/posts` and marks it active. - Visiting `/{name}`: - If exactly one blog exists → silently lands on that blog’s posts list. - Else → shows `/{name}/blogs` page listing all blogs for that Name. - `UserBlogs` page renders with a single H1, accessible list, and correct empty states. - Unit/component tests pass; a11y smoke has no critical issues. - No regressions to existing blog-scoped routes; deep links remain valid. --- ## Rollout Notes - No data migrations; purely UI/routing. - If telemetry exists, consider logging menu usage and redirect outcomes to validate adoption. - Update release notes after merge.