Skip to content

UI Component Library Sprint Plan — Cross-Functional War Room Output

Context: ~405 issues from UI Component Library Deep-Dive across 98 files (40 ui/ + 34 custom/ + 15 editor/ + 9 layout/) — ~13,700 LOC Generated: 2026-04-13 | Method: Cross-Functional War Room (PM + Engineer + Designer) Participants: PM (user impact), Engineer (effort/risk), Designer (UX/a11y impact) Related: Deep-Dive: UI Component Library | Attack Scenarios Severity Snapshot: 9 Critical · 40 High · ~160 Medium · ~200 Low · 14 confirmed dead files (~2,400 LOC)


Triage Summary

Bucket Criteria Count Effort
Ship Blocker Crashes, XSS, broken-by-default primitives, invisible UI, core a11y breakage. 18 ~4 hrs
Sprint 1 Accessibility floor, modal consolidation, interchangeable-component unification, dark-mode failures, Permission/Form correctness. 22 ~20 hrs
Sprint 2 Dead code removal, component dedup, token cleanup, editor hardening, documentation, test setup. 24 ~22 hrs
Backlog Typos, dead imports, stale comments, micro-inconsistencies, layering relative → alias imports. 30+ Ongoing

North Star: Every component in src/components/ renders correctly, passes keyboard/screen-reader checks, has a single canonical import path per responsibility, and no consumer reaches for a native element because the library wrapper is broken.


Ship Blockers (Pre-Release Gate)

Timeline: ~4 hours, 1 developer Exit criteria: Zero crashes on default props. No XSS in editor embeds. All 9 deep-dive Criticals (C1–C9) closed. Customer-visible broken UI fixed. Progress: 18 / 18 resolved ✅ — C1, C2a, C2b, C3, C4, C5, C6, C7, C8a, C8b, C8c, C9, SEC-1, SEC-2, UI-1, UI-2, UI-3, UI-4, UI-5, UI-6, UI-7 all landed. SEC-1 is in enforcing mode (non-allowlisted resources blocked). All exit criteria for the Pre-Release Gate are met.

Order Ref Description File Change Effort
1 C1 Fix hooks-after-conditional-return crash in toolbar CustomToolbar.jsx:26 Move all useState/useEffect calls above the early if (!editor) return null. Violates Rules of Hooks → crashes on mount when editor is nil. 10 min
2 C2a Remove case "small" fallthrough in custom input custom/input.jsx:42 Add explicit return or correct the switch — small currently falls through to default with h-0, rendering the input invisible. 10 min
2b C2b Replace h-0 default height custom/input.jsx Change h-0h-10 (match ui/input). Until fixed, any consumer without explicit size gets a zero-height input. 5 min
3 C3 Sanitize/allowlist iframe embed URLs + add sandbox editor/blocknote/IframeEmbed.jsx Add allowlist = [youtube.com, vimeo.com, loom.com, figma.com], reject non-https:, set sandbox="allow-scripts allow-same-origin allow-presentation", drop deprecated frameBorder. 30 min
4 C4 Stop router.push() during render in permission wrapper custom/permission-wrapper.jsx:41 Wrap redirect in useEffect(() => { if (!allowed) router.push(fallback); }, [allowed]). Fixes "Cannot update during render" warnings and unblocks SSR. 15 min
5 C5 Delete createEditor.jsx (dead + hardcoded external API) editor/createEditor.jsx Zero consumers, points at landing-pages-shoshin-tech.onrender.com ("articals" typo), leaks console.log(accessToken). Delete file + Redux import. 5 min
6 C6 Remove CSS class from data-slot attribute ui/popover.jsx:17 Change data-slot="popover-trigger !shadow-none"data-slot="popover-trigger" and move !shadow-none into className. 2 min
7 C7 Guard pricing-plan crash on empty data custom/pricing-plan.jsx:97 Replace data?.tabs[0]?.id with Array.isArray(data?.tabs) ? data.tabs[0]?.id : undefined. Component default is data = [] — current code throws on every unconfigured usage. 10 min
8 C8a Define gold color tokens in @theme inline Sass-boilerplate-frontend-v1/src/app/globals.css Add --color-gold: var(--gold); (+ define --gold in :root / .dark). Without this, 16 uses of text-gold/bg-gold/border-gold silently render invisible across tabs, multi-select, select, blog cards, blog-form focus rings, profile page. Resolved by aliasing gold → primary (all *-gold classes renamed to *-primary). 10 min
8b C8b Add --color-light-50 mapping globals.css @theme inline Add --color-light-50: var(--light-50); — CSS var exists but Tailwind doesn't generate the utility. Breaks 10 files (15 uses) including textarea, file-upload, date-picker descriptions. Sprint-plan typo — actual token is light-5, already mapped. No fix needed. 2 min
8c C8c Dedup --color-light and --card globals.css --color-light duplicated on lines 42-43 of @theme inline; --card defined twice in :root (lines 63 and 92). Remove duplicates. 5 min
9 C9 Rewrite ConfirmationModal on top of ui/alert-dialog custom/confirmation-modal.jsx Replaces the zero-a11y custom modal for the 14 highest-risk consumers (all destructive delete/restore flows). AlertDialog blocks Escape dismiss, has focus trap + role="alertdialog". Keep the existing prop shape so consumers don't change. 1 hr
10 SEC-1 Add security headers (next.config.mjs) next.config.mjs Closes KC1 + KC3. Add Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy. Shipping in enforcing mode — any non-allowlisted resource is blocked outright. 30 min
11 SEC-2 Add rel="noopener noreferrer" to footer-credit external link layout/footer-credit.jsx target="_blank" without rel → tabnapping. 1-line fix on a component that appears on every dashboard page. 1 min
12 UI-1 Fix truncated class in header skeleton layout/header-skeleton.jsx size-9 -fullsize-9 rounded-full. Skeleton circles currently render as squares, visible to every unauthenticated visitor on slow loads. 1 min
13 UI-2 Fix broken gradient in breadcrumb2 custom/breadcrumb2.jsx to gray-50to-gray-50 (missing hyphen). Gradient silently drops — used by 4 consumers. 1 min
14 UI-3 Remove debug logs + plaintext secret logs multiple code-box.jsx, TextEditor.jsx, createEditor.jsx, BlocknoteEditor.jsx — strip console.log of user content. 5 min
15 UI-4 Fix TimePicker icon logic inversion custom/date-inputs.jsx Icon shows "AM" when time is PM and vice versa — inverted ternary. Every user-facing time picker affected. 5 min
16 UI-5 Add "use client" to custom/input.jsx + ui/textarea.jsx both files Both call hooks without the directive. Works today only because every parent is a client component — one server-component import will throw. 2 min
17 UI-6 Remove dangerouslySetInnerHTML from chart.jsx tooltip OR sanitize ui/chart.jsx Recharts tooltip content path renders HTML from data — switch to text interpolation unless sanitizer is added. 15 min
18 UI-7 Fix chart.jsx zero-value rendering ui/chart.jsx {item.value && <span>{item.value}</span>} hides value when it's 0. Change to {item.value != null && ...}. Visible on every analytics dashboard tooltip. 5 min

Ship Blocker Verification Checklist

  • Editor opens without React error boundary firing (C1)
  • <Input size="small" /> renders with visible height (C2)
  • Iframe embed rejects javascript: URL and non-allowlisted domains (C3)
  • Unauthorized page redirect happens without "Cannot update during render" console warning (C4)
  • createEditor.jsx file deleted, no broken imports (C5)
  • Popover renders with shadow removed via className (C6)
  • <PricingPlan /> with no data prop does not throw (C7)
  • Selected tab shows gold underline; selected multi-select chip shows gold background (C8)
  • All destructive confirmation modals (delete user, delete blog, delete role, etc.) are focus-trapped and Escape-blocked (C9)
  • curl -I on any page returns Content-Security-Policy, X-Frame-Options, Referrer-Policy headers (SEC-1, enforcing mode)
  • Header skeleton circles are round (UI-1)
  • Breadcrumb2 gradient renders on page detail views (UI-2)
  • No console.log output from /editor or /blogs/create during blog authoring (UI-3)
  • TimePicker icon matches selected time (UI-4)
  • Dashboard KPI chart tooltip displays 0 values (UI-7)

Sprint 1: Accessibility Floor + Component Unification (Week 1)

Timeline: ~20 hours, 1 developer North Star: Keyboard users can complete every core flow (login, create blog, create user, delete record). There is exactly one canonical Button, one canonical Input, one canonical modal system. Dark mode works everywhere. Exit criteria: axe-core audit of login/dashboard/blog-create/users pages returns 0 criticals. Pages consume only canonical components.

Progress: 4 / 4 resolved ✅ — S1-1, S1-2, S1-3, S1-4 all landed. custom/modal.jsx has focus trap, scroll lock (with stacked-modal counter + scrollbar-gutter compensation), aria-labelledby via useId + ModalLabelContext, and isClosingRef guard. custom/dialog-modal.jsx is the Radix-backed drop-in with matching Modal/ModalHeader/ModalBody/ModalFooter API, max-h-[calc(100dvh-2rem)] flex flex-col viewport cap, and self-contained ModalBody scroll (flex-1 min-h-0 overflow-y-auto). All 3 hand-rolled inline modals migrated to DialogModal.

# Ref Description File(s) Effort
S1-1 Modal-a11y Added role="dialog", aria-modal="true", focus-trap-react focus trap, Escape-to-close, and scroll lock to custom/modal.jsx. Scroll lock uses a module-level counter for nested modals and compensates for scrollbar width so the page doesn't jump when a modal opens. aria-labelledby is wired via useId + ModalLabelContextModalHeader now accepts children in addition to title and auto-stamps the id. Consumers without a header can pass ariaLabel on Modal. custom/modal.jsx 2 hrs
S1-2 DialogModal wrapper Created custom/dialog-modal.jsx on @radix-ui/react-dialog primitives (not ui/dialog directly, so the animation/styling layer is fully owned). Preserves the Modal/ModalHeader/ModalBody/ModalFooter API, the bg-black/50 backdrop-blur-[3px] backdrop, rounded-2xl bg-background shadow-xl content, 300ms fade+slide+zoom motion, and both primaryCloseButton variants. Content is a flex flex-col column capped at calc(100dvh-2rem), and ModalBody is the primary scroll region (flex-1 min-h-0 overflow-y-auto) so tall content scrolls internally while header/footer stay pinned. ModalHeader wraps its text in DialogPrimitive.Title asChild so the visible header is the accessible name. custom/dialog-modal.jsx 1.5 hrs
S1-3 Migrate 3 hand-rolled inline modals html-export-modal.jsx, html-import-modal.jsx, and ai-integration/components/ListeningModal.jsx all now use DialogModal. Hand-rolled fixed inset-0 overlays, hand-rolled X buttons, and ad-hoc focus/escape handling removed. Non-standard sizes/radii preserved via contentClassName (max-w-4xl rounded-lg bg-card, max-w-3xl rounded-xl bg-card, max-w-md rounded-3xl bg-card p-8); ListeningModal's darker overlay preserved via overlayClassName. Scrollable bodies (export code listing, import paste form) use ModalBody so content scrolls internally without breaking the viewport cap. ListeningModal's floating transcript stays as a sibling element outside the portal to preserve its viewport-fixed positioning. html-export-modal.jsx, html-import-modal.jsx, ListeningModal.jsx 1 hr
S1-4 Fix double-close race in modal Added isClosingRef guard to custom/modal.jsx handleClose. Reset to false in the open branch of the isOpen effect so reopens work cleanly. Ref (not state) to avoid re-renders and stale closures. Repro: Escape keydown schedules onClose at t+300ms, user clicks backdrop mid-animation → second handleCloseonClose fires twice. Now guarded to single-fire. custom/modal.jsx 30 min

Canonical Component Unification (4 hrs)

Progress: 6 / 6 resolved ✅ — S1-5, S1-6, S1-7, S1-8, S1-9, S1-10 all landed. custom/button is documented as the canonical interactive button and the duplicate secondary / orphan primary variants are gone. ui/button gets a visible focus-visible ring on every variant. Blog form title/slug are now ui/Input. The three raw checkboxes in login / users / blogs are now Controller-wrapped custom/Checkbox. ui/textarea accepts a forwarded ref via the React 19 ref-as-prop pattern (merged with internal auto-resize ref), and both ui/textarea + custom/textarea expose className instead of the old textareaClass prop.

# Ref Description File(s) Effort
S1-5 Button canonicalization Added a canonical doc comment at the top of custom/button.jsx directing consumers to prefer it over ui/button for user-facing actions. Removed the duplicate secondary key (the second one overwrote bg-secondary with a gray from an old palette) and the orphan primary key (not in the JSDoc variant list; only referenced in a commented-out line and the JSDoc example, which was updated to default). custom/button.jsx 1 hr
S1-6 Button a11y — focus-visible ring Added focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background to the base buttonVariants cva string so every ui/button variant now shows a visible focus ring for keyboard users. Per-variant destructive / success override rules still work (cva cascades). ui/button.jsx 30 min
S1-7 Input canonicalization Blog form title/slug now use ui/Input (aliased as BareInput to avoid collision with the existing custom/Input import) instead of raw <input>. Borderless/bold visual preserved via class overrides — border-0 bg-transparent px-0 h-auto shadow-none focus-visible:ring-0 focus-visible:border-0. Title input also gets aria-invalid on validation errors. blogs-form.jsx 1 hr
S1-8 Checkbox canonicalization The three raw <input type="checkbox"> sites (login Remember Me, user-form isInviteOnly, blogs-form status==='published') are now Controller-wrapped custom/Checkbox (Radix-based, so a native register() won't work — explicit checked + onCheckedChange via Controller). Label text is passed via label prop; labelClass override preserves the original text tone. The blogs-form status Controller translates checked"published" \| "draft" inside onCheckedChange. login.jsx, user-form.jsx, blogs-form.jsx 45 min
S1-9 Textarea ref forwarding ui/textarea.jsx now accepts ref as a React 19 prop and merges it with the internal useRef used for autoResize logic via a useCallback ref function (both external callback refs and ref objects supported). custom/textarea.jsx also threads a ref prop through to the underlying ShadCnTextarea. RHF register() and <Controller> both now get a real handle to the DOM node. ui/textarea.jsx, custom/textarea.jsx 30 min
S1-10 Textarea className rename textareaClass prop → className in both ui/textarea and custom/textarea. The only external consumer (form-elements basic-input demo page) was updated to pass className. Aligns with every other canonical wrapper that uses the standard className slot. ui/textarea.jsx, custom/textarea.jsx, form-elements/basic-input/page.js 15 min

Form Field Accessibility (3 hrs)

Progress: 4 / 4 resolved ✅ — S1-11, S1-12, S1-13, S1-14 all landed. All 6 form wrappers (input, checkbox, textarea, radio, date-picker, file-upload) now expose dynamic ${id}-error / ${id}-description IDs, wire aria-invalid / aria-required / aria-describedby onto the underlying control (or a role="group" wrapper for compound date pickers), render description AND error simultaneously, show the accessible password toggle button with aria-pressed, and the file-upload has real onDrop / onDragOver / onDragLeave drag-and-drop with a border-primary bg-primary/5 drag-over state.

# Ref Description File(s) Effort
S1-11 aria-describedby + aria-required Added to all 6 form wrappers — input, checkbox, textarea, radio, date-picker, file-upload. Each wrapper now computes errorId = ${id}-error, descriptionId = ${id}-description, and describedBy = [errorId, descriptionId].filter(Boolean).join(' '), then passes aria-invalid, aria-required, aria-describedby through to the underlying control. date-picker can't forward ARIA to the compound shadcn primitive set (ShadCnDatePicker / TimePicker / DateTimePicker / DateRangePicker / MonthPicker), so it wraps the rendered date UI in a role="group" with aria-labelledby={labelId} pointing to the main <Label id>, plus the same aria-describedby / aria-invalid / aria-required on the group. Error <p> elements now use the dynamic errorId instead of the old hardcoded textarea-error / file-upload-error / etc. custom/input.jsx, custom/checkbox.jsx, custom/textarea.jsx, custom/radio.jsx, custom/date-picker.jsx, custom/file-upload.jsx 1.5 hrs
S1-12 Show description AND error Flipped {!error && description && <p>...}{description && <p>...} in all 6 form wrappers so description stays visible when validation fires. Error renders first (above description) to match natural reading order: error message, then the clarifying context. Sprint plan only named checkbox/textarea/date-picker, but input, radio, and file-upload had the identical !error && description gate, so they're all fixed for consistency. custom/input.jsx, custom/checkbox.jsx, custom/textarea.jsx, custom/radio.jsx, custom/date-picker.jsx, custom/file-upload.jsx 30 min
S1-13 Password toggle keyboard a11y Replaced the <Eye> / <EyeOff> onClick on the raw SVG with a real <button type="button"> — now focusable, Space/Enter-activated, and announces state to screen readers via aria-pressed={showPassword} + aria-label={showPassword ? "Hide password" : "Show password"}. Icons marked aria-hidden="true". focus-visible:ring-2 focus-visible:ring-ring ensures keyboard users see where focus lands. custom/input.jsx 15 min
S1-14 file-upload.jsx drag-and-drop wired for real Extracted file-ingestion logic into a shared ingestFiles(rawFiles) helper, wrapped by a thin handleFileChange(event) for the <input> change. Added handleDrop / handleDragOver / handleDragEnter / handleDragLeave handlers with an isDragging state. Both the box-style and input-style labels now call those on drop events, with a visible border-primary bg-primary/5 feedback state while dragging. Dropped files flow through the same preview generation + onChanges callback + synthetic-event compat shim as the input onChange path, so consumers see identical behavior whether files arrived via browse or drop. DataTransfer files ignored when disabled. custom/file-upload.jsx 45 min

Interactive Component A11y (4 hrs)

Progress: 5 / 5 resolved ✅ — S1-15, S1-16, S1-17, S1-18, S1-19 all landed. The MultiSelect in custom/select.jsx now exposes a full ARIA combobox + listbox with keyboard navigation (Arrow keys, Home/End, Enter, Escape, Tab) and filters/displays by label rather than the opaque value. ui/loading.jsx is now a role="status" live region with an sr-only label. All 3 carousel variants in custom/carousel.jsx get keyboard arrow-key navigation, pause-on-hover/focus, focusable thumbnails, ARIA carousel semantics, and token-based arrow colors that survive dark mode. The tooltip's per-instance TooltipProvider was hoisted to a single shared <TooltipProvider delayDuration={200}> in the root layout, and the white-on-white light-mode contrast is fixed by switching to bg-popover text-popover-foreground semantic tokens.

# Ref Description File(s) Effort
S1-15 MultiSelect keyboard nav + ARIA The MultiSelect trigger is now a role="combobox" div with tabIndex={0}, aria-haspopup="listbox", aria-expanded, aria-controls={listboxId}, and aria-activedescendant pointing at the highlighted option. The dropdown <ul> carries role="listbox" + aria-multiselectable + aria-labelledby={triggerId}, and each option is a role="option" with aria-selected. Trigger keys (Enter/Space/ArrowDown) open the dropdown; once open, search input handles ArrowDown/Up/Home/End to move activeIndex, Enter to select the active option, Escape to close (focus returns to trigger), Tab to close and continue. useId() generates stable trigger/listbox/option IDs. Tag X buttons get aria-label={Remove ${item.label}}; clear button gets aria-label="Clear selection". (Sprint plan referenced a custom/multi-select.jsx that doesn't exist — the only multi-select implementation is the MultiSelect export in custom/select.jsx, so all the work landed there.) custom/select.jsx 2 hrs
S1-16 MultiSelect search on label + tag-by-label Filter changed from option.value.toLowerCase().includes(...) to option.label.toLowerCase().includes(...) so typing "admin" actually finds the role labelled "Admin" instead of the UUID-style value. Tag display in the trigger now renders {item.label} instead of {item.value}, so users see human-readable role/tag names instead of ID strings. custom/select.jsx 30 min
S1-17 Loading a11y ui/loading.jsx now has role="status" + aria-live="polite" + aria-label={label} (default "Loading", configurable via prop) on the wrapper. The decorative anim-box gets aria-hidden="true". An sr-only <span> carries the label text so screen readers announce a name even when the announce timing depends on text content. (Sprint plan referenced custom/loading.jsx — the actual file is at ui/loading.jsx. ui/spinner.jsx was already correctly tagged with role="status" + aria-label="Loading".) ui/loading.jsx 15 min
S1-18 Carousel keyboard + pause-on-hover + dark-mode arrows All 3 variants (ImageCarousel, HeroCarousel, CustomCarousel) get: role="region" + aria-roledescription="carousel" + aria-label on the root, tabIndex={0} so the region is focusable, onKeyDown handler for ArrowLeft/ArrowRight slide navigation, and onMouseEnter/onMouseLeave/onFocus/onBlur setting an isPaused state that's threaded into the autoplay useEffect deps so the interval pauses when the user is reading. Prev/next arrow buttons get type="button" + aria-label="Previous slide" / "Next slide". Dot/thumb navigation is now role="tablist" with each dot/thumb as role="tab" + aria-selected + aria-current + visible focus ring. Hardcoded fill-black/50 (ImageCarousel) and fill="#0095FF" (HeroCarousel) replaced with fill-current text-foreground/70 and fill-current text-primary so dark mode flows through. HeroCarousel and CustomCarousel thumbnail <Image> elements are now wrapped in real <button> elements, finally making them focusable + Enter/Space activatable. custom/carousel.jsx 1 hr
S1-19 Tooltip single shared Provider + light-mode fix Tooltip no longer wraps children in a per-instance TooltipProvider — that caused every tooltip to start its own delay coordination scope. A single <TooltipProvider delayDuration={200}> is now mounted once in src/app/layout.js inside ThemeProvider, so all tooltips share one delay/skip-delay context and grouped-hover behavior actually works. TooltipContent styling switched from the broken bg-white text-primary-foreground (white-on-white in light mode because primary-foreground is light) to bg-popover text-popover-foreground border border-border, which uses the canonical shadcn semantic tokens that flip cleanly between light and dark. The arrow inherits the same popover tokens. ui/tooltip.jsx, app/layout.js 30 min

Permission Wrapper Correctness (2 hrs)

Progress: 3 / 3 resolved ✅ — S1-20, S1-21, S1-22 all landed. The triple-nested blog.create / blog.update checks (page → page component → form component) are collapsed to a single outermost wrapper in app/(dashboard-layout)/dashboard/blogs/{create,[slug]}/page.js. PermissionWrapper and NavPermissionWrapper now share a unified mode="any" | "all" prop (default "any"), closing latent bugs in multi-permission consumers that were silently requiring ALL when they meant ANY. The user-form submit button is no longer misgated by role.view — it now uses user.create / user.update per mode. And hasPermission is memoized via useMemo so it only re-runs when the permission input or user permission list actually changes.

# Ref Description File(s) Effort
S1-20 Eliminate triple PermissionWrapper nesting The app/(dashboard-layout)/dashboard/blogs/create/page.js route wrapped BlogCreatePage in <PermissionWrapper permission="blog.create" showError showLoader>, which then wrapped BlogsForm in the same check again, which then wrapped its own rendering in <PermissionWrapper permission={edit ? "blog.update" : "blog.create"}> — three sequential loading spinners and three unauthorized error displays on every blog create/edit load. Same pattern mirrored for the edit route via blog-edit-page.jsx. Kept the outermost wrapper (the app/.../page.js files) and removed the inner two per flow. BlogsForm is now wrapped in a <> fragment instead of PermissionWrapper. app/blogs/create/page.js, app/blogs/[slug]/page.js, blog-create-page.jsx, blog-edit-page.jsx, blogs-form.jsx 45 min
S1-21 Unify ALL vs ANY permission semantics Added mode="any" \| "all" prop to both wrappers, defaulting to "any". PermissionWrapper.hasPermission now takes a mode argument and routes to .some() / .every() accordingly (string permissions ignore mode). NavPermissionWrapper keeps its legacy requireAll prop as a back-compat alias — resolution is mode ?? (requireAll ? 'all' : 'any'). Switching PermissionWrapper's default from ALL → ANY intentionally fixes latent bugs in multi-permission consumers: ["blog.read", "blog.update"], ["blog-category.view", "blog-tag.view"], ["email-template.read", "email-template.update"], ["menu.view", "menu-item.view"] — these all read as "user needs either A or B", and previously a user with only one of them was incorrectly locked out. Single-element arrays are unaffected. Also fixed user-form submit button incorrectly gated by role.view → now mode === "edit" ? "user.update" : "user.create". custom/permission-wrapper.jsx, custom/nav-permission-wrapper.jsx, pages/dashboard/users/user-form.jsx 1 hr
S1-22 Memoize hasPermission call hasPermission is now called once inside a useMemo keyed on [permission, userPermissions, mode]. Previously, PermissionWrapper called it during render AND inside the redirect useEffect's body AND in the early-return branches — up to 4× per render. Memoization collapses this to one computation per input change; the child render path and the redirect effect both read from the same cached value. Negligible on its own but compounds across 50+ render-heavy consumers. custom/permission-wrapper.jsx 15 min

Sprint 1 Acceptance Test

1. Keyboard-only walkthrough:
   Tab through login → dashboard → create blog → create user
   Focus ring visible at every stop. No mouse used.

2. axe-core audit on:
   /login, /dashboard, /blogs/create, /users, /settings/profile
   → 0 critical violations

3. Dark mode:
   Switch theme, verify tabs (gold underline), multi-select chips,
   tooltips, carousel arrows, breadcrumb2 banner all visible.

4. Modal keyboard:
   Open any "Delete" confirmation → Escape does NOT dismiss
   Open any "Edit form" modal → Escape DOES dismiss, focus returns to opener

5. Form validation:
   Trigger error on any form field → screen reader announces error AND
   description (not just error). Required fields announced as required.

6. Multi-select:
   Type "admin" in role multi-select → filters by label
   Arrow keys navigate options → Enter selects → Escape closes

Sprint 2: Clean Foundation (Week 2–3)

Timeline: ~22 hours, 1–2 developers North Star: One component per responsibility. Dead code deleted. Editor no longer depends on private TipTap APIs. First component tests land. Exit criteria: ~14 dead files deleted. advance-table and data-table consolidated. Editor upgrade-safe. Component usage doc published. Test framework wired.

Dead Code Removal (1 hr)

Progress: 2 / 2 resolved ✅ — S2-1, S2-2 all landed. 6 confirmed-dead files deleted. layout/no-scroll-restoration.jsx kept — actively imported in app/layout.js. All 6 review-then-decide files kept as useful.

# Description Files Effort
S2-1 Delete confirmed-dead files Deleted 6 files: ui/author-card.jsx, ui/dashboard-skeletons.jsx, ui/searchable-dropdown.jsx (lowercase), custom/roles-badge-group.jsx, layout/navbar.jsx, editor/blocknote/BlocknoteViewer.jsx. layout/no-scroll-restoration.jsx originally listed as dead but is actively imported in app/layout.js — kept. 30 min
S2-2 Review-then-decide files All 6 kept: ui/alert-dialog.jsx (consumed by C9 ConfirmationModal rewrite), ui/command.jsx (useful primitive), custom/error-page.jsx (useful for error boundaries), custom/success-page.jsx (useful for success states), custom/toggle-switch.jsx (functional), custom/unauthorized-error.jsx (used by permission-wrapper).

Component Deduplication (3.5 hrs)

Progress: 5 / 5 resolved ✅ — S2-3, S2-4, S2-5 implemented. S2-6, S2-7 closed (no work needed). advance-table.jsx deleted (1 consumer migrated to data-table). PaginationView extracted as shared pure component with 0-items guard. heading.jsx unified with variant prop; section-heading.jsx is now a thin wrapper.

# Ref Description Files Effort
S2-3 Consolidate advance-table + data-table Migrated the 1 advance-table consumer (table/advance-table/page.js) to import from data-table. Added className="max-w-none whitespace-normal" on the expanded-row cell to override DataTable's default truncation. Deleted custom/advance-table.jsx (128 LOC). Zero remaining references. data-table is now the single canonical table component (15 production consumers + 1 demo). custom/data-table.jsx 30 min
S2-4 Consolidate PaginationClient + PaginationServer Extracted shared PaginationView pure presentational component and getPageNumbers() utility (both file-private). PaginationClient and PaginationServer are now thin state adapters — Client passes setCurrentPage as onPageChange, Server computes page from URL searchParams and updates via router.push(). Added 0-items guard: both return null when totalItems <= 0. All 19 consumers unchanged (same named exports, same prop interfaces). Eliminated ~170 lines of duplicated JSX. custom/pagination.jsx 45 min
S2-5 Consolidate heading.jsx + section-heading.jsx Added variant="default" \| "section" prop to heading.jsx with variant styles extracted to a module-level variantStyles map. section-heading.jsx is now a thin wrapper: <Heading variant="section">. All 29 consumer imports (15 Heading + 14 SectionHeading) unchanged — fully backward compatible. Skipped polymorphic as prop per YAGNI. ui/heading.jsx, ui/section-heading.jsx 15 min
S2-6 Delete duplicate SearchableDropdown.jsx No duplicate exists. Only one file found: ui/SearchableDropdown.jsx (PascalCase). The supposed lowercase searchable-dropdown.jsx was already deleted or never existed. Component has dark mode support. 1 consumer (demo page). No case-sensitivity collision. No action needed.
S2-7 Merge error-page.jsx + success-page.jsx base Closed — both files have zero consumers (no imports anywhere in the codebase). Kept per S2-2 decision for future use. Different variant counts (8 vs 7), completely different SVG animations — shared base extraction not worthwhile. Variant objects recreated every render (~400 LOC each) is a real perf antipattern but academic with zero consumers; address if/when consumers are added.

Editor Hardening (3 hrs)

Progress: 3 / 3 resolved ✅ — S2-8, S2-9, S2-10 all landed. Private TipTap API access replaced with public BlockNote/stable TipTap equivalents. Editor blocks use CSS custom properties for dark mode. CodeBlock language prop wired to a visible label.

# Ref Description Files Effort
S2-8 Replace private TipTap API access BlocknoteEditor.jsx: Replaced editor._tiptapEditor?._state?.doc?.textContent with public editor.document block-walking text extraction. Removed dead commented-out _tiptapEditor.on("destroy"/"transaction") code. CustomToolbar.jsx: Replaced fragile _state?.history$?.done?.items?.values undo/redo stack tracking + _tiptapEditor.on("transaction") listener with useEditorContentOrSelectionChange hook + stable TipTap can().undo()/can().redo() query. Replaced _tiptapEditor.commands.undo()/redo() with BlockNote public editor.undo()/editor.redo(). Removed unused useEffect import. Fixed duplicate dark:invert class on redo button. BlocknoteEditor.jsx, CustomToolbar.jsx 1 hr
S2-9 Add dark mode to editor blocks Replaced hardcoded light-mode colors with CSS custom properties: Blockquote.jsx #f5f5f5var(--color-muted), #333var(--color-foreground), #ccc border → var(--color-border). Divider.jsx #dddvar(--color-border). Mention.jsx #8400ff33color-mix(in srgb, var(--color-primary) 20%, transparent) for theme-aware highlighting. All fallback to original values if vars are undefined. Blockquote.jsx, Divider.jsx, Mention.jsx 15 min
S2-10 Fix CodeBlock.jsx language prop Wired the existing language prop (preserved in schema for document compat) to render as an uppercase label positioned top-right of the code block. Added dark-mode support via var(--color-muted) / var(--color-foreground) / var(--color-muted-foreground). No syntax highlighter added (would require new dependency) — prop is now visually functional and forward-compatible for a future lowlight/prism integration. CodeBlock.jsx 15 min

Token Cleanup (2 hrs)

Progress: 3 / 4 resolved ✅ — S2-12 already resolved by C8 ship blocker. S2-13, S2-14 implemented. S2-11 descoped — proposed replacement is semantically wrong (see notes).

# Ref Description Files Effort
S2-11 Replace text-light / text-light-4 / shadow-box with shadcn canonical tokens ⚠️ DESCOPED Investigation revealed the proposed replacement is unsafe. text-light = #000000de (87% opacity black, primary body text) ≠ text-muted-foreground = oklch(0.556) (gray-400, subdued text). Blind replacement would make text significantly less readable across 470 occurrences in 91 files. shadow-box is a compound class (border bg-card rounded-lg shadow-[...] dark:border-0), not a simple shadow — replacing with shadow-sm would lose border, bg-card, and rounded styling (58 occurrences, 44 files). The custom tokens ARE defined in globals.css @theme inline, flip correctly for dark mode, and work. The real problem (discoverability for new contributors) should be solved by documentation (S2-27), not mass refactoring with wrong semantics. If alignment with shadcn naming is desired later, use aliases (text-foregroundtext-light logic) rather than replacement.
S2-12 Replace border-gold / bg-gold / text-gold with primary Already resolved during C8 ship blocker work. All *-gold classes were renamed to *-primary. Zero gold class references remain in the codebase.
S2-13 Fix h-68 non-standard class Original target ui/dashboard-skeletons.jsx was already deleted in S2-1. The h-68 class no longer exists in the codebase (the file it was in is gone). No action needed.
S2-14 Relative → alias imports Converted 29 ../ relative imports across 16 files in custom/, layout/, theme-control/, and cross-page directories to @/components/... aliases. 13 remaining relative imports in email-templates/utils/index.js are intentional intra-feature barrel re-exports — left as-is. Lint passes clean. 16 files 10 min

Bug Fixes (4 hrs)

Progress: 9 / 9 resolved ✅ — S2-15, S2-16, S2-17, S2-19, S2-20, S2-21, S2-23 implemented. S2-18 and S2-22 already resolved in prior work.

# Ref Description File Effort
S2-15 analytic-card: color change by sign Added conditional color: String(change).startsWith("-") ? "text-red-600" : "text-green-600". Negative changes now render red, positive/neutral green. custom/analytic-card.jsx 5 min
S2-16 theme-toggle: fix hydration layout shift Replaced return null pre-mount with a disabled placeholder <Button> using the same variant='outline' size='icon' classes and an invisible <Sun> icon (opacity-0). Prevents layout jump on first paint. ui/theme-toggle.jsx 5 min
S2-17 date-inputs: fix onChange-on-mount Added isInitialMount ref guards to both TimePicker and DateTimePicker time-change useEffect hooks in ui/date-inputs.jsx. The effects now skip the first run, preventing synthetic onChange calls on mount that broke RHF dirty tracking. (custom/date-inputs.jsx from the sprint plan doesn't exist — the actual file is custom/date-picker.jsx which delegates to ui/date-inputs.jsx.) ui/date-inputs.jsx 15 min
S2-18 Form labels: add htmlFor Already resolved. custom/date-picker.jsx already has htmlFor={id} on the <Label>, plus aria-labelledby, aria-describedby, aria-invalid, and aria-required (implemented during Sprint 1 form field a11y pass S1-11).
S2-19 card.jsx: CardTitle renders semantic heading CardTitle now accepts as prop (default "h3") and renders the specified element instead of <div>. Consumers can pass as="h2" or as="div" to override. Fully backward compatible — existing consumers get an <h3> where they previously had a <div>. ui/card.jsx 5 min
S2-20 dropdown-menu: bg-card vs bg-popover inconsistency Changed DropdownMenuContent from bg-card to bg-popover, matching DropdownMenuSubContent which already uses bg-popover. Both now use the same semantic token. ui/dropdown-menu.jsx 2 min
S2-21 slider: default thumb count Changed fallback from [min, max] (2 thumbs) to [min] (1 thumb) when neither value nor defaultValue is provided. Simple single-value sliders now render correctly without requiring explicit defaultValue={[0]}. ui/slider.jsx 2 min
S2-22 Fix formatText XSS vector Already resolved. formatText in utils.js already uses <SanitizedHTMLPreview> which wraps DOMPurify for sanitization. The XSS vector described in the sprint plan (raw dangerouslySetInnerHTML) was fixed in the v1→v2 migration.
S2-23 footer-wrapper / header-wrapper: Promise.all + error handling Both wrappers now fetch in parallel via Promise.all instead of sequential await. Added try/catch with graceful fallbacks (empty arrays + empty objects) so a failed API call doesn't crash the layout. header-wrapper: 2 parallel fetches. footer-wrapper: 3 parallel fetches. header-wrapper.jsx, footer-wrapper.jsx 10 min

Documentation + Testing (8.5 hrs)

# Ref Description Effort
S2-24 Component Usage Guide: one-page doc in docs/component-usage.md — "When to use custom/button vs ui/button", "When to use custom/modal vs DialogModal vs AlertDialog", "Which form wrapper has what a11y", canonical import paths. Referenced by every PR template. 2 hrs
S2-25 Set up Vitest + React Testing Library for the frontend repo. Write the first 5 component tests: button (variants + loading), custom/input (all sizes — regression for C2), ConfirmationModal (focus trap, Escape blocked), multi-select (keyboard nav — regression for S1-15), permission-wrapper (redirect via effect — regression for C4). 4 hrs
S2-26 Add Storybook (or lightweight /dev/components route) to showcase every primitive in light + dark + keyboard-focus state. Catches future dark-mode regressions visually. 2 hrs
S2-27 Document custom Tailwind tokens in docs/tailwind-tokens.md. List all text-light*, shadow-box, custom-shadow, and their shadcn equivalents. 30 min

Backlog (Ongoing)

Fix opportunistically when touching adjacent files. No dedicated sprint time needed.

Ref Description Fix When
Typos & naming
B1 custom/navItem.jsx — rename to nav-item.jsx (only camelCase file in the tree) Touching nav code
B2 custom/select.jsxellipsis prop is dead Touching select
B3 custom/radio.jsx — dual callback (onChange + onValueChange) Touching radio
B4 custom/breadcrumb.jsx — rename misleading separator prop Touching breadcrumb
Dead code inside live files
B5 BlocknoteEditor.jsx — dead state isLink, selectedBlocks Editor touch
B6 CustomToolbar.jsx — dead state vars (post-C1 refactor) Editor touch
B7 CustomDragMenu.jsxResetBlockTypeItem exported but unused Editor touch
B8 footer.jsx — duplicate formatLink function, 63 lines commented social code Footer touch
B9 header.jsx — duplicated placeholder header (unmounted branch) Header touch
B10 TextEditor.jsx — artificial 100 ms delay, dead useEffect Editor touch
B11 blocknote/editor-styles.css — excessive !important, duplicate properties CSS touch
B12 layout/footer-skeleton.jsx — trailing spaces in classNames Footer touch
Minor a11y
B13 custom/code-box.jsxaria-label on copy button, children may not be string Touching code-box
B14 custom/skeleton.jsx — hardcoded colors vs bg-muted, no a11y loading role Any skeleton touch
B15 custom/avatar.jsx — no alt text, status indicator invisible to SR Touching avatar
B16 custom/user-avatar-group.jsx — imports TooltipProvider but never uses it; can return undefined Touching group
Editor polish
B17 LinkBlock.jsx — popover opens by default; add URL validation Editor touch
B18 Mention.jsx — no parseHTML → can't round-trip HTML Editor touch
B19 Divider.jsx — parse targets <section> instead of <hr> Editor touch
B20 CustomSlashMenu.jsx — missing ARIA listbox/option roles Editor touch
Misc cleanup
B21 custom/accordion.jsxexpandIconClassName misapplied to trigger not icon Accordion touch
B22 ui/collapsible.jsx — no cn(), no className destructuring Any touch
B23 ui/select.jsx — hardcoded text-black, !py-5 override, w-fit default Select touch
B24 ui/label.jsxtext-light-4 custom class, unused React import Label touch
B25 ui/calendar.jsxbuttonVariant name confusion Calendar touch
B26 custom/permission-badge-group.jsx — 6 minor issues Touching badges
B27 custom/confirmation-modal.jsx — use Button isLoading instead of custom spinner (post-S1-rewrite) Post-Sprint 1
B28 custom/confirmation-modal.jsxonClose not called on confirm path Post-Sprint 1
B29 custom/mobile-navigation.jsx — "Get Started" button does nothing, hardcoded auth links Landing page touch
B30 layout/header.jsx — "Get Started" button has no href/onClick Landing page touch
B31 custom/pricing-plan.jsx — hardcoded 4-col grid, no onClick on Buy buttons Pricing touch
B32 Narrow Next.js image remotePatterns — currently wildcard ** (SSRF risk via image optimization) Next config touch
B33 Landing page hardcoded content in @/constants/landing-page-data — migrate to backend like nav Future CMS feature

Key Decisions Log

Decision PM Engineer Designer Outcome
Fix order for ship blockers Visible crashes first, then XSS, then invisible gold Crashes are 5-min fixes; XSS needs allowlist thought Invisible UI loses trust Crashes → XSS → tokens → dead code
custom/modal rewrite OR ConfirmationModal first ConfirmationModal — destructive actions are highest risk Same — one file change covers 14 consumers Destructive confirmations most critical for a11y ConfirmationModal first (Ship Blocker C9), modal a11y in Sprint 1
Delete alert-dialog.jsx OR keep Keep — S1-C9 rewrite consumes it Keep Keep — it's the correct primitive Keep
Gold tokens: alias to primary or define separately Alias — no brand value in a separate accent Alias — one less variable to maintain Alias — brand palette already defined in primary Ship blocker: define; Sprint 2: alias to primary
Delete error-page.jsx/success-page.jsx Keep — probable use case for error boundaries Keep — functional, just unused Keep — well-designed variants Keep, refactor in Sprint 2
custom/button vs ui/button canonical custom/button — ripple + loading are product expectations Either works, but pick one custom/button — matches interaction language custom/button canonical for interactive; ui/button for composition only
Drag-and-drop in file-upload Fix — users expect it 45 min spike Lying labels are worse than no feature Fix in Sprint 1
Editor private API access Replace — upgrade risk 2 hrs Invisible to users Sprint 2
Test framework Sprint 2 — after architecture stabilizes Sprint 2 — need component consolidation first Sprint 2 ideally, Sprint 1 stretch Sprint 2
SearchableDropdown case collision Ship blocker on Linux CI? Only dormant on Windows — Sprint 2 Low user visibility Sprint 2

Sprint Board Visualization

┌─────────────────────────────────────────────────────────────────┐
│                SHIP BLOCKERS (pre-release gate)                 │
│                18 items · ~4 hrs · 1 developer                  │
│                                                                 │
│  [CRASH] C1 C2a C2b C4 C7 UI-5                                  │
│  [XSS]   C3 UI-6 SEC-1 SEC-2                                    │
│  [A11Y]  C9 (ConfirmationModal → AlertDialog)                   │
│  [VIS]   C6 C8a C8b C8c UI-1 UI-2 UI-4 UI-7                     │
│  [DEAD]  C5 (delete createEditor.jsx)                           │
│  [DEBUG] UI-3 (strip console.logs)                              │
│                                                                 │
│  EXIT: 9 Criticals closed. No default-prop crashes. CSP set.    │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│            SPRINT 1 — "A11y Floor + Unification" (W1)           │
│                22 items · ~20 hrs · 1 developer                 │
│                                                                 │
│  [MODAL]  S1-1 S1-2 S1-3 S1-4                                   │
│  [UNIFY]  S1-5 S1-6 S1-7 S1-8 S1-9 S1-10                        │
│  [FORMS]  S1-11 S1-12 S1-13 S1-14                               │
│  [A11Y]   S1-15 S1-16 S1-17 S1-18 S1-19                         │
│  [PERM]   S1-20 S1-21 S1-22                                     │
│                                                                 │
│  EXIT: axe-core 0 criticals on 5 core pages. Keyboard works.    │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│            SPRINT 2 — "Clean Foundation" (W2–3)                 │
│                24 items · ~22 hrs · 1–2 developers              │
│                                                                 │
│  [DEAD]   S2-1 S2-2 (~14 files, ~2,400 LOC deleted)             │
│  [DEDUP]  S2-3 S2-4 S2-5 S2-6 S2-7                              │
│  [EDITOR] S2-8 S2-9 S2-10                                       │
│  [TOKEN]  S2-11 S2-12 S2-13 S2-14                               │
│  [BUGS]   S2-15 S2-16 S2-17 S2-18 S2-19 S2-20 S2-21 S2-22 S2-23 │
│  [DOCS]   S2-24 S2-27                                           │
│  [TEST]   S2-25 S2-26 ★ (first component tests)                 │
│                                                                 │
│  EXIT: One component per responsibility. Editor upgrade-safe.   │
└──────────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                    BACKLOG (ongoing)                             │
│                    33 items · fix when touching adjacent files   │
│                                                                 │
│  B1–B33: typos, dead state, minor a11y, editor polish, misc     │
└─────────────────────────────────────────────────────────────────┘

Total Effort Estimate

Phase Items Effort Developers Timeline
Ship Blockers 18 ~4 hrs 1 Day 1 (before any deployment)
Sprint 1 22 ~20 hrs 1 Week 1
Sprint 2 24 ~22 hrs 1–2 Week 2–3
Backlog 33+ ~8 hrs 1 Ongoing
Total 97+ ~54 hrs ~3 weeks

Remaining ~308 low-severity issues (mostly Medium/Low from the deep-dive) roll up under Backlog categories — they are covered by the "fix when touching adjacent files" rule rather than enumerated line-by-line.


Dependencies Between Items

  • C9 (Ship) → S1-1 through S1-4 (Sprint 1 Modal track): ConfirmationModal rewrite must land first so custom/modal.jsx a11y work doesn't get undone by the migration.
  • C8 (Ship) → S2-12 (Sprint 2 Gold alias): Gold token must exist before we can alias it to primary.
  • S1-5 (Button canonical) → S2-25 (Button tests): Can't write button tests until there's one canonical button.
  • S2-3 (Table dedup) → S2-25 (Table tests): Wait for consolidation before testing.
  • S2-1 (Delete dead code) → S2-14 (Alias imports): Delete first so codemod runs over less surface.

Generated by BMAD Cross-Functional War Room — 2026-04-13 Cross-reference: Deep-Dive: UI Component Library Related security analysis: UI Component Attack Scenarios