Imported from YosemiteCrew/Yosemite-Crew (
apps/frontend/AGENTS.md). Install upstream withnpx skills add YosemiteCrew/Yosemite-Crew --skill frontend. Copyright stays with the author.
Frontend - Agent Rules
This is the rulebook for automated agents (and human contributors) working in apps/frontend, the Yosemite Crew (YC) Next.js web app. It covers the design system, styling, state, SonarQube, and testing conventions this app enforces. For the shared architectural boundaries see docs/FRONTEND_ARCHITECTURE.md; for the general quality bar see docs/FRONTEND_QUALITY_GUIDE.md.
Inherits all root AGENTS.md rules. This file adds frontend-specific rules.
Stack: Next.js 15, React 19, TypeScript, Tailwind CSS 4, Zustand (client-state library), Jest + RTL (React Testing Library).
Design System - Reuse First
Before writing any new UI, check src/app/ui/ for existing components:
src/app/ui/
Button.tsx variants: primary | secondary | danger
Card.tsx variants: default | bordered | subtle
Badge.tsx non-status labels (status chips use primitives/StatusPill)
Input.tsx base input
Stack.tsx flex layout helper
Text.tsx typography
inputs/ Datepicker, Dropdowns, Search, FileInput
cards/ Appointment, Inventory, Forms
tables/ DataTable variants
overlays/ Modal, Toast, Loader
layout/ Header, Sidebar
primitives/ low-level Buttons, Icons
filters/ form/inventory filters
avatars/ avatar components
board/ kanban/board pieces
icons/ icon components
theme/ theme helpers
widgets/ composite widgets
(Tree drifts — ls src/app/ui/ for the current contents.)
Import from barrel: import { Button, Card } from '@/app/ui'
Design-token source of truth: src/app/globals.css. src/app/ui/tokens.md is reference material only. Never hardcode colors.
There are two token layers and they must agree. The @theme block defines
--color-*, which is what Tailwind utilities compile against (text-blue-text,
bg-card-hover). The warm-bone layer below it defines the short runtime names
(--blue, --ink, --screen, --hairline) used by var(--x) and arbitrary
values like text-[var(--ink-muted)]. The short forms outnumber --color-* in
the app roughly 3:1, so "use --color-*" is not the rule - use whichever
layer the surrounding code uses, and never let the two spellings of one concept
hold separate literals. Alias one to the other (--blue-text: var(--color-blue-text)), because a token maintained by hand in both places
drifts: --color-blue-text sat at var(--blue) and kept resolving to a
sub-AA value after --blue-text had been fixed.
Every ink token must clear AA on the DARKEST surface it can land on. The
bone palette's darkest is --band (#e8e0d2), not --screen, so checking
against --screen alone passes things that fail in the wild. The light ramp is
--ink > --ink-body > --ink-soft > --ink-muted (5.31) > --ink-faint
(4.56, the lightest value the palette allows at AA). There is no room below
--ink-faint - --ink-faint2 is the same value for exactly that reason. If you
want text fainter than --ink-faint, the answer is not a lighter ink; it is
less text, or a larger size, or a different surface.
That 4.56 ramp is the app value. The faint inks are the one deliberately
two-valued pair in the palette: PIMS gets #66635f under
body:has([data-yc-app]), while :root keeps #8f8984 for the marketing
pages, whose --spot sections are always dark and would drop to 2.85:1 if
darkened. Three consequences, all of which have already shipped as bugs:
- The hook is on
bodyvia:has(), not on the shell element, because overlayscreatePortalout of the shell and would keep the wrong value. - The TEXT-SEMANTIC aliases must be re-declared with the inks, since an
alias resolves where it is declared - on
:root- and will not recompute just because its dependency changed lower down. That is--color-text-tertiary,--color-text-extra,--color-grey-text,--color-grey-bg,--black-grey. The RAW ramp steps (--color-neutral-500/-600) are deliberately left alone: they also backborder-neutral-500and the scrollbar thumb, so scoping them would darken borders to fix text. Text belongs on a--color-text-*token, never on a raw ramp step -appScopeAliasClosure.test.tsenforces both halves. - Opacity composites text too. A faint ink that passes at full strength
fails behind
opacity, and the ramp bottoms out with no headroom: at 0.45#66635fis 1.81:1 on--band, and no alpha below 1.0 gets it back to 4.5. Recede a control with a lighter INK, not with opacity.
See src/app/ui/tokens.md for the value table and the guard.
Column headers come from one recipe. <GenericTable> for real tabular
markup; <TableHead> (src/app/ui/tables/TableHead.tsx) for grid or flex
shells. Do not hand-roll a --screen-2 band with uppercase micro-type - PIMS
grew five separate recipes that way, and a single page was rendering three of
them. src/app/__tests__/ui/tables/tableHeadConsistency.test.ts fails the build
if you do, and Tables/TableHead in Storybook stacks the table header against
the shell header so drift shows up as a Chromatic diff. If a shell needs the
band but NOT the header type - because its labels are proper names rather than
column nouns - take the band alone; the recipe's uppercase would eat them.
Fill tokens and text tokens are not interchangeable. --blue is a fill and
carries no contrast duty; --blue-text must clear 4.5:1 on the bone surfaces.
A fill that carries white text has two duties at once - 4.5:1 for the label and
3:1 against its own surface, since a selected control may drop its border and
let the fill alone signal state. That is what --blue-strong is for.
Styling Rules
- Tailwind CSS 4 only for new code. No new Bootstrap classes.
- Use
clsxfor conditional classes. - Fonts: Satoshi for body/UI; Newsreader (
--font-newsreader) is the display serif for page titles and marketing moments. Never default to Inter or system fonts for new UI. - No arbitrary Tailwind values (e.g.
w-[347px]) without a clear reason.
UI Copy Rules
- Never render backend/raw enums or acronyms in UI copy (example:
PAYMENT_AT_CLINIC,VET). - Always transform technical values to user-friendly text before render.
- Never use
Actoras a label in cards, tables, or details panes. Prefer contextual labels such asLead,Support, or neutralUpdated by.
State Management
Zustand stores in src/app/stores/. One store per domain. Do not introduce new state libraries.
Available stores (26 — list drifts, enumerate src/app/stores/ before adding one): appointment, appointmentWorkspace, auth, availability, companion, counter, document, forms, fullscreenLoader, integration, inventory, invoice, org, parent, profile, revampCatalog, room, routeLoader, search, service, signingOverlay, speciality, subscription, task, team, universalSearch. Never duplicate a store — check this directory first.
SonarQube - Non-Negotiable Rules
Full rule set in .claude/skills/frontend-sonar/SKILL.md. Summary of the most commonly violated:
useStatemust be destructured:const [value, setValue] = useState(...).- No
const [, setter]- useuseRefwhen the value is never read. - No
<div role="...">where a native HTML element exists. Use<dialog open>not<div role="dialog">. - Non-interactive
<img>,<div>,<span>withonClick- wrap in<button type="button">withdisabledprop instead. Only fall back torole+tabIndex+onKeyDownwhen a<button>would break layout. - Cognitive complexity ≤ 15. Nesting ≤ 4 levels. No nested ternaries in JSX.
- Deep
setStateupdater callbacks - extract named handler functions before the JSXreturnrather than nesting lambdas insideonChange. - Deep
.map()insidesetState- extract inner transform to a module-level named function. - High-complexity guard functions - extract sub-branches (e.g. unverified-owner logic) into named helper functions defined before the main function.
- Nested ternaries inside prop values → extract to a named module-level helper function, not an inline const.
- Raw text node adjacent to a sibling JSX element → wrap the text in a JSX expression:
{"Label"}not bareLabel. - Arrays only used for
.includes()→ convert toSetand use.has(). - Use
globalThis.windownot barewindow. UseNumber.isNaNnotisNaN. - Prefer positive conditions in ternaries -
=== undefined ? fallback : valuenot!== undefined ? value : fallback. - Use
??not||when left side is an optional chain. - Merge duplicate CSS selector blocks into one. Remove duplicate
if/else ifbranches that set the same value. - Remove every unused import individually. Never leave dead imports.
- Prefer
.at(-1)overarr[arr.length - 1]. Avoidelse { if (...) { } }- collapse toelse if. - Remove empty object spreads. Use canonical Tailwind class names (e.g.
h-25noth-[100px],z-5000notz-[5000]).
Repeating Sonar Patterns From Recent Audits
- Nested ternaries in JSX or prop values should become named helpers or extracted statements.
- Functions over cognitive complexity 15 should be split before you patch local branches.
- Deep callback logic inside
onChange,.map(), orsetStateupdaters should move into named helpers. - Negated guards and
else { if (...) }chains should be rewritten as direct positive branches. - Arrays used only for membership checks should become
Setlookups. - Bare
windowaccess should becomeglobalThis.window; optional-chain fallbacks should use??. - Redundant assertions, duplicate imports, and dead imports should be removed in the same cleanup.
- ARIA listbox/option shims should be replaced with native controls when feasible.
- Inline text next to JSX siblings should be wrapped in
{"..."}to avoid ambiguous spacing warnings. - Empty spreads and other no-op expressions should be deleted, not left as-is.
Latest batch (rows 145-166) - specific fixes + test fallout
role="option"on a rich-content button (S6819) → usearia-pressed(toggle button) for multi-select; single-select options become plain<button>. Update the matching test query in the same change:getByRole('option', …)→getByRole('button', { name, pressed })orgetByRole('button', { name }).Do not pass function directly to .reduce(…)(S7060) → wrap as(acc, item) => fn(acc, item); if that wrapper then breaks nesting > 4 inside a promise chain, hoist a module-levelmapXToYhelper and call it from a flat.then.- Arrow function with > 7 params (
S107) → convert to a single typed props object; update the call site to an object literal. - "Conditional returns the same value either way" (
S3923) is a Bug, not a smell - both branches are identical; collapse to one expression. - Inline
Pick<T, 'a' | 'b'>param type flagged as a union → extract a namedtypealias above the function. [object Object]guards must preserve prior falsy behaviour: atypeof === 'string' | 'number'guard does not treat''/0as empty likeif (value)did - add an explicit empty check so tests still get[].- Component-body cognitive complexity driven by JSX → extract the conditional subtree into its own component + a
getXClassNamehelper, and move keyboard/active-index logic into a custom hook. - Store updaters built from repeated
x && x.length > 0 ? x : enc.x→ collapse with a genericpreferNonEmpty(next, current)helper (covers the inverteddocumentscase too). - Portal dropdowns (
LabelDropdown/MultiSelectDropdown) do not render their option panel in jsdom (portalStyle stays null). Tests selecting an option must mock the dropdown to render options/placeholder inline - neverfindByRolea real portal option. Watch placeholder renames flowing into those mocks.
After any change: npx tsc --noemit + pnpm --filter frontend run lint.
Testing
# Prefer targeted Jest runs during development. Full Jest runs are allowed when the user explicitly asks, when validating repo-wide failures, or when changing shared test infrastructure. Playwright and accessibility runs are allowed whenever relevant.
pnpm --filter frontend run test -- --testPathPatterns="ComponentName"
Coverage Mandate - Non-Negotiable
Target: ≥ 95% Statements, Branches, Functions, Lines. Every change must move coverage upward, never downward.
- Any file you touch must finish with equal or higher coverage than you found it.
- Any file you create must hit ≥ 90% on first commit - no new file ships without tests.
- When you delete code, delete the matching test code too.
- When you modify behaviour, update existing tests for the changed path AND add new cases for new branches.
- Snapshot tests do not substitute for behavioural assertions - every logical branch needs at least one outcome assertion.
All four test layers must grow together
| Layer | Tool | When required |
|---|---|---|
| Unit | Jest | Every service, store, hook, utility, helper |
| Component | RTL | Every UI component - render + interaction + conditional rendering |
| Snapshot | Jest toMatchSnapshot |
Stable layouts - complement behavioural tests, never replace them |
| E2E | Playwright (e2e/) |
Auth, booking, checkout, payment, and any critical user journey |
Coverage check workflow
# Verify coverage for the file(s) you changed:
pnpm --filter frontend run test -- --testPathPatterns="<YourFile>" --coverage --collectCoverageFrom="src/app/path/to/YourFile.tsx"
# If Statements/Branches/Functions dropped, add tests before declaring done.
New code = new tests (mandatory)
Every new file you add must ship tests in the same commit batch. No exceptions.
| New code | Required test |
|---|---|
| Service function | Jest unit: success + all error branches (axios, non-axios) |
| Zustand store | Jest: every action, selector, guard, and edge case |
| Custom hook | renderHook covering all return values and state branches |
| Utility / lib function | Jest unit with full branch coverage |
| UI component | RTL render + at least one user interaction test |
| E2E-critical flow (auth, booking, checkout) | Playwright test in e2e/ |
Coverage bar for new files: Statements ≥ 90%, Branches ≥ 90%, Functions ≥ 90%. Never leave an existing file in a worse coverage state than you found it.
Mandatory pre-commit checks (run in order, never skip)
# 1. Type check - run with a 120s timeout; if it times out tell the user, never silently skip
npx tsc --noemit # from apps/frontend/
# 2. Lint
pnpm --filter frontend run lint
# 3. Targeted tests - check src/app/__tests__/ for matching test file before running
pnpm --filter frontend run test -- --testPathPatterns="<YourFile>"
When modifying an existing file, check whether a test file already exists for it in src/app/__tests__/ (mirroring the source path). If it does, run it and fix any failures your change introduced before declaring the task done. A change is not complete if it breaks existing tests.
Always report actual test output at each COMMIT CHECKPOINT. Never fabricate or omit results. If tsc times out, say so. If no test file exists for a touched file, say so - do not silently skip.
- Mock react-icons as
<span>, not<button>. await act(async () => { ... })for async state updates.- Reset Zustand stores in
beforeEach. - DOM nesting warnings are test failures in this repo.
- When a hook calls
useXxxStore.getState()directly, the store mock must exposegetStatetoo. UseObject.assign(jest.fn(), { getState: jest.fn() })in factory mocks, or attach(useXxxStore as any).getState = mockGetStateinbeforeEachfor auto-mocks. Seefrontend-testingskill for full patterns.
Test TypeScript rules
- No
require()in test bodies - ESLint rule@typescript-eslint/no-require-importsblocks it. Always use top-level ES imports and cast:(fromFormRequestDTO as jest.Mock).mockImplementationOnce(...). jest.resetAllMocks()wipes factory mock defaults - re-initialize all.mockReturnValue()calls inbeforeEachafterresetAllMocks().axios.isAxiosErrormock - usejest.mock("axios", () => ({ isAxiosError: jest.fn() })), notjest.spyOn. Cast as(axios.isAxiosError as unknown as jest.Mock).- Read-only DOM properties - use
Object.defineProperty(el, 'scrollTop', { value: 0, writable: true, configurable: true }), notObject.assign. - Type casts that don't overlap - add
unknownas intermediary:as unknown as TargetTypeinstead of directas TargetType. Tasktype uses_id, notid- always use_idwhen constructing partial task fixtures.RoleCodeonly includes'OWNER'and'ADMIN'- use'MEMBER' as anyfor other role strings in tests.
What NOT to Do
- Do not nest
<button>inside<button>. - Do not add new
eslint-disablecomments. - Do not import the same module twice in one file.
- Do not add new shadcn, Radix, or Material-UI imports - not in this stack.
- Do not use the
voidoperator on promises.
