# Pack: frontend / client state / UI build

Load when: the touch list exhibits Redux Toolkit (`createSlice`, `configureStore`), RTK Query tags, react-router route tables, Tailwind 4 CSS (`@theme`, `personality.css`, `globals.css`), Vite config (`resolve.alias`, env), `@nurix/components` imports, localforage/redux-persist, or date handling crossing the wire.

## redux-persist-shape-migration — CRITICAL · serialized-shape
**Contract:** State shapes frozen in the user's browser storage by redux-persist must be readable by the *current* reducers' `initialState` — `version`/`migrate` is the only bridge, and `whitelist`/`blacklist` reference reducer-map keys by literal string.
**Detect:** `persistReducer(`, `persistStore(`, `whitelist:`, `blacklist:`, `createMigrate(`, `version:`, `stateReconciler`, storage keys `persist:*`
**Ships green, breaks:** Default reconciler `autoMergeLevel1` replaces each persisted top-level slice wholesale — a field added to a persisted slice's `initialState` rehydrates as `undefined` for every returning user (fresh installs and dev with cleared storage look fine). Default `version` is `-1` and with no `migrate` supplied nothing transforms old shapes. Renaming a reducer-map key silently drops it from `whitelist` — the slice just stops persisting. The bad merged shape is re-persisted on the next write, so corruption sticks.
**Safe change:** Bump `version` + add a `createMigrate` step for every persisted-slice shape change; grep `whitelist`/`blacklist` when renaming reducer keys; test by rehydrating a captured copy of old storage, never a fresh state.

## vite-env-client-exposure — CRITICAL · trust-invariant
**Contract:** The `VITE_` name prefix (or `envPrefix`) is simultaneously the exposure gate into the public bundle and the rendezvous for `import.meta.env.VITE_X` reads — naming decides secrecy.
**Detect:** `import.meta.env`, `.env*`, `envPrefix`, `define:` in `vite.config.*`, grep `VITE_.*(SECRET|KEY|TOKEN|PASSWORD)` in `.env*`
**Ships green, breaks:** Any `VITE_`-prefixed value is string-inlined into shipped JS — `VITE_API_SECRET=…` is world-readable with zero warning (fails open). Non-prefixed vars silently read `undefined`. Env access is statically replaced at build: `import.meta.env[key]` dynamic access works in dev and returns `undefined` in production; `import.meta.env.BASE_URL` must appear verbatim (no destructuring, no aliasing to a variable) or it is never replaced.
**Safe change:** Audit `.env*` for secret-shaped `VITE_*` names before every release; keep server secrets unprefixed and server-side only; access env only as full literal `import.meta.env.VITE_X` expressions; verify by grepping the `dist/` output for the value.

## localforage-store-identity — CRITICAL · rendezvous-string
**Contract:** The `(driver, name, storeName)` triple in `localforage.config()`/`createInstance()` is the *address* of persisted user data — new code must present the identical triple to find old users' data.
**Detect:** `localforage.config(`, `localforage.createInstance(`, `name:`, `storeName:`, `setDriver(`, `INDEXEDDB|WEBSQL|LOCALSTORAGE`
**Ships green, breaks:** Changing `name` (default `"localforage"`) or `storeName` (default `"keyvaluepairs"`) opens a different IndexedDB database/object store — `getItem` resolves `null`, all prior data stranded invisibly; drivers never migrate data between each other. Extra trap: `config()` called after the first data API call **returns** (does not throw) an `Error` object — the reconfiguration silently never applies unless you check the return value. Everything stored is also a serialized-shape: returning users rehydrate last year's object shape into today's code.
**Safe change:** Treat `name`/`storeName` as frozen identifiers; to move, write read-old-store→write-new-store migration code; call `config()` once at module init before any data call; envelope persisted values (`{v: 2, data}`) and upconvert on read.

## redux-store-shape-selectors — HIGH · rendezvous-string
**Contract:** The keys of `configureStore({reducer: {…}})` ARE the state-tree paths every `useSelector(s => s.key.…)` walks, and `createSlice({name})` is the action-type prefix that string matchers dispatch on.
**Detect:** `configureStore(`, `combineSlices(`, `useSelector(`, `createSlice({`, `extraReducers`, `addMatcher`, action-type literals `'slice/action'`
**Ships green, breaks:** Renaming a reducer-map key while string-path or untyped-JS selectors still read the old key → `undefined` propagates and components render empty, no error (typed `RootState` selectors are the only statically-checked subset). Renaming slice `name` changes every dispatched action type (`user/logout` → `account/logout`) — `extraReducers` string cases, listener-middleware predicates, persistence filters, and analytics stop matching with zero feedback.
**Safe change:** Grep old key and old `name` across the repo including string literals; keep reducer-map key identical to slice `name`; export selectors from the slice file instead of inlining paths in components.

## rtk-query-tag-taxonomy — HIGH · rendezvous-string
**Contract:** `providesTags` on queries and `invalidatesTags` on mutations rendezvous on identical `{type, id}` tuples from `tagTypes` — matched by runtime string equality only.
**Detect:** `tagTypes:`, `providesTags`, `invalidatesTags`, `injectEndpoints`, `enhanceEndpoints({addTagTypes`
**Ships green, breaks:** Invalidating a tag that no query provides is a silent no-op — the mutation succeeds and the cached list stays stale until an unrelated refetch masks it. TS checks the `type` half against `tagTypes` but the `id` half is untyped: `{type:'Post', id:'LIST'}` provided vs a forgotten/misspelled `id` on the invalidate side never match. Endpoints injected via `injectEndpoints` that use tag types nobody added via `addTagTypes` never invalidate anything.
**Safe change:** Define the tag vocabulary once as an exported const and derive `tagTypes` from it; for every mutation, name the exact providing endpoint and diff the full tuple including `id`; verify live via Redux DevTools under `state.<api>.provided` after mutating.

## react-router-route-strings — HIGH · rendezvous-string
**Contract:** The route table (`createBrowserRouter` path strings and `:param` names) and every `<Link to>`, `navigate()`, `useParams().x`, `useSearchParams().get('k')` scattered across components agree on literal strings.
**Detect:** `createBrowserRouter(`, `path:`, `<Route path=`, `useParams(`, `useSearchParams(`, `navigate(`, `<Link to=`, `generatePath(`
**Ships green, breaks:** Renaming `:userId` → `:id` leaves `useParams().userId` as `undefined` — the type is already `string | undefined`, so TypeScript stays green. Moving a path 404s every hardcoded `to="/old/path"` with no build error; renaming a search-param key returns `null` silently and breaks inbound deep links (bookmarks, emails) that still carry the old key. Route typegen exists only in **framework mode** (routes.ts + the react-router Vite plugin); plain react-router-dom 7 library mode with `@vitejs/plugin-react-swc` has zero static route checking — do not assume typegen protects you.
**Safe change:** Centralize paths as builder functions (`routes.user(id)`) and import them for both route table and links; grep the literal path and param name before renaming; treat public search-param keys as a wire contract — support old keys during a deprecation window.

## single-react-copy-alias — HIGH · dual-authority
**Contract:** Exactly one React runtime may own the hooks dispatcher and context registry — `@nurix/*` packages ship a nested `react`, so the app's `vite.config` `resolve.alias` must pin `react`, `react-dom`, `react/jsx-runtime`, and `react/jsx-dev-runtime` to the app's single copy.
**Detect:** `resolve.alias` in `vite.config.*`, `node_modules/@nurix/*/node_modules/react`, `resolve.dedupe`, `npm ls react`
**Ships green, breaks:** Two copies bundle with no warning; symptoms are runtime-only: "Invalid hook call", `TypeError: Cannot read properties of null (reading 'useState')`, and the fully silent one — `useContext` inside a library component resolves against the other copy's context object and returns the **default value**; react-redux 9 surfaces this as "could not find react-redux context value" even though the Provider is right there. Dev (esbuild prebundle) and build (Rollup) can dedupe differently, so it can pass `vite dev` and break `vite build`, or the reverse.
**Safe change:** Alias all four specifiers — the automatic JSX transform imports `react/jsx-runtime`/`jsx-dev-runtime` directly, so aliasing bare `react` alone is insufficient; after any `@nurix/*` or React upgrade, run `vite build && vite preview` and exercise a library component that consumes app context; confirm a single copy with `npm ls react`.

## tailwind-theme-token-rename — HIGH · rendezvous-string
**Contract:** Each `@theme` variable is simultaneously (a) a generated utility family (`--color-brand` → `bg-brand`/`text-brand`/`border-brand`) and (b) a plain CSS variable read via `var(--color-brand)` — one identifier, two independent consumer populations.
**Detect:** `@theme` blocks in `globals.css`/`personality.css`, `var(--color-`, `var(--radius`, grep utility classes built from the token stem
**Ships green, breaks:** Renaming a token compiles clean; every class using the old utility silently vanishes from the generated stylesheet (Tailwind emits nothing for unknown classes — elements just lose styling), and every `var(--old-name)` reader (handwritten CSS, inline styles, the component library) falls back to `initial`. Namespaces gate generation: only recognized namespaces (`--color-*`, `--font-*`, `--spacing-*`, `--radius-*`, `--shadow-*`, …) produce utilities — a token moved to an unrecognized namespace keeps the variable but kills the utility with no diagnostic.
**Safe change:** Grep both consumer forms — utility stems (`bg-old`, `text-old`) and `var(--…-old)` including `node_modules/@nurix/components` dist CSS; keep the old token as an alias for one release; visually diff themed screens after the rename.

## tailwind-theme-inline-indirection — HIGH · config-elsewhere
**Contract:** `@theme inline { --color-primary: var(--primary) }` makes utilities emit the *referenced* variable (`background-color: var(--primary)`), resolved per-element at runtime — the org's theming (personality.css vars swapped under `[data-theme]`/`[data-style]`) only flows through utilities via the `inline` form.
**Detect:** `@theme inline`, plain `@theme {`, token values that are `var(--…)`, `[data-theme=`/`[data-style=` blocks in `personality.css`
**Ships green, breaks:** Adding a var-referencing token to a plain (non-`inline`) `@theme` block compiles and looks correct in the default theme; switching `data-theme`/`data-style` then silently fails to restyle those utilities, because the theme variable captured the referenced var's value as resolved at `:root` — subtree/attribute-scoped overrides never reach it. No warning, no error; only the alternate theme is wrong.
**Safe change:** Any token whose value is `var(--app-var)` goes in the `@theme inline` block, raw literal values in plain `@theme`; after adding tokens, toggle `data-theme` and `data-style` in devtools and confirm the new utilities respond in both states.

## tailwind-source-detection — HIGH · generated-artifact
**Contract:** The generated stylesheet contains only class names that appear as complete, unbroken literal strings in scanned source files — the build-time scanner vs class names assembled at runtime; the default scan set skips `.gitignore`d paths and `node_modules`.
**Detect:** `` `…${ `` inside `className`, `clsx(`/`cn(` with computed strings, class names arriving from API/CMS/config/DB, `@source` directives in CSS
**Ships green, breaks:** `bg-${color}-500` builds and renders unstyled — the utility is simply absent from the CSS. Class names stored in data or composed in unscanned code work only as long as some scanned file coincidentally contains the same literal — deleting that last literal usage breaks a "different" feature. Gitignored source directories are silently excluded from scanning.
**Safe change:** Always write full class literals and select via lookup maps (`{primary: 'bg-blue-600'}[variant]`); safelist genuinely dynamic sets with `@source inline("…")` (v4.1+); add explicit `@source "…"` for out-of-tree or ignored directories.

## dark-variant-data-attributes — HIGH · rendezvous-string
**Contract:** `@custom-variant dark (&:where([data-theme=dark] *))` and the personality selectors `[data-theme="…"]`/`[data-style="…"]` rendezvous on the exact attribute name+value that runtime JS stamps on `<html>` (`document.documentElement.dataset.theme/style`).
**Detect:** `@custom-variant dark`, `[data-theme=`, `[data-style=`, `dataset.theme`, `setAttribute('data-theme'`, `classList.add('dark')`
**Ships green, breaks:** Without the `@custom-variant dark` override, Tailwind's default `dark:` variant is the `prefers-color-scheme: dark` media query — every `dark:` class silently follows the OS setting and ignores the app's toggle entirely. A value or mechanism mismatch (JS writes `class="dark"` while CSS expects `[data-theme=dark]`, or a casing typo) leaves the alternate theme unreachable with zero errors. Org default is light: base tokens must stay scoped `:root, :root[data-theme="light"]` or unstamped pages render the wrong theme.
**Safe change:** Define attribute name and values as one exported constant consumed by the toggle code; add `@custom-variant dark` the moment any `dark:` utility pairs with an attribute toggle; flip the attribute in devtools and verify both directions plus the no-attribute default.

## nurix-components-styles-tokens — HIGH · rendezvous-string
**Contract:** `@nurix/components` is imported by subpath (`@nurix/components/button`) but styled through two separate channels — its own pre-built `styles.css` (imported in `globals.css`) and the app's semantic CSS variables (personality tokens like `--radius-*`, `--color-*`) that its rules read.
**Detect:** `from '@nurix/components/`, the library `styles.css` `@import` line in `globals.css`, grep `var(--` in `node_modules/@nurix/components/**/styles.css`
**Ships green, breaks:** Omitting the `styles.css` import renders every component fully functional but unstyled — no JS error, no build warning, no missing-module failure. Renaming or dropping a personality token the library reads silently degrades components to fallback/`initial` values (e.g. a flat archetype's `--radius-*: 0` re-assertion lost in a token refactor). The library is pre-built, so the app's Tailwind scanner never generates utilities for markup inside it — its shipped CSS is the only style source.
**Safe change:** Keep the `styles.css` import at its mandated position in `globals.css`; before touching any personality token, grep the library's dist CSS for `var(--that-token)`; smoke-render a themed library component after every `@nurix/components` upgrade.

## vite-alias-tsconfig-paths — MED · dual-authority
**Contract:** Path specifiers like `@/lib/x` are resolved by two independent authorities — `tsconfig.json` `compilerOptions.paths` for typecheck/editor and `vite.config` `resolve.alias` for the served bundle.
**Detect:** `"paths":` in `tsconfig*.json`, `resolve.alias` in `vite.config.*`, `vite-tsconfig-paths` in `package.json`
**Ships green, breaks:** A missing Vite alias fails loud; the silent case is *divergent targets* — after a folder move or a one-sided edit, `tsc --noEmit` resolves one file while the bundle executes another, and same-named exports mask it completely. Editor auto-imports follow tsconfig and keep generating specifiers Vite resolves elsewhere.
**Safe change:** Single-source the mapping (`vite-tsconfig-paths`, or generate both from one constant); update both files in the same commit on any directory move; when suspicious, log `import.meta.url` from the module in dev to see which file actually loaded.

## css-import-order — MED · lifecycle-protocol
**Contract:** `globals.css` must import in the fixed sequence `@import "./personality.css"` → `@import "tailwindcss"` → the library's `styles.css` → `@theme inline {…}` — cascade-layer order is fixed at first appearance of each `@layer` name, so file position *is* the styling contract between Tailwind, the component library, and the personality tokens.
**Detect:** first ~10 lines of `globals.css`, `@import "tailwindcss"`, `@import` of `@nurix/components` styles, `@layer` declarations inside the library CSS
**Ships green, breaks:** Any reorder compiles and mostly renders — the breakage is visual-only and theme/element-specific: importing the library's `styles.css` before `@import "tailwindcss"` lets preflight's `@layer base` resets land after (and wipe) library button/form styling at equal specificity, and flips which side wins app-utility-vs-library override wars. Nothing logs; a form-heavy screen just looks subtly wrong.
**Safe change:** Treat the four-line header as frozen; insert new global stylesheets at a deliberate position relative to `tailwindcss` and the library import, never appended blindly; after any reorder, visual-diff a form-heavy screen in every `data-theme`/`data-style` combination.

## date-fns-local-tz — HIGH · serialized-shape
**Contract:** Datetimes cross the wire as UTC ISO strings or date-only `YYYY-MM-DD`, but date-fns `format()` and accessors operate in the runtime's local timezone — serializer and formatter must agree on the zone, and nothing checks that they do.
**Detect:** `format(`, `parseISO(`, `new Date('20` with date-only literals, `@date-fns/tz`, `TZDate`, `{ in:` option
**Ships green, breaks:** `new Date("2026-07-03")` parses as UTC midnight; `format(…, 'yyyy-MM-dd')` in any negative-offset zone renders `2026-07-02` — dates shift a day only for users west of UTC, while CI (UTC) and the developer's machine may pass. Writing the formatted local date back corrupts stored data. date-fns 4's fix is opt-in, not default: pass `{ in: tz('UTC') }` or use `TZDate` from `@date-fns/tz` — plain `format` remains local-zone forever.
**Safe change:** Classify each field: calendar dates stay strings end-to-end (never round-trip through `Date`); instants stay UTC ISO and format with an explicit `in:`/`TZDate`; run tests under a non-UTC zone (`TZ=America/New_York`) to surface the shift.
