Appearance
Coding Standards
What reviewers enforce. These are the day-to-day rules; the architecture pages explain why.
Definition of Done
A change is done when all of these hold:
- Implemented to standards.
- Unit + smoke tests green (+ integration/E2E where relevant).
- Security & lint gates pass.
- Performance budget respected.
- Docs / ADR updated ("if it isn't documented, it isn't done").
- Observability added for new surfaces.
- Migrations expand-contract and promoted to both Supabase projects.
- Tracker (
QRS-###) updated. - Verified end-to-end in-app, not just via tests.
The hard rules [ENFORCED]
| Rule | Meaning |
|---|---|
No supabase.from() in src/ | Reads → RPC in hooks; writes → EF in services. Data Access. |
| No inline EF names | EF names come from a per-feature EDGE_FN map (UPPER_SNAKE_CASE). |
| One Supabase client | src/lib/supabaseClient.ts; no second createClient; no hardcoded prod fallback URL. |
| Tier boundaries | public ⇏ user,admin; user ⇏ admin. Features import UI from their tier's components/ui/. |
| Zero hard-coded colors | In class strings (chart stroke/fill excepted). Theme via cn() + isLight/tokens. |
| No per-page theme branching | Light/dark driven centrally. |
| RLS on every user-facing table | Plus the EF as primary write gate. |
Naming conventions [ENFORCED]
| Thing | Convention | Example |
|---|---|---|
| Component | PascalCase | ReminderRow.tsx |
| Hook | use + camelCase | useReminders.ts |
| Data-layer contract | {Domain}Service in packages/data/src/{domain}/service.ts (+ service.stub.ts) | remindersService |
| Table | snake_case, domain-specific | reminder_occurrences, setu_cards |
| RPC | one of five families — see below | get_industries, resolve_features |
| Edge Function | kebab domain-action (Type A) / domain-noun-verb (Type B) | manage-reminder |
EDGE_FN key | UPPER_SNAKE_CASE, in a per-domain *_EDGE_FN map | REMINDERS_EDGE_FN.MANAGE_REMINDER |
| Migration | 14-digit YYYYMMDDHHMMSS_name.sql | 20260808100000_v2_identity_and_tenancy.sql |
| ADR | NNNN-kebab-title.md | 0020-platform-schema-first-principles-redesign.md |
| Tracker id | QRS-### | QRS-042 |
Examples corrected 2026-08-08
This table previously illustrated itself with MenuItemCard.jsx, useDigitalMenu.js, digital_menus and public_page_ops_cache_refresh — a .jsx component from the retired SPA, and two table/function families that were dropped from Dev on 2026-08-08. A naming table is copied verbatim more often than almost any other page, so a stale example here propagates.
SQL functions — FIVE families, not one
The prefix tells a reader what kind of work happens. Anything outside these five is a naming violation, not a sixth family.
| Family | Means | Examples |
|---|---|---|
get_* | Retrieve stored rows, possibly projected | get_industries · get_public_setu_card · get_my_features |
resolve_* | Derive an answer by computation over several sources | resolve_features · resolve_workspace_plan |
is_* | Predicate returning a bare boolean | is_slug_reserved |
my_* | RLS helper scoped to auth.uid(), called from inside a policy | my_workspace_ids · my_oversight_workspace_ids |
{table}_{action} | Trigger function, named for what it guards | workspaces_maintain_path · setu_cards_slug_write_once |
resolve_* and my_* earn their place rather than merely differing: resolve_* signals the result is computed and not stored, so nobody goes looking for a table behind it; and my_* reads correctly at the call site inside a policy — workspace_id = any (my_workspace_ids()) — where get_my_* would add noise to the most performance-sensitive expressions in the schema.
Table names must be domain-specific, never generic
The test is "will a second thing plausibly want this name?" QRSETU expects to grow vCards, invitation cards and event cards, so cards was ambiguous on arrival and became setu_cards. A dairy has meal plans and a gym has membership plans, so plans became platform_plans. And primitives collided with apps/mobile/src/ui — the systemic RN primitive set — so it became process_primitives: the same word meaning two unrelated things inside one repo.
Column names stay short (archetype_key, plan_key): the table name carries the disambiguation, and a business_archetype_key column would be verbose without adding clarity.
Consistency
- New code reads like surrounding code — match idiom, structure, naming.
- No speculative abstractions; no "temporary" hacks on release branches.
- No regressions. Surface bugs/debt/risks and confirm before non-trivial or cross-cutting changes.
- Validate each step (tests/build) before the next.
TypeScript [ENFORCED — TS-first]
.ts/.tsxfor logic; keep UI glue idiomatic.- Shared, platform-agnostic core (types, Zod schemas, contracts, pure logic) — no react-dom/DOM imports — so web and future native both consume it.
- Convert
.js/.jsxopportunistically during retrofit.
Lint & hooks
- Flat config
eslint.config.mjs— keepsno-undef/import/no-self-importas errors; some noisy rules disabled deliberately (read the inline comments before re-enabling). - Husky
pre-commitrunstype-check && lint(project-wide). Planned:lint-staged+ affected tests. - Planned guardrail rules: no
from()insrc/, no inline EF names, no secondcreateClient, tier import boundaries, no hard-coded colors, no per-page theme branching, +eslint-plugin-sonarjs/eslint-plugin-security.
Principal-Architect stance
Evaluate every change as a Principal Solution Architect: surface better alternatives, call out debt/risk, and log every confirmed finding to the tracker (QRS-###) — log-after-confirmation, never fix-and-forget.
TypeScript (operating-manual text)
Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)
This is the verbatim text of CLAUDE.md § "TypeScript" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.
TypeScript [ENFORCED — TS-first]
TS-first with a shared, platform-agnostic core (types, Zod schemas, service/data-access contracts, pure logic — no react-dom/DOM imports) so web and the Expo app both consume it. .tsx/.ts for logic; keep UI glue idiomatic. TypeScript is non-negotiable for all new code; a phased JS→TS migration roadmap covers the remaining core modules (opportunistic + planned), with Digital Menu on its own R2 migration (excluded from R1 and re-homed onto the E-commerce archetype — ADR-0009). Archetype definitions, Zod field schemas, and entitlement/vertical resolvers are TS in the shared core from day one. (This deliberately diverges from NEFOXX's "no TypeScript" rule — invert only that; keep every other NEFOXX guardrail.)
Feature-scoped naming (QRS-436, operating-manual text)
Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)
This is the verbatim text of CLAUDE.md § "FEATURE-SCOPED NAMING" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.
FEATURE-SCOPED NAMING [ENFORCED — non-negotiable, gated, owner instruction 2026-08-09, QRS-436]
Name the FEATURE, never the shape. The rule above covered tables; this generalises it to every artifact — files, directories, exported identifiers, schemas, Edge Functions, npm scripts, hooks, types, React components. A name may not describe only what a thing is (Template, Card, Block, Manifest) when it must also say what it belongs to (SetuCardTemplate).
Why this is a top-level rule and not a style preference. Generic naming has had to be swept out of this repo four times, and every single one was found by a human reading a filename — never by a gate, and never at the moment the name was introduced:
| Word | The collision |
|---|---|
cards | vCards, event cards, greeting cards and visiting cards are all planned → setu_cards |
plans | a dairy has meal plans, a gym has membership plans → platform_plans |
primitives | THREE live meanings at once: ADR-0020 process primitives, the per-app src/ui component primitives, and @qrsetu/tokens' raw colour ramps → process_primitives / colorPrimitives |
templates | TWO live meanings at once: supabase/templates/ held Supabase Auth's 13 email templates while card-template.ts held Setu Card manifests — and npm run check:templates validated only the second |
By the time a reader notices, the name has spread: templates had reached 254 live code occurrences and 358 portal ones before anyone said the word out loud. Renaming at the moment of introduction is one keystroke; renaming later is a cross-cutting sweep. That asymmetry is the whole argument.
The decidable line — this is what the gate enforces, and why it does not just ban a word list:
A name that CROSSES a module boundary must carry its feature scope. A name already scoped by the directory it lives in need not repeat it.
So features/setu-card/blocks/HeaderBlock.tsx is correct — the path travels with the reader, and SetuCardHeaderBlock inside setu-card/blocks/ would be noise. But import type { CardBlock } from '@qrsetu/schemas' is wrong: at that call site there is no path at all, and "Card" could mean any of five planned card products.
What must be feature-scoped: exported packages/* identifiers · directory names · SQL tables, functions, views · Edge Function names · npm scripts · top-level modules. What need not be: local variables, parameters, non-exported helpers, and intra-feature file and component names.
Two deliberate exemptions, because an over-broad rule is one that gets bypassed:
- SQL COLUMN names stay short — the table carries the disambiguation (see the rule above:
archetype_key, neverbusiness_archetype_key).setu_cards.template_keyis correct, and flagging it would put the gate in conflict with this file's own documented convention. - Genuinely cross-feature concepts keep a neutral name where the neutrality is the point — a design token (
Palette,SemanticSet) is consumed by every card type, so scoping it to one would be wrong. Add such a prefix toQUALIFIED_PREFIXESintools/check-naming.jswith a written reason, never silently.
Forward-looking, because the expansion is already planned. Everything card-shaped in the repo today is the Setu Card, and there is no second card product yet. Name as if there were: SetuCardTemplate / EventCardTemplate / VCardTemplate, setu_card_templates / event_card_templates. Do not build a shared abstraction across card types that do not exist — an event card's blocks (date, venue, RSVP) share almost nothing with a Setu Card's, so a generic CardTemplate with a cardType discriminator would be the branch-on-type sprawl ADR-0021 D4 bans elsewhere. Each card product gets its own contract when it arrives.
Enforcement — four layers, because a standard with no gate decays (this file's own repeated lesson):
npm run check:naming(tools/check-naming.js) — N1 package exports · N2 directories · N3 npm scripts · N4 SQL objects. Incident-driven root list; each entry names its collision. Mutation-tested in both directions (tools/check-naming.test.mjs, 10 cases) per QRS-013.- Claude Code hook (
tools/hooks/on-naming-surface-edit.mjs) — fires the moment a boundary-crossing file is edited, so the feedback lands while the reasoning is still in context rather than at commit. Unit-tested vianpm run test:hooks. - pre-commit and pre-push, then CI (
ci.yml) as the authority. npm run check:docscarries a retired-vocabulary group so the generic word cannot return to the portal.
⚠ The gate earned its place on first run: it immediately found export ArchetypeKey in packages/data (now BusinessArchetypeKey) and export const primitives in @qrsetu/tokens — the third simultaneous meaning of that word, in a repo whose own CLAUDE.md already documented the other two.
Lint and format (operating-manual text)
Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)
This is the verbatim text of CLAUDE.md § "Lint & format" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.
Lint & format [ENFORCED — mandatory, whole-tree, warnings = errors]
Every line committed is linted and format-checked — no opt-in, no silent skips. The monorepo lints as one central eslint . at the repo root (root npm run lint = eslint . --max-warnings=0), NOT a per-workspace fan-out — so coverage is the default and a new file/workspace can never escape the gate. (This replaced a lint --workspaces --if-present that matched zero workspaces and passed CI as a green no-op — a false gate; see the dev-tracker.) Rules live in the shared @qrsetu/eslint-config package as composable layers (base = ESLint + typescript-eslint + import recommended · reactWeb/reactNative idioms · node · guardrails · prettierCompat last); the root eslint.config.mjs only scopes each layer to its path. Deno edge functions (supabase/**) are excluded from ESLint. ⚠ deno lint is wired NOWHERE — zero hits across .github/workflows/, .husky/ and package.json — so supabase/** is currently linted by nothing (QRS-327, still open, and this sentence has asserted the opposite for months).
- Commands (monorepo root):
npm run lint(gate) ·npm run lint:fix·npm run format(Prettier write) ·npm run format:check(gate). Formatting is Prettier (.prettierrc.json), separate from lint; both are required CI checks (ci.yml) alongsidetype-check+test. - Pre-commit: husky (v9) runs seven gates —
check:readmes,check:parity,check:naming,check:docs,check:portal-nav,check:screens,check:arch-proposal— then lint-staged (⚠ this named onlycheck:readmesuntil 2026-08-28) (ESLint--fix --max-warnings=0+ Prettier on staged files only — fast); the full-tree gate + project-wide type-check run in CI. - Active guardrails (
@qrsetu/eslint-config/guardrails.js): no hard-coded colors inapps/**(use tokens; chart/ illustration fills are the sanctioned exception — disable inline with a reason), tier import boundaries (mobileuser ⇎ admin), package purity (packages/toolingnever import app@/code). Deferred (added WITH the code they guard, not speculatively): nofrom()in app code (withpackages/data), no inline EF names (with the service layer), the fullschemas→domain→dataDAG. ⚠eslint-plugin-sonarjsandeslint-plugin-securityare INSTALLED AND WIRED — both are inpackage.jsonandtooling/eslint-configexports asonarlayer. This said "still planned" until 2026-08-28, while the same file described the ~217-rule sonarjs gate 35 lines later. - Legacy note: the retired Vite SPA under
legacy/keeps its owneslint.config.mjsand is not linted by the root gate (ignored) — it is reference only.
Path aliases and TS config (operating-manual text)
Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)
This is the verbatim text of CLAUDE.md § "Path aliases & TS config" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.
Path aliases & TS config
Each app owns its own @/*. apps/mobile: @/* → apps/mobile/src/* and @/assets/* → apps/mobile/assets/* (apps/mobile/tsconfig.json, extending expo/tsconfig.base; strict). It also sets allowImportingTsExtensions because @qrsetu/domain's internal relative imports carry explicit .ts extensions so node --test resolves them with no bundler and no added dependency (QRS-212) — tsc follows those while checking the app, so the flag has to be set on both sides. Shared base configs live in tooling/typescript-config. Cross-package imports always go through the @qrsetu/* workspace name, never a relative path out of a package.
Every TS workspace type-checks INDEPENDENTLY [QRS-015]. Root type-check used to fan out --workspaces --if-present while only apps/mobile and packages/domain defined the script, so the other seven packages were checked only transitively — through whatever the app happened to import. All ten now own a tsconfig.json + type-check (apps/web arrived after this sentence was written). (tooling/* is JS/JSON only and correctly has none.)
packages/* deliberately have NO DOM and NO Node types. tooling/typescript-config/base.json sets lib: ["ES2022"] and no types, which is what makes a stray fs/Buffer/__dirname in a platform-agnostic package a compile error (ADR-0012). That guardrail was untested until QRS-015, and four packages then failed on console, process and setTimeout. Adding @types/node would have deleted the guardrail to silence the symptom — instead tooling/typescript-config/platform-globals.d.ts declares exactly the cross-runtime subset (Hermes + browser + Node) and nothing more, and each package includes it. packages/domain is the exception: it sets "types": ["node"] for its co-located node:test files, so it must NOT also include that file (duplicate console declarations are an error).
legacy/ keeps its own vite.config.js / jsconfig.json / components.json (shadcn "new-york") — reference only.
Naming conventions — the operating-manual statement
Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)
This is the verbatim text of CLAUDE.md § "Naming conventions [ENFORCED]" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.
Naming conventions [ENFORCED]
Components PascalCase (ReminderRow.tsx) · hooks use + camelCase (useReminders.ts) · data-layer contracts {Domain}Service in packages/data/src/{domain}/service.ts (+ service.stub.ts) · tables snake_case (reminder_occurrences) · RPCs get_{entity} / get_{feature}_summary (get_reminders) · Edge Functions kebab domain-action / domain-noun-verb (manage-reminder) · EDGE_FN keys UPPER_SNAKE_CASE · migrations YYYYMMDDHHMMSS_name.sql · tracker ids QRS-### · ADRs NNNN-kebab-title.md.
SQL function naming — FIVE families, not one [amended 2026-08-08 after a compliance audit]. The line above said only get_{entity}, and the v2 baseline has twenty functions in five distinct families (⚠ "fifteen" until 2026-08-28). Rather than force every one into get_* — which would have made a boolean predicate and a multi-scope resolver look like a row fetch — the convention is widened, because the prefix should tell a reader what KIND of work happens:
| Family | Means | Examples |
|---|---|---|
get_* | Retrieve stored rows, possibly projected | get_industries · get_platform_plans · get_public_setu_card · get_public_catalogue · get_my_features |
resolve_* | Derive an answer by computation over several sources | resolve_features (8 scopes × 3 axes) · resolve_workspace_plan |
is_* | Predicate returning a bare boolean | is_slug_reserved |
my_* | RLS helper scoped to auth.uid(), called from inside policies | my_workspace_ids · my_oversight_workspace_ids · my_shared_org_ids |
{table}_{action} | Trigger function, named for what it guards | workspaces_maintain_path · setu_cards_slug_write_once · industries_validate_primitives |
Two of these earn their place rather than merely differing: resolve_* signals that the result is computed and not stored, so a reader does not go looking for a table behind it; and my_* reads correctly at the call site inside a policy (workspace_id = any (my_workspace_ids())), where get_my_* would add noise to the most performance-sensitive expressions in the schema. Anything outside these five is a naming violation, not a sixth family — pick the one that describes the work.
Table names must be domain-specific, never generic [ENFORCED — 2026-08-08]. cards became setu_cards, plans became platform_plans, primitives became process_primitives, archetypes became business_archetypes. The test is "will a second thing plausibly want this name?" — QRSETU is expected to grow vCards, invitation cards and event cards, so cards was ambiguous on arrival; a dairy has meal plans and a gym has membership plans, so plans was too; and primitives collided with apps/mobile/src/ui, which this file itself calls "the systemic RN primitive set" — the same word meaning two unrelated things inside one repo. Column names stay short (archetype_key, plan_key, primitive_key): the table name carries the disambiguation, and a business_archetype_key FK column would be verbose without adding clarity.