Imported from benju66/design-pulse (
.agent/skills/frontend-architecture/SKILL.md). Install upstream withnpx 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), andpop-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 (EditableCellcomponents), 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 ZustandcompareQueuehas been fully removed. TheCompareModalaccepts aselectedIdsprop (derived at click time fromrowSelectionstate). See thedata-table-architectureskill 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 sharedGridFilterDrawercomponent (src/components/ui/GridFilterDrawer.tsx) for their filter panels. The drawer is rendered as aposition: absolute; top-0; right-0; h-full; w-72; z-[40]sibling inside the grid card's rootdiv(which carriesposition: relative). The toolbar exposes a single[Filters N]pill button with an active-count badge. Filter state always lives inpage.tsx; the grids receive it via three props:filterSlot?: ReactNode(the panel content),filterActiveCount?: number(badge count), andonClearFilters?: () => void(the "Clear All" handler). ThefilterSlotJSX must use the vertical panel layout (labeledflex flex-col gap-1.5form fields), not the inline pill layout — the pill layout is obsolete. - Budget Ledger Compound Cells (Phase 2): In Budget Ledger mode (
isLedgerView),OpportunityGridV2collapses 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 viaEditableCellcomponents. This is controlled by acolumnVisibilityuseEffectthat toggles compound IDs ON and individual IDs OFF whenisLedgerViewistrue.ManagementCell(management) is set tofalsein both modes because its fields (assignee, priority, due_date) are irrelevant for budget line items — users can opt-in via the Column Chooser. TheColumnChooserreset handler (onResetcallback) must re-apply these mode-specific defaults — callingtable.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:OpportunityGridV2usesgridV2ColumnVisibilityin Zustand — the single canonical key for both Value Matrix and Budget Ledger column visibility. The legacygridColumnVisibilitykey has been fully removed. - Budget Ledger Data Pipeline (Display-Layer Normalization): The
mergedOpportunitiesuseMemoinpage.tsxmerges 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 withpadStart(6, '0'), and theirdivisionis re-derived frompadded.slice(0, 2) + '0000'. This prevents legacy codes like61753.Mfrom creating fake "DIVISION 61" groups. (2) RPC rows withnullor emptycost_codeare filtered out before budget-line creation — unassigned VE items appear as real opportunity rows in the UNCATEGORIZED group, not as phantom budget lines. (3) Thecsi_divisionvalue from the RPC is validated as all-digit before use; non-numeric values fall back to'Uncategorized'. The RPC itself (get_master_ledger_grid) enforcesAND o.cost_code IS NOT NULL AND o.cost_code != ''in theve_impactsCTE and usesLEFT(LPAD(SPLIT_PART(code, '.', 1), 6, '0'), 2)for division derivation. TheMemoizedGroupedRowinOpportunityGridV2.tsxhas 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 insrc/components/views/(ValueMatrixView,BudgetLedgerView,CoordinationView). This prevents full-tree re-renders when switching sidebar views. Infrequently-used views (AnalyticsDashboard,MyDeskDashboard) usenext/dynamicfor lazy loading. The filter pipeline uses a sharedapplyBaseFilterscallback (nocurrentViewdependency, noactiveStatus— onlyactiveBuildingAreasandactiveCostCodes) with view-specific memos (filteredOpportunitiesfor Value Matrix,filteredLedgerItemsfor Budget Ledger) to avoid recomputation on sidebar navigation.activeStatusis scoped to Value Matrix only — it is applied in thefilteredOpportunitiesmemo, never inapplyBaseFilters, to prevent ghost filters leaking into the Budget Ledger. Budget Ledger filter count and clear handlers (ledgerFilterActiveCount,ledgerClearFilters) are computed centrally inpage.tsxand 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 arecordTypeprop ('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 withkey={id + '-desc'}for identity-based remounting,onSave(blur-only) semantics, and HTML content storage. ThehasDescriptionContent()utility fromlib/htmlUtils.tsmust 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.tsxuses 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 withinGlobalSettingsModal.tsx(not yet extracted per D1 — file already exceeds threshold). Hooks live insrc/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
@mentionsor 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
- Strict TypeScript (No
any): The codebase strictly forbids the use ofany. You must useunknownor exact interfaces for API payloads and TanStack generics. All TanStack data grids MUST utilize the globally declaredTableMetainterface 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 dynamicparamsmust be typed and resolved asPromises(Next.js 15+ standard). - 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 rawuseEffectfor data fetching. - Tailwind First: Use Tailwind utility classes. Strictly support
dark:mode variants. Utilize Tailwind v4@containerqueries 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 alltable,thead,tbody,td, andthelements, breaking virtualized grid scroll containers and causing ~2s view-switch lag from excessive style recalculation. Rich text typography styles MUST be scoped via the.ProseMirrorselector inglobals.css. Never applyproseTailwind classes to any element. - Headless UI: Rely on the established
@tanstack/react-tableand@dnd-kitpatterns. Avoid introducing heavy UI component libraries (like Material UI or Ant Design) that clash with the custom styling. - 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 usingcrypto.randomUUID()so that the ID perfectly matches the database and prevents React components from unmounting/losing focus when the server responds. - 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 duringonMutate. This ensures the data grid's structural sharing instantly detects the change and recalculates aggregate ranges without waiting for the server. - Grid Performance & Structural Sharing:
- Mutable Meta: Explicitly ban deep-comparison loops over mutable objects like
table.options.metainsideReact.memocell comparators. Cell memoization must strictly rely onprevProps.row.original !== nextProps.row.originalfor high-performance rendering. Never mutatetable.options.metainline on render (e.g.table.options.meta.func = ...); instead, use auseRefand 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 usingReact.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 apinnedColumnOffsetshash into theReact.memocomparator 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 getterrow.getIsSelected()insideReact.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 parentdataarray with auseMemoinline. Generating new array references destroys structural sharing and causes fatal UI lag. Instead, strictly use thegetSubRowsconfiguration property (e.g.,getSubRows: row => optionsMap[row.id] || []) to dynamically yield sub-rows without altering parent references. - Sticky Grid Architecture: When implementing
position: stickyfor pinned columns inside TanStack tables, strictly useborder-separate border-spacing-0on the parent<table/>and applybg-clip-paddingwith explicitly opaque background colors (never/50opacity variants) to the pinned<th>and<td>cells. Usingborder-collapsecauses 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 iteratevirtualItemsand compare each item's.startagainstvirtualizer.scrollOffsetto find the true top-visible index. Native CSSposition: stickydoes not work for group rows inside virtualized lists because all rows useposition: absolutewith 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 fromheader.getSize(). The clone MUST be gated behindrow.getIsExpanded()so collapsed groups scroll normally. For the "push-off" transition, calculate the distance between the next incoming group's.startandscrollOffset— when it falls within the sticky row's height, apply a negativetranslateY. - The Infinite Flex Trap (Virtualizer Scrolling): When placing a virtualized grid inside a tall flexbox structure, a
flex-1child container defaults tomin-height: auto. If its content is taller than the viewport, it expands infinitely, breaking the inneroverflow-autoscrolling 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 applymin-h-0at every step down the flex tree where a child might exceed viewport bounds to constrain the virtualizer's bounding box natively.
- Mutable Meta: Explicitly ban deep-comparison loops over mutable objects like
- Supabase RPC Null Safety: When calling Supabase
.rpc()methods from the frontend, always explicitly fallback tonullfor optional parameters (e.g.,p_field: value || null). Passingundefinedomits the key from the JSON payload entirely, causing fatal "function signature not found" errors in PostgreSQL. - Multi-Assignee Format: The
assigneefield 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. - 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">vskey="macro") viaAnimatePresence. This causes text "teleporting" and jarring layout jumps. You MUST use the Unified Container Pattern: a single persistent rootdivwith a static header row (KPIs and buttons) that never moves. Only wrap the expandable body content in anAnimatePresencewith aninitial={{ height: 0 }}toanimate={{ 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. - Animation & Tween Cleanup: When utilizing imperative animation libraries (like
Konva.Tween) insideuseEffectblocks 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. - 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 todocumentlisteners, causing popovers to close instantly on right-clicks or text-selection releases. You MUST use theuseRefcontainment 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 (mousedownorpointerup), never to React's syntheticonClick— this is what makes the containment pattern immune to the React 18 delegation problem. dnd-kit Exception:onPointerDownandonMouseDowne.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 toonClick/ synthetic event handlers used in click-outside detection contexts. - Zero-JS Enterprise Tooltips: For high-frequency interactive elements (like
dnd-kitmatrix cells), strictly avoid heavy React-based tooltip libraries or portals that calculate position via JS. You must implement instant, zero-JS tooltips using Tailwind'sgroupandgroup-hoverwithabsolutepositioning, a highz-[100], andpointer-events-none. This prevents layout shifts and z-index clipping during drag operations. Lucide React Limitation: Lucide React icon components do NOT accept atitleHTML 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 passingtitledirectly. - Escape-Key State Preservation (Hybrid UX Strategy): You must handle the
Escapekey based on the context of the input. For inline single-row grid cells,Escapemust CANCEL the edit and revert to the initial value (matching standard Excel UX). However, for complex text areas and floating popovers,Escapemust SAVE the value to prevent catastrophic data loss. When saving onEscape, never blindly unmount the component; you must explicitly extracte.currentTarget.valueand trigger the save mutation before updating the UI state to close the popover. - 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.tsdeclaration file (e.g.,src/types/exceljs.d.ts) to re-export the native types and maintain the strictNo anypolicy. - Atomic Dropdown Mutations: When implementing
<select>dropdowns or instantly-resolvable input cells in a data grid, strictly execute the database mutationonChangerather than relying ononBlur. Because global grid state (likeactiveCell) can unmount the cell before a native browserblurevent fires, relying ononBlurcreates severe data-loss race conditions. - 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 ortable.options.meta. When a parent already provides the data via meta, downstream hooks must acceptnullas the query key to skip the fetch entirely (e.g.,useProjectSettings(metaHasData ? null : projectId)). Permission Prop Threading: When embedding shared components likeContendersMatrixinside parents that already calluseCurrentUserPermissions(), the shared component must accept an optionalpermissions?: UserPermissionsprop. When provided, the internaluseCurrentUserPermissions(permissionsProp ? null : projectId)call is skipped by passingnullas the key. This prevents duplicate TanStack Query subscriptions for the same data. - 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 thepostgres_changesconfig. Never subscribe to an unfiltered table. - 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
.csvfiles 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. - 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.
- Two-Sheet Excel Format for Cost Code Ingestion: The canonical Excel import format for
cost_codesuses 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}\bis a word-boundary pattern — NOT a negative lookbehind — and is iOS-safe. - 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 throughformatCostCode()first. TheSmartCostCodeComboboxMUST fan out one selectable dropdown entry per activecategory_*flag on eachCostCoderow. A code withcategory_m=trueANDcategory_s=trueyields two separate entries (10-2600.Mand10-2600.S). Selecting a suffixed entry MUST atomically set bothcost_codeANDcost_typein a singleonChangemutation — never allow a state where the code and its type are set independently via separate UI controls in the grid. TheshowCostTypeSegmentprop onSmartCostCodeComboboxMUST remainfalsefor all grid cell usage (CostCodeCell); the segmented control is reserved for Detail Panel contexts only. Thecategory_*boolean flags oncost_codesrows have dual purpose: they drive the viewer accordion display AND the combobox dropdown fan-out. Do NOT conflate them withopportunities.cost_type— the flags describe what a code can be used for;cost_typerecords what it was used for on a specific line item. - 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 Pulsecost_codestable, you MUST extract only the sub-code — the segment between the first-and the first.— then callpadStart(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"),spliton 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 (returnnullifsubCode.length > 6). - Zustand Persist Version Contract: When adding any new field to a Zustand
persiststore'spartializeconfig, you MUST bump theversioninteger and add a correspondingmigratebranch. Failure to do so causes existing users' persisted state (the old version) to silently omit the new keys, hydrating them asundefinedinstead of their default values. Themigratefunction signature MUST be typed as(persistedState: unknown, version: number)— neverany. Use a safe cast pattern:const state = persistedState as Partial<UIState>. The migration must handle ALL prior versions usingif (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. - Persisted State Validity Guards (localStorage Resilience): When reading any enum-like string value from Zustand
persiststorage (e.g., acurrentVieworactiveTab), you MUST validate it against a module-levelSetconstant 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 typedSet<UnionType>and a type guard function:const VALID_VIEWS = new Set<ProjectView>([...])andfunction 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 adisplayTab/safeTabsplit:safeTabvalidates the stored value;displayTabapplies the role gate on top. - Flat vs Per-Project Zustand View Modes: The
useUIStorehas two distinct scoping patterns for persisted preferences — flat (single string, shared across all projects) and per-project (Record<string, T>, independent perprojectId). 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 aRecord<string, ViewMode>when a plainViewModeis 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. - Atomic Compound Zustand Navigation: When a user interaction must update two or more related Zustand state fields simultaneously (e.g., setting
currentView = 'settings'ANDsettingsTab = 'estimate'), you MUST perform the update in a single Zustandset()call, not two sequential calls. Two sequentialset()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. - 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 checkif (isLoading) return null;(or similar) before rendering gated UI. - 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. - Cross-Store Synchronization (Infinite Recursion Guards): When implementing bi-directional state synchronization between two distinct Zustand stores (e.g.,
useMapStoreanduseUIStore), 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 fatalMaximum call stack size exceededruntime error when one store updates or clears the state of the other. - 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. - Grid Card
overflow-hiddenvs Absolute-Positioned Drawers: Grid card wrappers that useoverflow-hidden(e.g.,CoordinationTable's innerrounded-b-xlwrapper) will clipposition: absolutechildren — including theGridFilterDrawer. Do NOT useoverflow: clipas a workaround; it does not free absolutely-positioned descendants from the clipping context. The correct fix is to removeoverflow-hiddenfrom the wrapper and rely on the inner scroll container'soverflow-auto+rounded-b-xlclasses to clip table content at the card's rounded corners. Therounded-b-xlborder on the wrapper still renders correctly withoutoverflow-hidden. Never re-addoverflow-hiddento any grid card div that also contains aGridFilterDrawersibling. MultiSelectFilterPanel Width: When renderingMultiSelectFilterinside theGridFilterDrawerpanel (not inline in a toolbar), you MUST passfullWidthprop to expand the trigger button to 100% container width. Without it, the component renders asinline-blockat intrinsic width and looks misaligned in the vertical panel layout. ThefullWidthprop switches the root div frominline-blocktoblock w-full.- Controlled
<details>Accordions (No Data-Drivenopen): Never bind theopenattribute 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 auseEffectkeyed 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-depscomment explaining the rationale. Sync the browser's native toggle viaonToggle={(e) => setOpen(e.newState === 'open')}using React 19's typedToggleEvent. Note: The HTMLtoggleevent does NOT bubble, so noe.target === e.currentTargetguard 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 usehasDescriptionContent()fromlib/htmlUtils.ts— never.trim(). TipTap stores empty content as<p></p>which.trim()incorrectly evaluates as truthy. TipTap SSR: When usinguseEditorfrom@tiptap/reactin Next.js, you MUST passimmediatelyRender: falsein the editor config to suppress noisy SSR hydration warnings. - 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'sgetSnapshotstability 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 inuseShallowfromzustand/react/shallowto preserve shallow referential equality. - URL Filter Namespace Isolation (
useURLFiltersContract): All calls touseURLFilters(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.,status→ve.status) to prevent cross-view parameter collisions when multiple views serialize filter state to the samewindow.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 callsuseURLFilters, 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.
-
ButtonComponent (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+loadingTextprops instead of manualLoader2spinner swaps. The component handles spinner rendering, button disabling, and text replacement internally. - Escape Hatch: Pass additional
classNamefor one-off overrides (e.g.,className="w-full"orclassName="shadow-lg shadow-teal-500/20"). These merge with base styles viacn(). - Utility:
cn()fromsrc/lib/cn.tswrapsclsxfor conditional class merging. Usecn()instead of raw template literals for any conditional className logic.
- Variants:
-
ModalShellComponent (src/components/ui/ModalShell.tsx): All overlay dialog modals MUST use the shared<ModalShell>wrapper. Never create ad-hocfixed inset-0backdrop + container markup when ModalShell exists.- Props:
isOpen,onClose(required).size(sm|md|lg|full, defaultsm),nested(usesz-[60]instead ofz-50),closeOnBackdropClick(defaulttrue),closeOnEscape(defaulttrue),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-2xlcontainer,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.activeElementisINPUT,TEXTAREA, orSELECT, the Escape handler is skipped to prevent data loss in inline-editable grids (Rule C18 compliance). - Excluded modals:
PdfImportModal(dark-themed mini-app withz-[200]) andProjectEstimateTabinline modals (tightly coupled to local state) are exempt from ModalShell.
- Props:
Architectural Organization & File Structure
- 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. - Domain-Driven Hook Separation: Never create "god files" for data fetching (e.g., a single
useProjectQueries.tscontaining 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. - Custom Hook Extraction for Complex Logic: When a UI component relies on complex data transformation (e.g., chained
useMemoblocks 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. - 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 thedata-table-architectureskill 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
- Row Selection: TanStack-native
rowSelectionstate withCheckboxCell/CheckboxHeaderfromdata-table/cells. Never use Zustand for selection state. - Bulk Actions:
BulkActionBarwith selection count, delete, clear, and optionalextraActionsslot. - Delete Confirmation:
DeleteConfirmModalwith entity-specific labels. - Ghost Row (Quick Add):
GhostRow<T>with domain-specificplaceholder,defaultValues, andstaticFields. - Column Visibility: Per-project Zustand persistence via
Record<string, VisibilityState>. Each grid has its own key (e.g.,gridV2ColumnVisibility,coordColumnVisibility,permitColumnVisibility). - Column Chooser:
ColumnChoosercomponent with reset handler that re-applies domain-specific visibility defaults. - Virtual Scrolling:
@tanstack/react-virtualwithMemoizedRowstructural hash comparison. - Filter Drawer:
GridFilterDrawercomponent withfilterSlot,filterActiveCount, andonClearFiltersprop pattern. - Empty/Loading States:
TableEmptyStateandTableLoadingStatefor 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.