Skip to content
Skillv1.0.0

frontend-architecture

This skill applies when you are modifying React components, UI/UX layouts, state management (Zustand), or dealing with frontend browser performance.

by benju66(0) 0 installs
Free
Sign in to install

Free account. Installing gives you the manifest plus copy-paste snippets.

See reviews

About

Imported from benju66/design-pulse (.agent/skills/frontend-architecture/SKILL.md). Install upstream with npx skills add benju66/design-pulse --skill frontend-architecture. Copyright stays with the author.

Frontend Architecture, React & State Management Skill

This skill applies when you are modifying React components, UI/UX layouts, state management (Zustand), or dealing with frontend browser performance.

UI / UX Architecture

  • Tri-State Master-Detail View: The unified grid (OpportunityGridV2) supports three modes: flat (dense table — Value Matrix), split (DetailPanel slides in), and pop-out (isolated browser window). The same component serves both Value Matrix (isLedgerView=false) and Budget Ledger (isLedgerView=true), controlled by a prop toggle. Value Matrix mode uses flat rows with inline editing (EditableCell components), no grouping, and no budget metric pills. Budget Ledger mode uses division/cost-code grouping, read-only financial cells (ReadOnlyCell), and toolbar metric pills.
  • Compare Tray: Users can check multiple rows in the grid to open an e-commerce style side-by-side comparison modal. All tables use TanStack-native rowSelection — the legacy Zustand compareQueue has been fully removed. The CompareModal accepts a selectedIds prop (derived at click time from rowSelection state). See the data-table-architecture skill for the full pattern.
  • Project Sidebar: A collapsible left navigation bar controlling views (Dashboard, Map, Analytics, Coordination, Settings).
  • Project Settings (Master-Detail): Vertical sidebar configuration layout with a floating, global save/discard state bar (AnimatePresence) to manage heavy configurations.
  • Grid Filter Drawer: All data grid views (OpportunityGridV2, CoordinationTable) use the shared GridFilterDrawer component (src/components/ui/GridFilterDrawer.tsx) for their filter panels. The drawer is rendered as a position: absolute; top-0; right-0; h-full; w-72; z-[40] sibling inside the grid card's root div (which carries position: relative). The toolbar exposes a single [Filters N] pill button with an active-count badge. Filter state always lives in page.tsx; the grids receive it via three props: filterSlot?: ReactNode (the panel content), filterActiveCount?: number (badge count), and onClearFilters?: () => void (the "Clear All" handler). The filterSlot JSX must use the vertical panel layout (labeled flex flex-col gap-1.5 form fields), not the inline pill layout — the pill layout is obsolete.
  • Budget Ledger Compound Cells (Phase 2): In Budget Ledger mode (isLedgerView), OpportunityGridV2 collapses 9 granular columns into 2 visible compound cells (ItemDefinitionCell, CostClassificationCell) and 1 hidden-by-default cell (ManagementCell) to eliminate horizontal sprawl. The Value Matrix retains the original individual columns with inline editing via EditableCell components. This is controlled by a columnVisibility useEffect that toggles compound IDs ON and individual IDs OFF when isLedgerView is true. ManagementCell (management) is set to false in both modes because its fields (assignee, priority, due_date) are irrelevant for budget line items — users can opt-in via the Column Chooser. The ColumnChooser reset handler (onReset callback) must re-apply these mode-specific defaults — calling table.setColumnVisibility({}) alone erases the toggle and renders both column sets simultaneously. When adding new columns that overlap with a compound cell's scope, you MUST update both the compound cell renderer AND the visibility toggle. Zustand Key: OpportunityGridV2 uses gridV2ColumnVisibility in Zustand — the single canonical key for both Value Matrix and Budget Ledger column visibility. The legacy gridColumnVisibility key has been fully removed.
  • Budget Ledger Data Pipeline (Display-Layer Normalization): The mergedOpportunities useMemo in page.tsx merges RPC budget lines with VE opportunities. Three normalization rules are enforced at merge time — never by mutating the database: (1) VE cost codes are stripped of suffixes (.M, .S, .L, .E, .O), padded to 6 digits with padStart(6, '0'), and their division is re-derived from padded.slice(0, 2) + '0000'. This prevents legacy codes like 61753.M from creating fake "DIVISION 61" groups. (2) RPC rows with null or empty cost_code are filtered out before budget-line creation — unassigned VE items appear as real opportunity rows in the UNCATEGORIZED group, not as phantom budget lines. (3) The csi_division value from the RPC is validated as all-digit before use; non-numeric values fall back to 'Uncategorized'. The RPC itself (get_master_ledger_grid) enforces AND o.cost_code IS NOT NULL AND o.cost_code != '' in the ve_impacts CTE and uses LEFT(LPAD(SPLIT_PART(code, '.', 1), 6, '0'), 2) for division derivation. The MemoizedGroupedRow in OpportunityGridV2.tsx has a final guard: if the 2-char division prefix is non-numeric, it renders 'UNCATEGORIZED' instead of 'DIVISION XX'.
  • View Extraction Architecture: The project page (src/app/project/[projectId]/page.tsx) acts as a state-management shell — all hooks, filter state, and data queries live there. View-specific JSX is extracted into dedicated components in src/components/views/ (ValueMatrixView, BudgetLedgerView, CoordinationView). This prevents full-tree re-renders when switching sidebar views. Infrequently-used views (AnalyticsDashboard, MyDeskDashboard) use next/dynamic for lazy loading. The filter pipeline uses a shared applyBaseFilters callback (no currentView dependency, no activeStatus — only activeBuildingAreas and activeCostCodes) with view-specific memos (filteredOpportunities for Value Matrix, filteredLedgerItems for Budget Ledger) to avoid recomputation on sidebar navigation. activeStatus is scoped to Value Matrix only — it is applied in the filteredOpportunities memo, never in applyBaseFilters, to prevent ghost filters leaking into the Budget Ledger. Budget Ledger filter count and clear handlers (ledgerFilterActiveCount, ledgerClearFilters) are computed centrally in page.tsx and passed as props — never duplicated inline in view components.
  • Shared Contenders Matrix (Cross-View Component): ContendersMatrix (src/components/opportunities/ContendersMatrix.tsx) is rendered in both the Value Matrix (ExpandedCard.tsx) and the Coordination Board (CoordinationDetailPanel.tsx). It accepts a recordType prop ('VE' | 'Coordination') that controls visual hierarchy — VE mode shows full interactive financial editing, Coordination mode de-emphasizes financials (read-only, opacity-60) and expands the design narrative textarea. Both panels also pin a unified "Description / Notes" <RichTextEditor> directly above the matrix with key={id + '-desc'} for identity-based remounting, onSave (blur-only) semantics, and HTML content storage. The hasDescriptionContent() utility from lib/htmlUtils.ts must be used for all content-existence checks (accordion auto-open, blue dot indicator) because TipTap stores empty content as <p></p> which .trim() incorrectly evaluates as truthy. Never re-create a static "VE Selection Details" block in the Coordination panel — all contender data flows through this shared component.
  • CSI Mapping Sub-View Architecture (Global Settings): The CSI Mapping tab inside GlobalSettingsModal.tsx uses a 3-way segmented control (CsiMappingWithSubViews) switching between: (1) ML Flywheel — the global CSI training data view showing ML-learned CSI-to-cost-code mappings with remap and verification controls. (2) Company Defaults — admin-managed company-wide default CSI-to-cost-code mappings with Excel upload/download (pre-populated with existing data), search, and row delete. Template generator: src/lib/excel/companyDefaultsTemplate.ts. (3) Rosetta Stone — read-only cross-project aggregation view showing company defaults alongside project-specific overrides as green pills per cost code. All three are internal functions within GlobalSettingsModal.tsx (not yet extracted per D1 — file already exceeds threshold). Hooks live in src/hooks/useCompanyCsiQueries.ts.

TypeScript & Browser Compatibility (iOS Safety)

  • CRITICAL: Explicitly FORBID the use of JavaScript/TypeScript regex "negative lookbehinds" (e.g., (?<!...)). This causes fatal crashes on older iOS WebKit engines. You MUST use standard loop-based logic, string splitting, or manual parsing to achieve the intended result.
  • Object-Based Mentions: When implementing @mentions or rich-text tagging, avoid complex string parsing. Instead, insert the plain-text name into the UI textarea, but strictly store the underlying tagged user UUIDs in a dedicated JSONB array column (e.g., mentions jsonb).

Code Generation Instructions

  1. Strict TypeScript (No any): The codebase strictly forbids the use of any. You must use unknown or exact interfaces for API payloads and TanStack generics. All TanStack data grids MUST utilize the globally declared TableMeta interface located in the types directory, rather than defining localized meta interfaces, to ensure strict type safety across all project modules. Furthermore, all Next.js App Router dynamic params must be typed and resolved as Promises (Next.js 15+ standard).
  2. Respect the Cache: Always use TanStack Query mutations (onMutate, onSuccess) to update the UI optimistically or invalidate queries. Do NOT force page reloads and do NOT use raw useEffect for data fetching.
  3. Tailwind First: Use Tailwind utility classes. Strictly support dark: mode variants. Utilize Tailwind v4 @container queries for structural grid layouts instead of viewport breakpoints (lg:, md:) whenever the component exists within a sliding or resizable layout (like the Master-Detail view). BANNED: @plugin "@tailwindcss/typography" — the typography plugin injects global CSS rules targeting all table, thead, tbody, td, and th elements, breaking virtualized grid scroll containers and causing ~2s view-switch lag from excessive style recalculation. Rich text typography styles MUST be scoped via the .ProseMirror selector in globals.css. Never apply prose Tailwind classes to any element.
  4. Headless UI: Rely on the established @tanstack/react-table and @dnd-kit patterns. Avoid introducing heavy UI component libraries (like Material UI or Ant Design) that clash with the custom styling.
  5. Client-Side UUIDs for Optimistic Inserts: Explicitly ban the use of fake temp- IDs for optimistic creation. New records must always be minted on the client side using crypto.randomUUID() so that the ID perfectly matches the database and prevents React components from unmounting/losing focus when the server responds.
  6. Optimistic Parent-Row Spreading: Any mutation affecting a child relational table (e.g., modifying opportunity_options) MUST spread the parent row ({ ...opp }) in the React Query cache during onMutate. This ensures the data grid's structural sharing instantly detects the change and recalculates aggregate ranges without waiting for the server.
  7. Grid Performance & Structural Sharing:
    • Mutable Meta: Explicitly ban deep-comparison loops over mutable objects like table.options.meta inside React.memo cell comparators. Cell memoization must strictly rely on prevProps.row.original !== nextProps.row.original for high-performance rendering. Never mutate table.options.meta inline on render (e.g. table.options.meta.func = ...); instead, use a useRef and pass the ref cleanly during table initialization.
    • Row Memoization & Live Resizing: To prevent fatal UI lag during column resizing (columnResizeMode: 'onChange'), virtualized rows (<tbody><tr> or <tr/>) MUST be strictly memoized using React.memo. To avoid the memoization trap where rows fail to update when columns are toggled/reordered, you must inject a structural hash (e.g., visibleColumnIds = row.getVisibleCells().map(c => c.column.id).join(',')) into the row component and compare it in the memo function. If columns have pinning, you must also inject a pinnedColumnOffsets hash into the React.memo comparator so rows re-render smoothly when sticky boundaries change. Row Selection Trap: You must pass the row's selection state as a primitive boolean prop (e.g., isRowSelected={row.getIsSelected()}) and compare it directly (prevProps.isRowSelected === nextProps.isRowSelected). Never compare the dynamic getter row.getIsSelected() inside React.memo's custom comparator, as the getter queries live state on the table instance, returning identical values for both previous and next props and failing to trigger a re-render.
    • Sub-Rows & Structural Sharing: When mapping relational sub-rows (like options) into a TanStack table, never mutate or remap the parent data array with a useMemo inline. Generating new array references destroys structural sharing and causes fatal UI lag. Instead, strictly use the getSubRows configuration property (e.g., getSubRows: row => optionsMap[row.id] || []) to dynamically yield sub-rows without altering parent references.
    • Sticky Grid Architecture: When implementing position: sticky for pinned columns inside TanStack tables, strictly use border-separate border-spacing-0 on the parent <table/> and apply bg-clip-padding with explicitly opaque background colors (never /50 opacity variants) to the pinned <th> and <td> cells. Using border-collapse causes native browser bleeding during horizontal scrolling where content shows under the borders.
    • Virtualizer Overscan Trap & Sticky Group Headers: virtualItems[0] is NOT the first visible row — TanStack Virtualizer's overscan buffer prepends invisible rows above the viewport. Any logic dependent on scroll position (e.g., sticky headers, "current section" detection) MUST iterate virtualItems and compare each item's .start against virtualizer.scrollOffset to find the true top-visible index. Native CSS position: sticky does not work for group rows inside virtualized lists because all rows use position: absolute with calculated transforms. The correct pattern is a zero-height floating overlay (sticky top; height: 0) rendered as a sibling above the <table>, containing a cloned group row with column widths explicitly synced from header.getSize(). The clone MUST be gated behind row.getIsExpanded() so collapsed groups scroll normally. For the "push-off" transition, calculate the distance between the next incoming group's .start and scrollOffset — when it falls within the sticky row's height, apply a negative translateY.
    • The Infinite Flex Trap (Virtualizer Scrolling): When placing a virtualized grid inside a tall flexbox structure, a flex-1 child container defaults to min-height: auto. If its content is taller than the viewport, it expands infinitely, breaking the inner overflow-auto scrolling container. The virtualizer then measures a massive container height and renders all rows at once, causing severe DOM bloat and hiding content off-screen. You MUST explicitly apply min-h-0 at every step down the flex tree where a child might exceed viewport bounds to constrain the virtualizer's bounding box natively.
  8. Supabase RPC Null Safety: When calling Supabase .rpc() methods from the frontend, always explicitly fallback to null for optional parameters (e.g., p_field: value || null). Passing undefined omits the key from the JSON payload entirely, causing fatal "function signature not found" errors in PostgreSQL.
  9. Multi-Assignee Format: The assignee field on opportunities uses a comma-separated string format (e.g., user1@email.com,user2@email.com) to support multiple users without requiring a junction table. Always use .includes() or .split(',') rather than exact string matching (===) when filtering by assignee.
  10. Fluid Layout Transitions & Auto-Collapse (Unified Container Pattern): When building collapsible summary panels (e.g., BudgetSummaryV2, CoordinationSummary), explicitly avoid swapping entirely different DOM components (e.g., <motion.div key="micro"> vs key="macro") via AnimatePresence. This causes text "teleporting" and jarring layout jumps. You MUST use the Unified Container Pattern: a single persistent root div with a static header row (KPIs and buttons) that never moves. Only wrap the expandable body content in an AnimatePresence with an initial={{ height: 0 }} to animate={{ height: 'auto' }} transition to ensure the container interpolates seamlessly without re-rendering the header. Furthermore, large metric dashboards should automatically collapse into their single-row micro-views whenever the Grid Filter Drawer opens to dynamically preserve vertical real estate for the data grids.
  11. Animation & Tween Cleanup: When utilizing imperative animation libraries (like Konva.Tween) inside useEffect blocks to animate properties, you MUST implement a cleanup return function (e.g., return () => tween.destroy();) to prevent conflicting animation loops and memory leaks if the dependency state changes rapidly.
  12. React 18 Event Bubbling (Click-Outside Pattern): Never use e.stopPropagation() on React pointer events to prevent global click-outside listeners from firing. Because React 18 uses root-level event delegation, native DOM events will still bubble to document listeners, causing popovers to close instantly on right-clicks or text-selection releases. You MUST use the useRef containment pattern (e.g., ref.current.contains(e.target as Node)) or DOM querying (e.g., e.target.closest()) within your global listeners to securely manage popover lifecycles. Click-outside listeners MUST attach to native DOM events (mousedown or pointerup), never to React's synthetic onClick — this is what makes the containment pattern immune to the React 18 delegation problem. dnd-kit Exception: onPointerDown and onMouseDown e.stopPropagation() calls on dnd-kit drag handles are a legitimate and required exception. They prevent the drag gesture from activating the parent scroll container and are explicitly supported by the dnd-kit API. Do NOT remove these. The prohibition applies only to onClick / synthetic event handlers used in click-outside detection contexts.
  13. Zero-JS Enterprise Tooltips: For high-frequency interactive elements (like dnd-kit matrix cells), strictly avoid heavy React-based tooltip libraries or portals that calculate position via JS. You must implement instant, zero-JS tooltips using Tailwind's group and group-hover with absolute positioning, a high z-[100], and pointer-events-none. This prevents layout shifts and z-index clipping during drag operations. Lucide React Limitation: Lucide React icon components do NOT accept a title HTML attribute as a prop (TypeScript will reject it). To add a native browser tooltip to an icon, wrap it in a <span title="..."> element instead of passing title directly.
  14. Escape-Key State Preservation (Hybrid UX Strategy): You must handle the Escape key based on the context of the input. For inline single-row grid cells, Escape must CANCEL the edit and revert to the initial value (matching standard Excel UX). However, for complex text areas and floating popovers, Escape must SAVE the value to prevent catastrophic data loss. When saving on Escape, never blindly unmount the component; you must explicitly extract e.currentTarget.value and trigger the save mutation before updating the UI state to close the popover.
  15. Heavy Client-Side Processing & Dynamic Imports: When integrating heavy file-parsing libraries (like exceljs), you MUST offload the processing to the client browser to minimize server compute. Furthermore, you must dynamically import the browser-safe build (e.g., import('exceljs/dist/exceljs.min.js')) to prevent Next.js Webpack from choking on Node.js stream polyfills. If this dynamic import breaks module typings, you MUST create a .d.ts declaration file (e.g., src/types/exceljs.d.ts) to re-export the native types and maintain the strict No any policy.
  16. Atomic Dropdown Mutations: When implementing <select> dropdowns or instantly-resolvable input cells in a data grid, strictly execute the database mutation onChange rather than relying on onBlur. Because global grid state (like activeCell) can unmount the cell before a native browser blur event fires, relying on onBlur creates severe data-loss race conditions.
  17. No Hooks Inside N-Rendered Components (Firehose Rule): Never call data-fetching hooks (e.g., useProjectSettings, useQuery, useProjectMembers) directly inside any component that is rendered N times simultaneously — this includes TanStack table cells, dnd-kit card components (e.g., SortableContenderCard), list items, and canvas nodes. Each hook call registers an independent subscriber. As components mount/unmount during scrolling or drag operations, these registrations accumulate and are never cleaned up, causing progressive memory growth and eventual session slowdown. Instead, derive all shared data once in the nearest non-repeated parent component (e.g., disciplines, buildingAreas, categories, rawCostCodes) and pass it down via props or table.options.meta. When a parent already provides the data via meta, downstream hooks must accept null as the query key to skip the fetch entirely (e.g., useProjectSettings(metaHasData ? null : projectId)). Permission Prop Threading: When embedding shared components like ContendersMatrix inside parents that already call useCurrentUserPermissions(), the shared component must accept an optional permissions?: UserPermissions prop. When provided, the internal useCurrentUserPermissions(permissionsProp ? null : projectId) call is skipped by passing null as the key. This prevents duplicate TanStack Query subscriptions for the same data.
  18. The Subscription Firehose: When subscribing to Supabase Realtime channels for high-volume tables, you MUST explicitly define a server-side filter (e.g., filter: 'opportunity_id=eq.' + id) in the postgres_changes config. Never subscribe to an unfiltered table.
  19. Template Ingestion Guardrails: When building client-side file uploaders for Excel templates that rely on Data Validation dropdowns linked to foreign keys, you MUST explicitly exclude .csv files to prevent users from bypassing validation. Furthermore, when parsing human-readable dropdown values (e.g., 09 65 16 - Flooring), you must explicitly split the string and extract the raw base code before passing the payload to Supabase to prevent Foreign Key constraint crashes.
  20. The Query Firehose (Lazy Loading): When rendering lists of hundreds of items (like a data grid or directory viewer), strictly avoid calling data-fetching hooks (e.g., usage checks or relationship counts) on a per-row basis on mount. This triggers a "firehose" of concurrent queries that will overwhelm the connection pool. Instead, you MUST use lazy loading (e.g., firing a single async query only when the user clicks a specific row's "Delete" button) or lift the requirement into a single batched parent RPC.
  21. Two-Sheet Excel Format for Cost Code Ingestion: The canonical Excel import format for cost_codes uses a two-sheet workbook. Sheet 1 ("Divisions"): one row per division group — Column A = 2-digit Division # (Text-formatted, e.g. "01"), Column B = Division Name. Sheet 2 ("Cost Codes"): all selectable cost codes — Column A = Code (Text-formatted, e.g. "010000"), Column B = Description, Column C = 2-digit Division # (dropdown validated from =Divisions!$A$2:$A$500), Columns D–H = Category L/M/S/E/O (Yes/No). Division-level codes (is_division=true) MUST appear in Sheet 1 ONLY. Both Code (Sheet 2) and Division # (both sheets) columns MUST be Excel Text-formatted (numFmt: '@') to prevent silent leading-zero stripping. The parser MUST support backward compatibility: if no "Divisions" sheet is detected (or it has zero data rows), fall through to the single-sheet path that reads division info from Column C human-readable labels and auto-synthesizes missing division header rows. The single-sheet backward compat regex \b\d{1,2}\b is a word-boundary pattern — NOT a negative lookbehind — and is iOS-safe.
  22. Compound Cost Code Display & Selection Model: The canonical display format for a cost code everywhere in the UI is XX-XXXX.S – Description Name (e.g. 10-2600.M – Wall and Door Protection). The suffix (.L, .M, .S, .E, .O) sits directly after the formatted code number with no space, followed by and the description. NEVER render a raw 6-digit code (e.g. 102600) in any user-facing element — always pass it through formatCostCode() first. The SmartCostCodeCombobox MUST fan out one selectable dropdown entry per active category_* flag on each CostCode row. A code with category_m=true AND category_s=true yields two separate entries (10-2600.M and 10-2600.S). Selecting a suffixed entry MUST atomically set both cost_code AND cost_type in a single onChange mutation — never allow a state where the code and its type are set independently via separate UI controls in the grid. The showCostTypeSegment prop on SmartCostCodeCombobox MUST remain false for all grid cell usage (CostCodeCell); the segmented control is reserved for Detail Panel contexts only. The category_* boolean flags on cost_codes rows have dual purpose: they drive the viewer accordion display AND the combobox dropdown fan-out. Do NOT conflate them with opportunities.cost_type — the flags describe what a code can be used for; cost_type records what it was used for on a specific line item.
  23. Procore Budget Code Normalization (Sub-Code Only): When normalizing Procore budget cost codes (format: "{div}-{sub}.{ext}", e.g. "2-29005.000") for matching against the Design Pulse cost_codes table, you MUST extract only the sub-code — the segment between the first - and the first . — then call padStart(6, '0'). Do NOT concatenate the division prefix with the sub-code (e.g. "02" + "29005" = "0229005", 7 characters), which will never match the 6-digit internal format. The division prefix in Procore's format is a display-routing artifact only; the sub-code already encodes the division numerically. Correct: "2-29005.000" → find first - → take "29005.000" → take before ."29005"padStart(6,'0')"029005". iOS Safety (Rule A): Validate the extracted sub-code using a character-loop digit check — no regex. Edge case: For division numbers ≥ 10 (e.g. "10-101400.000"), split on the first - correctly isolates "10" as the div prefix, and "101400.000""101400" (already 6 digits, no padding needed). Always guard against sub-codes that produce more than 6 digits after padding (return null if subCode.length > 6).
  24. Zustand Persist Version Contract: When adding any new field to a Zustand persist store's partialize config, you MUST bump the version integer and add a corresponding migrate branch. Failure to do so causes existing users' persisted state (the old version) to silently omit the new keys, hydrating them as undefined instead of their default values. The migrate function signature MUST be typed as (persistedState: unknown, version: number) — never any. Use a safe cast pattern: const state = persistedState as Partial<UIState>. The migration must handle ALL prior versions using if (version < N) branching, initializing missing keys to their correct defaults (e.g., {} for Record fields, 'split' for a flat string mode). Each version branch must fall through: a user upgrading from v0 to v3 in one step must receive all intermediate defaults.
  25. Persisted State Validity Guards (localStorage Resilience): When reading any enum-like string value from Zustand persist storage (e.g., a currentView or activeTab), you MUST validate it against a module-level Set constant before using it. Never trust localStorage blindly — the stored value may be stale (from a renamed view), corrupted, or from a future-incompatible schema. The canonical pattern is a module-level typed Set<UnionType> and a type guard function: const VALID_VIEWS = new Set<ProjectView>([...]) and function isProjectView(v: string | undefined): v is ProjectView { return !!v && VALID_VIEWS.has(v as ProjectView); }. These constants and functions MUST be declared at module scope (not inside the component) to prevent recreation on every render. Apply a safe default ('dashboard', 'info') when the guard fails. For role-gated tabs (e.g., team), add a second runtime guard that redirects to a safe default when the user's RBAC role no longer grants access — this prevents a demoted user from having privileged content restored from stale localStorage. Use a displayTab / safeTab split: safeTab validates the stored value; displayTab applies the role gate on top.
  26. Flat vs Per-Project Zustand View Modes: The useUIStore has two distinct scoping patterns for persisted preferences — flat (single string, shared across all projects) and per-project (Record<string, T>, independent per projectId). Follow the existing convention strictly: view modes (coordinationViewMode, permitViewMode, veGridViewMode) are FLAT — they reflect a user's preferred layout for a module type and should be consistent regardless of which project is open. Filter state (permitFilters) and navigation position (activeView, activeSettingsTab) are PER-PROJECT — they reflect context-specific user state that must be independently remembered per project. Never make a view mode per-project (adding a Record<string, ViewMode> when a plain ViewMode is correct) — this creates unnecessary complexity and diverges from the codebase convention. The test: ask "would a user want this reset when they switch projects?" View modes: No. Filters and active views: Yes.
  27. Atomic Compound Zustand Navigation: When a user interaction must update two or more related Zustand state fields simultaneously (e.g., setting currentView = 'settings' AND settingsTab = 'estimate'), you MUST perform the update in a single Zustand set() call, not two sequential calls. Two sequential set() calls trigger two React render cycles, creating a frame where the first field has changed but the second has not — for navigation this causes a visible flash (e.g., the Settings view renders briefly with the old tab before the new one applies). The canonical pattern is a dedicated compound action on the store: navigateToSettings: (projectId, tab) => set(state => ({ activeView: { ...state.activeView, [projectId]: 'settings' }, activeSettingsTab: { ...state.activeSettingsTab, [projectId]: tab } })). Callers must use the compound action exclusively; the individual setters (setActiveView, setActiveSettingsTab) are reserved for single-field updates only.
  28. Explicit Loading States for Permission Hooks (RBAC Race Conditions): When building custom hooks that aggregate async data for role-based access control (like useCurrentUserPermissions), you MUST explicitly return an { isLoading: boolean } flag alongside the evaluated permissions. Never return silent default/fallback permissions while the underlying TanStack queries are still in-flight. Doing so causes UI flickering and race conditions where permission-gated actions are momentarily inaccessible (or improperly accessible) during initial render. Consumers of the hook must explicitly check if (isLoading) return null; (or similar) before rendering gated UI.
  29. Delta-Based Local State Merging (Stale Cache Prevention): When merging user-specific UI preferences (like pinned columns or layout visibility) with global admin configurations from project_settings, never save the final computed array to the user's local browser cache (Zustand). You must only save a strict "Delta" (e.g., { explicitlyPinned: [], explicitlyUnpinned: [] }). This guarantees that if a Project Admin adds a new global configuration in the future, the user's local cache won't act as a stale mask and permanently hide the new feature.
  30. Cross-Store Synchronization (Infinite Recursion Guards): When implementing bi-directional state synchronization between two distinct Zustand stores (e.g., useMapStore and useUIStore), you MUST explicitly check if the incoming state is identical to the current state before applying updates or calling the sister store's setters. Use strict equality or shallow array comparison (e.g., if (currentId === id) return;). Failure to include this bailout guard causes an infinite update loop and a fatal Maximum call stack size exceeded runtime error when one store updates or clears the state of the other.
  31. Complex Modal State Orchestration (Anti-Data-Loss): When breaking down massive interactive modals into sub-components (as mandated by the Anti-Monolith Rule D1), the parent modal MUST act as the absolute source of truth. All extracted sub-views (e.g., WizardView, TitleBlockTrainingView) must use strictly controlled inputs and pass updates up via callbacks. Do not use isolated local state inside the sub-components, as swapping views will unmount the component and cause catastrophic data loss.
  32. Grid Card overflow-hidden vs Absolute-Positioned Drawers: Grid card wrappers that use overflow-hidden (e.g., CoordinationTable's inner rounded-b-xl wrapper) will clip position: absolute children — including the GridFilterDrawer. Do NOT use overflow: clip as a workaround; it does not free absolutely-positioned descendants from the clipping context. The correct fix is to remove overflow-hidden from the wrapper and rely on the inner scroll container's overflow-auto + rounded-b-xl classes to clip table content at the card's rounded corners. The rounded-b-xl border on the wrapper still renders correctly without overflow-hidden. Never re-add overflow-hidden to any grid card div that also contains a GridFilterDrawer sibling.
  33. MultiSelectFilter Panel Width: When rendering MultiSelectFilter inside the GridFilterDrawer panel (not inline in a toolbar), you MUST pass fullWidth prop to expand the trigger button to 100% container width. Without it, the component renders as inline-block at intrinsic width and looks misaligned in the vertical panel layout. The fullWidth prop switches the root div from inline-block to block w-full.
  34. Controlled <details> Accordions (No Data-Driven open): Never bind the open attribute of a native <details> element directly to server/query data (e.g., <details open={!!description?.trim()}>). This creates a half-controlled component where React's virtual DOM fights the browser's native toggle state on every re-render, causing visible lag and snap-back when background TanStack Query refetches trigger reconciliation. Instead, use a fully controlled pattern: const [open, setOpen] = useState(() => !!initialData) with a useEffect keyed on the record identity (e.g., row.original.id) — never the field value — to re-derive the default when navigating between items. The effect intentionally omits the data field from its dependency array to prevent overriding the user's manual toggle during refetches; suppress the resulting lint warnings with a targeted // eslint-disable-next-line react-hooks/set-state-in-effect, react-hooks/exhaustive-deps comment explaining the rationale. Sync the browser's native toggle via onToggle={(e) => setOpen(e.newState === 'open')} using React 19's typed ToggleEvent. Note: The HTML toggle event does NOT bubble, so no e.target === e.currentTarget guard is needed for nested <details> elements. TipTap Content Check: When the accordion's initial open state depends on whether rich text content exists, you MUST use hasDescriptionContent() from lib/htmlUtils.ts — never .trim(). TipTap stores empty content as <p></p> which .trim() incorrectly evaluates as truthy. TipTap SSR: When using useEditor from @tiptap/react in Next.js, you MUST pass immediatelyRender: false in the editor config to suppress noisy SSR hydration warnings.
  35. Zustand Selector Reference Stability (React 19 getSnapshot Compliance): When writing selectors for Zustand hooks (e.g. useUIStore), you MUST NEVER return inline-created arrays [], objects {}, or computed values directly inside the selector function (e.g., (s) => s.data || {}). This violates React 19's getSnapshot stability contract, producing referential inequality on every render and triggering an infinite re-render loop ("Maximum update depth exceeded"). You MUST either return the raw value and apply an external, module-level stable constant fallback outside the selector (e.g., useUIStore((s) => s.data) || EMPTY_OBJECT), or wrap the selector in useShallow from zustand/react/shallow to preserve shallow referential equality.
  36. URL Filter Namespace Isolation (useURLFilters Contract): All calls to useURLFilters (src/hooks/useURLFilters.ts) MUST provide a unique namespace string as the required second argument (e.g., 've', 'coord', 'permit'). The namespace is dot-prefixed onto every URL query key (e.g., statusve.status) to prevent cross-view parameter collisions when multiple views serialize filter state to the same window.location.search. The returned state object uses the original short keys, so downstream consumer code requires zero changes. Omitting the namespace is a compile error by design. When adding a new view that calls useURLFilters, choose a short, unique namespace that does not collide with existing ones. Current registry: 've' (Value Matrix), 'coord' (Coordination Board), 'permit' (Permits).

Shared UI Primitives (src/components/ui/)

All interactive UI primitives that appear across multiple features MUST use the shared components in src/components/ui/. Never create ad-hoc styled <button>, <input>, or similar elements when a shared component exists.

  1. Button Component (src/components/ui/Button.tsx): All action buttons (submit, cancel, delete, export, etc.) MUST use the shared <Button> component. Raw <button> elements are only permitted for highly specialized controls (tab bars, segmented toggles, table-cell micro-buttons, icon-only controls) that don't map to the standard variant system.

    • Variants: primary (default, sky-500), secondary (slate bg), ghost (transparent), destructive (rose-600), outline (border only).
    • Intents: default (sky), coordination (indigo-600), drawings (teal-500), amber (amber-500). Intents override the primary variant's color.
    • Sizes: sm, default, lg, icon.
    • Loading: Use isLoading + loadingText props instead of manual Loader2 spinner swaps. The component handles spinner rendering, button disabling, and text replacement internally.
    • Escape Hatch: Pass additional className for one-off overrides (e.g., className="w-full" or className="shadow-lg shadow-teal-500/20"). These merge with base styles via cn().
    • Utility: cn() from src/lib/cn.ts wraps clsx for conditional class merging. Use cn() instead of raw template literals for any conditional className logic.
  2. ModalShell Component (src/components/ui/ModalShell.tsx): All overlay dialog modals MUST use the shared <ModalShell> wrapper. Never create ad-hoc fixed inset-0 backdrop + container markup when ModalShell exists.

    • Props: isOpen, onClose (required). size (sm | md | lg | full, default sm), nested (uses z-[60] instead of z-50), closeOnBackdropClick (default true), closeOnEscape (default true), preventClose (master lock, disables all dismiss), className (escape hatch for container overrides).
    • Shell provides: Fixed overlay with bg-slate-900/50 backdrop-blur-sm, separate backdrop div for click-outside, rounded-2xl container, shadow-2xl, entrance animation (animate-in fade-in zoom-in-95 duration-150), Escape key handler.
    • Shell does NOT provide: Close X button, header layout, footer layout — consumers render their own header with a standard close button (p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300 rounded-xl transition-colors, X size={18}).
    • Escape safety: When document.activeElement is INPUT, TEXTAREA, or SELECT, the Escape handler is skipped to prevent data loss in inline-editable grids (Rule C18 compliance).
    • Excluded modals: PdfImportModal (dark-themed mini-app with z-[200]) and ProjectEstimateTab inline modals (tightly coupled to local state) are exempt from ModalShell.

Architectural Organization & File Structure

  1. Anti-Monolith Component Rule (Tab & Panel Extraction): Strictly avoid monolithic component files (e.g., >500 lines). When building complex UIs like Settings pages or Modals that utilize distinct tabs or panels, you MUST extract the contents of each tab/panel into its own isolated component file within a dedicated sub-directory (e.g., components/dashboard/tabs/). The parent file should act purely as a state-manager and layout shell.
  2. Domain-Driven Hook Separation: Never create "god files" for data fetching (e.g., a single useProjectQueries.ts containing all hooks). TanStack Query hooks MUST be separated into discrete, domain-specific files (e.g., useOpportunityQueries.ts, useSettingsQueries.ts) to prevent massive import blocks and reduce merge conflicts.
  3. Custom Hook Extraction for Complex Logic: When a UI component relies on complex data transformation (e.g., chained useMemo blocks for filtering data grids) alongside localized state (e.g., active filters), you MUST extract the filtering logic and its state into a custom hook (e.g., useOpportunityFilters). Keep main page components focused strictly on layout composition.
  4. Shared DataTable Component System: All data grids share a common component layer in src/components/data-table/. When building a new table or migrating an existing one, invoke the data-table-architecture skill for the full API reference, migration checklist, and guardrails. Domain-specific cells remain in their original directories — never genericize cells with complex formatting or conditional rendering.

Standard Table Design Reference

This section documents the canonical table design patterns that ALL data grids in the application must follow. Future workers building new tables or modifying existing ones should use this as the authoritative specification.

Grid Inventory

Grid File Shared Components Used Domain Cells
Value Matrix / Budget Ledger OpportunityGridV2.tsx GhostRow, BulkActionBar, DeleteConfirmModal, CheckboxCell, CheckboxHeader, MemoizedRow EditableCell.tsx, ReadOnlyCell.tsx, OptionsCell.tsx, InlineOptionCell.tsx
Coordination Board CoordinationTable.tsx GhostRow, BulkActionBar, DeleteConfirmModal, CheckboxCell, CheckboxHeader, MemoizedRow, TableEmptyState Inline discipline pills, MEP impact cells
Permit Board PermitTable.tsx GhostRow, BulkActionBar, DeleteConfirmModal, CheckboxCell, CheckboxHeader, MemoizedRow, TableEmptyState PermitTextCell, PermitDateCell, PermitStatusCell, PermitDropdownCell, PermitAssigneeCell
Lessons Learned LessonsTable.tsx GhostRow, BulkActionBar, DeleteConfirmModal, CheckboxCell, CheckboxHeader, TableEmptyState Inline lesson cells
Brand Standards BrandStandardsGrid.tsx CheckboxCell, CheckboxHeader, BulkActionBar, DeleteConfirmModal Inline brand standard cells

Standard Features Every Grid Must Support

  1. Row Selection: TanStack-native rowSelection state with CheckboxCell / CheckboxHeader from data-table/cells. Never use Zustand for selection state.
  2. Bulk Actions: BulkActionBar with selection count, delete, clear, and optional extraActions slot.
  3. Delete Confirmation: DeleteConfirmModal with entity-specific labels.
  4. Ghost Row (Quick Add): GhostRow<T> with domain-specific placeholder, defaultValues, and staticFields.
  5. Column Visibility: Per-project Zustand persistence via Record<string, VisibilityState>. Each grid has its own key (e.g., gridV2ColumnVisibility, coordColumnVisibility, permitColumnVisibility).
  6. Column Chooser: ColumnChooser component with reset handler that re-applies domain-specific visibility defaults.
  7. Virtual Scrolling: @tanstack/react-virtual with MemoizedRow structural hash comparison.
  8. Filter Drawer: GridFilterDrawer component with filterSlot, filterActiveCount, and onClearFilters prop pattern.
  9. Empty/Loading States: TableEmptyState and TableLoadingState for consistent UX.

Cell Architecture Decision Tree

Is the cell rendering domain-specific formatting (custom pills, conditional colors, multi-field compound cells)?
  → YES: Keep it in the domain directory (e.g., `src/components/opportunities/EditableCell.tsx`)
  → NO: Is it a standard text/date/select/checkbox input?
    → YES: Use the shared cell from `src/components/data-table/cells/`
    → NO: Create a new shared cell in `data-table/cells/` if it will be reused by 2+ grids

Zustand Column Visibility Keys

Grid Zustand Key Persistence
Value Matrix + Budget Ledger gridV2ColumnVisibility Per-project
Coordination Board coordColumnVisibility Per-project
Permit Board permitColumnVisibility Per-project
Lessons Learned lessonsColumnVisibility Per-project
Brand Standards brandStandardsColumnVisibility Per-client

When adding a new grid, create a new dedicated key — never share keys across grids to prevent cross-view visibility pollution.

Use it

Copy one of these into your project. Installing also returns the manifest and these snippets.

yaml
targets:
  - https://api.opensmartroute.ai/api/v1/registry/benju66-design-pulse-frontend-architecture/manifest   # or paste the manifest below

Manifest

An Open Capability Manifest: the router reads it to know what this does, what it costs and when to pick it.

benju66-design-pulse-frontend-architecture.ocm.jsonjson
{
  "ocm": "1",
  "id": "benju66-design-pulse-frontend-architecture",
  "kind": "skill",
  "name": "frontend-architecture",
  "description": "This skill applies when you are modifying React components, UI/UX layouts, state management (Zustand), or dealing with frontend browser performance.",
  "publisher": "benju66",
  "version": "1.0.0",
  "capabilities": {
    "domains": [
      "general"
    ],
    "tags": [
      "skill-md",
      "github"
    ],
    "languages": [
      "en"
    ]
  },
  "quality_prior": 0.6,
  "examples": [
    "This skill applies when you are modifying React components, UI/UX layouts, state management (Zustand), or dealing with frontend browser performance."
  ],
  "primary": false,
  "metadata": {
    "source": {
      "provider": "github",
      "repository": "https://github.com/benju66/design-pulse",
      "path": ".agent/skills/frontend-architecture/SKILL.md",
      "ref": "823223a7056f159239d468644f82bf5be0c077fb",
      "url": "https://github.com/benju66/design-pulse/blob/823223a7056f159239d468644f82bf5be0c077fb/.agent/skills/frontend-architecture/SKILL.md",
      "key": "benju66/design-pulse/.agent/skills/frontend-architecture/SKILL.md"
    }
  },
  "instructions": "<!-- Canonical content (Antigravity). Claude mirror: .claude/skills/frontend-architecture/ (pointer only). -->\n# Frontend Architecture, React & State Management Skill\n\nThis skill applies when you are modifying React components, UI/UX layouts, state management (Zustand), or dealing with frontend browser performance.\n\n## UI / UX Architecture\n* **Tri-State Master-Detail View:** The unified grid (`OpportunityGridV2`) supports three modes: `flat` (dense table — Value Matrix), `split` (DetailPanel slides in), and `pop-out` (isolated browser window). The same component serves both Value Matrix (`isLe",
  "cost": {
    "context_tokens": 11609
  }
}

Fetch it by URL: GET /api/v1/registry/benju66-design-pulse-frontend-architecture/manifest?version=1.0.0

Reviews

Star ratings from people who tried it. One review per account; edit yours any time.

No reviews yet. Install it, try it, and be the first to rate it.