diff --git a/docs/superpowers/specs/2026-07-29-modern-layout-capability-design.md b/docs/superpowers/specs/2026-07-29-modern-layout-capability-design.md new file mode 100644 index 00000000..9e5a3d13 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-modern-layout-capability-design.md @@ -0,0 +1,82 @@ +# Modern Layout Capability — Design Spec (Spec 1) + +**Status:** approved-by-delegation (user AFK, full decision delegation; decision rule = "best for tsforge + BoringStack"). Panel-gate + live-build validation substitute for user review. + +**Goal:** Give the tsforge harness a first-class, general understanding of modern web-app **layouts** and **design-system usage**, so builds produce responsive, accessible, themeable UIs that *lean on* BoringStack's existing primitives, and so an app's **primary UI can live outside the generic "dashboard"** with settings demoted to a secondary area. + +**Principle:** Layout + design-system knowledge is a **harness** capability, not a BoringStack change (BoringStack stays minimal). The harness already ships this style of value as front-loaded convention guides + build-time wiring + conditional acceptance; we extend those seams. **Design the model broad** (don't lock into too few layout options); **implement narrow** (ship what the todos app needs, with seams for the rest). + +--- + +## Global Constraints (verbatim, bind every task) + +- **Never relax the gate.** No downgrading rules/severity. Fixes make the model satisfy the gate. +- **Core stays stack-agnostic.** All BoringStack-specific logic lives under `packages/core/src/loop/boringstack/`; generic seams only in the core loop. +- **No `as`/`!` casts, no eslint-disable, cc ≤ 20, shared AST walkers.** Run full `bun run validate` before "done". +- **Lean on what exists — do not reinvent.** Compose BoringStack's CSS-variable design tokens + `components/ui/` (ShadCN/Radix) + `data-theme` theming. Author no new CSS framework, no new component library. +- **Responsive + accessible + themeable are defaults, not options**, in every layout the harness emits. +- **Every harness change is panel-gated** (4-model panel, reviewers ok ≥ 2) **and live-build validated** (solo build on a quiet box). + +--- + +## Reality this builds on (from two read-only audits) + +**Routing (audit 1):** BoringStack routes are already flat/top-level (`/task`, not `/dashboard/task`). The shell is `AppShell` (sidebar + header) at `apps/ui/src/components/core/AppShell/`. Post-login redirect is `DEFAULT_REDIRECT_TO = "/dashboard"` (`apps/ui/src/features/auth/components/LoginPage/LoginPage.constants.ts`). The harness hardcodes every feature → `ProtectedRoute → AppShell → sidebar entry → nav-testid → sidebar-nav e2e` (`wire-resource.ts` `wireUiRouteFile` ~L133-187 `path:"/${camel}"`; `build.ts` `scopeFor` ~L161-179; `refine-prompt.ts` ~L269; `acceptance/testid-contract.ts` ~L130; `acceptance/e2e-generator.ts` nav test ~L681-686). + +**Design system (audit 2):** Tokens in `apps/ui/src/assets/css/tailwind.css` (`:root` + `:root[data-theme="dark"]`, mapped via Tailwind `@theme`): colors `background/foreground/primary(+strong/low/ink/foreground)/secondary(+foreground)/muted(+foreground/strong)/accent(+cyan/pink)/destructive(+foreground)/success(+foreground)/border(+strong)/input/ring/card(+foreground)/panel(+strong)/popover(+foreground)` — note `destructive-foreground` and `success-foreground` DO exist (tailwind.css lines 34/86/132), `--radius*`, `--font-sans` (Inter) / `--font-mono`, `--animate-*`. Components in `apps/ui/src/components/ui/`: Button, Card, Dialog, DropdownMenu, Form, Input, Label, Popover, ScrollArea, Sheet, Skeleton, Sonner, Switch, Tabs (cva + `cn()` + tailwind-merge + Radix + lucide). Theme via `useTheme()` + `data-theme` attribute (NO `dark:` classes; `AGENT_CONTRACT.md` forbids them). Responsive = mobile-first Tailwind + Sheet drawer (`hidden md:flex` sidebar). A11y = `eslint-plugin-jsx-a11y` (16 rules) as ERRORS + Radix + semantic HTML + skip link. Harness `conventions.ts` has 12 topic guides but **nothing on styling/theming/responsive/a11y/composition**. + +--- + +## Part A — Design-system convention guides (codify what exists) + +New harness convention topics (front-loaded like the existing 12 in `packages/core/src/loop/conventions.ts`). Pure knowledge; leans 100% on the scaffold. This is the low-risk, high-value half and lands first. + +New topics: +1. **`design-tokens`** — the exact token vocabulary + when each applies (primary = CTA, destructive = delete, muted-foreground = secondary text, border/border-strong = dividers, panel/card = containers, ring = focus). Rule: **never hardcode hex/rgb**; use tokens via bare Tailwind classes (`bg-primary`, `text-muted-foreground`). Opacity variants (`border-strong/40`). +2. **`theming`** — theming is `data-theme`-driven; **never use `dark:` variants**; tokens flip automatically. Test both themes by toggling `data-theme`. +3. **`responsive`** — mobile-first (no-prefix = mobile; `md:`/`lg:` = up); the Sheet mobile-drawer pattern for nav; responsive padding idioms (`px-4 lg:px-6`); container queries for intra-component layout. +4. **`accessibility`** — semantic landmarks (`nav`/`main`/`header`/`section`); `aria-label` for icon-only buttons; `aria-hidden` on decorative icons; `sr-only`; `aria-current="page"`; skip link; labels linked to inputs; never make a `div` interactive (use `Button`/links). Frame it as "satisfy the jsx-a11y rules proactively, don't discover them at the gate." +5. **`components-ui`** — prefer `@/components/ui/*` primitives; extend via `cva` variants; compose classes with `cn()` (not ternary/template strings); `asChild` slot pattern; `data-slot` scoping. + +Wire these into the topic registry + surface at the same points as existing guides (build refine-prompt + interim `check`/RULE_DOCS). + +## Part B — Layout-role capability (the structural change) + +**Plan schema (broad).** Extend `IUiIntent` (`packages/core/src/loop/planning/plan-types.ts`): +```ts +layout?: "app-sidebar" | "app-topnav" | "settings" | "focused" | "public"; // default "app-sidebar" +home?: boolean; // this feature's route is the post-login landing (AT MOST one per plan; none → /dashboard) +``` +`nav` (existing description) stays. The `LayoutArchetype` TYPE is intentionally broad (anti-lock-in, the roadmap); **v1 implements `app-sidebar` + `settings`**. Plan validation gates on `IMPLEMENTED_LAYOUT_ARCHETYPES` = {`app-sidebar`, `settings`} — a not-yet-built archetype (`app-topnav`/`public`/`focused`) is **rejected**, NOT silently accepted/fallen-back (critical for `public`, which implies unauthenticated but would be wrapped in `ProtectedRoute`). The vocabulary stays future-proof; a new archetype ships by adding wiring + extending the implemented set. + +**Wiring (implement narrow):** In `packages/core/src/loop/boringstack/`: +- **Sidebar grouping** — the harness's sidebar wiring/guidance groups nav into a **primary app group** (`layout: app-sidebar`) and a demoted **Settings group** (`layout: settings`, plus the scaffold's existing account/profile/notification links). AppSidebar edit stays in feature scope; the refine-prompt tells the model which group to add to based on `layout`. +- **Home landing** — the harness writes `DEFAULT_REDIRECT_TO` DETERMINISTICALLY for the `home` slice's route (`wireHomeRedirectForPlan` → `applyHomeRedirect`, called ONCE at the plan level in `runBoringstackBuild` — resume-safe). It is NOT model-edited and `LoginPage.constants` is deliberately NOT in feature scope; the refine-prompt tells the model not to touch it. At most one home (validated); none → login keeps `/dashboard`. +- **Route/layout** — keep `ProtectedRoute → AppShell` for `app-sidebar` and `settings` (both are authenticated app areas; "settings" is a grouping + optional sub-nav, not a separate auth boundary in v1). `wireUiRouteFile` stays; only grouping + home differ. (Distinct SettingsLayout / public-unauth shells are **designed-for** via the enum but **deferred** — YAGNI until a build needs them.) + +**Sidebar grouping is guide-only (cosmetic)** — a mis-grouped nav link still works, so it is not gate-verified (deferred). The home landing IS verified by an observable outcome test (runBoringstackBuild rewrites the live redirect). Accepted limitations: a no-home plan leaves the redirect untouched (fresh scaffolds default to `/dashboard`); the redirect regex assumes a plain constants file (line-anchored, not a full parser). + +**Acceptance (unchanged contract, role-aware only where safe):** Every feature remains reachable + e2e-tested via its nav testid — `role` only changes *which sidebar group* the nav link lives in, not whether it exists. This deliberately preserves the proven acceptance machinery (no gate relaxation). Smart-view filtering (Spec 2) is verified as UI within the Task feature, not as new entities. + +--- + +## What's implemented now vs designed-for-later + +- **Now:** Parts A (all 5 guides) + B (`app-sidebar` + `settings` roles, home landing, sidebar grouping, broad schema). +- **Later (schema-valid, not implemented):** `app-topnav`, `public` (unauth features), `focused` as a distinct feature layout, a dedicated `SettingsLayout` shell. Each is its own future spec when a build needs it. +- **Non-goals:** any BoringStack fork; new CSS/components; M2M relationships; scheduled reminders (Spec 2 Phase 2). + +## Testing & validation + +- Unit tests for: plan-schema accepts/defaults `layout`/`home`; exactly-one-home validation; sidebar-group selection by role; home→`DEFAULT_REDIRECT_TO` wiring; guides present in the registry. +- **Panel-gate** the harness diff (4-model, ≥2 agree). +- **Live build**: Spec 2 (todos app) is the end-to-end proof — app lands on Today, settings demoted, UI uses tokens/primitives, a11y gate clean. Run solo on a quiet box. + +## Architecture / files to change + +- `packages/core/src/loop/conventions.ts` — register + author the 5 design-system topics (Part A). +- `packages/core/src/loop/planning/plan-types.ts` — `IUiIntent.layout` + `home` (Part B schema). +- `packages/core/src/loop/boringstack/wire-resource.ts` (`wireHomeRedirect`/`applyHomeRedirect`) + `build.ts` (`homeRouteForPlan`/`wireHomeRedirectForPlan`, called in `runBoringstackBuild`) — deterministic home redirect. `refine-prompt.ts` — sidebar-group GUIDANCE (cosmetic; not gate-verified) + a note telling the model the redirect is auto-wired. `propose-plan.ts` — planner emits `layout`/`home`. NO `LoginPage.constants` in feature scope. +- `packages/core/src/loop/boringstack/acceptance/testid-contract.ts` — keep nav contract; document role→group only. +- Plan validation (where `isEntitySpec`/plan is validated) — exactly-one-home + valid `layout`. +- Tests alongside each. diff --git a/packages/core/src/agent/agent.constants.ts b/packages/core/src/agent/agent.constants.ts index 53ade6c1..27c42e17 100644 --- a/packages/core/src/agent/agent.constants.ts +++ b/packages/core/src/agent/agent.constants.ts @@ -369,9 +369,14 @@ export const PULL_CONVENTIONS_TOOL = { "testing", "api-service", "i18n", + "design-tokens", + "theming", + "responsive", + "accessibility", + "components-ui", ], description: - 'which guide: component-anatomy (where a component lives + one-per-file), file-layout (no inline types/constants/helpers), jsx (no computation in markup), state (hooks, not component body), no-casts (type guards instead of `as`/`!`), routing (thin route files), forms, data-fetching (api-client, never raw fetch), lint-gotchas (await promises, no void-expr values, no stringified errors, no duplicate strings), testing (.test.ts vs .test.tsx, the vi.hoisted api-client mock, createApp/app.handle route tests, enforced test rules), api-service (mutating service methods record an audit event; throw ApiError), i18n (add a locale key only when you reference it via t("key") — never pre-declare, or it\'s a dead-key error).', + 'which guide: component-anatomy (where a component lives + one-per-file), file-layout (no inline types/constants/helpers), jsx (no computation in markup), state (hooks, not component body), no-casts (type guards instead of `as`/`!`), routing (thin route files), forms, data-fetching (api-client, never raw fetch), lint-gotchas (await promises, no void-expr values, no stringified errors, no duplicate strings), testing (.test.ts vs .test.tsx, the vi.hoisted api-client mock, createApp/app.handle route tests, enforced test rules), api-service (mutating service methods record an audit event; throw ApiError), i18n (add a locale key only when you reference it via t("key") — never pre-declare, or it\'s a dead-key error), design-tokens (never hardcode colors; use CSS-variable Tailwind tokens by role), theming (data-theme-driven, never dark: variants), responsive (mobile-first breakpoints + Sheet mobile drawer), accessibility (satisfy jsx-a11y up front: aria-label/aria-hidden/sr-only, no interactive div, semantic landmarks), components-ui (use @/components/ui Radix primitives, cn() + cva, asChild).', }, }, required: ["topic"], diff --git a/packages/core/src/loop/boringstack/build.ts b/packages/core/src/loop/boringstack/build.ts index 8770b4e0..02404a4c 100644 --- a/packages/core/src/loop/boringstack/build.ts +++ b/packages/core/src/loop/boringstack/build.ts @@ -11,6 +11,7 @@ import type { IProvider } from "../../inference"; import type { IGate } from "../../gate/gate-runner"; import type { Exec } from "./exec"; import { generateResource, generateFeature } from "./generate"; +import { applyHomeRedirect } from "./wire-resource"; import { runBoringstackGate } from "./gate"; import { extractFailures } from "./extract-failures"; import { resolveStuckFile } from "../expert-handoff"; @@ -178,12 +179,52 @@ export function scopeFor(name: string): string[] { ]; } +/** + * Reduce the shared Drizzle schema to at most `budget` chars for the judge, KEEPING the region + * around THIS feature's own table (`export const =`). A many-entity app accretes one table + * per feature into the shared schema in build order, so the feature under review — and its + * `belongsTo` FOREIGN KEY, which is declared inside its own table block — typically sits near the + * END; naive head-first truncation would drop the exact FK evidence the judge needs. When the + * schema fits the budget it's returned whole; otherwise a window is centered on the feature's table. + * If the table export can't be located in an oversized schema, returns "" (the caller then omits + * the schema entirely rather than burning budget on an arbitrary head slice that — under the + * accretion model — cannot contain this feature's table anyway; feature code keeps the full budget). + * Pure — unit-testable. + */ +export function sliceSchemaForJudge( + schema: string, + tableExport: string, + budget: number +): string { + if (schema.length <= budget) { + return schema; + } + + const anchor = schema.indexOf(`export const ${tableExport} `); + + if (anchor < 0) { + return ""; + } + + // Center the window on the table: keep a third of the budget of context before it (its imports / + // referenced tables) and the rest from the table onward (its columns + FK). + const start = Math.max(0, anchor - Math.floor(budget / 3)); + const end = Math.min(schema.length, start + budget); + const head = start > 0 ? "…[truncated]\n" : ""; + const tail = end < schema.length ? "\n…[truncated]" : ""; + + return `${head}${schema.slice(start, end)}${tail}`; +} + /** * Read the generated resource code from the filesystem for the completeness judge. - * Concatenates the API resource (.ts) and UI feature (.ts/.tsx/.jsx, test/story files - * excluded) source, capped at ~96000 characters. React components (.tsx/.jsx) are ordered - * FIRST across both apps so a large API can't exhaust the budget before the judge sees the - * UI. Returns empty string if directories don't exist. + * Concatenates the shared Drizzle schema (so the judge can see the feature's table + FKs and + * verify `belongsTo` relationships), the API resource (.ts), and the UI feature (.ts/.tsx/.jsx, + * test/story files excluded) source, capped at ~96000 characters. The schema is emitted FIRST but + * bounded to at most HALF the budget (`sliceSchemaForJudge`, windowed around this feature's table) + * so it can never starve the implementation the judge must evaluate; React components (.tsx/.jsx) + * are ordered ahead of other feature files so a large API can't exhaust the budget before the judge + * sees the UI. Returns empty string if nothing exists. */ export async function readResourceCode( cwd: string, @@ -301,6 +342,34 @@ export async function readResourceCode( const blocks: string[] = []; let totalLen = 0; + // The entity's table + FOREIGN KEYS live in the SHARED Drizzle schema, not the feature + // dir — so a `belongsTo` relationship (the FK definition) is invisible to the feature-scoped + // candidates gathered above. Without it the completeness judge is asked to verify e.g. + // "belongs to a project" but cannot see the FK, so it false-REJECTS a fully-correct feature + // ("schema not shown, relationship cannot be verified") — parking a built resource the model + // can't fix because there is nothing to fix (observed live: the todos Project/Task build, where + // the DB table + task_project_id_fkey existed and worked, yet the judge parked it). Emit the + // schema FIRST, but bounded to at most HALF the budget and WINDOWED around this feature's own + // table (sliceSchemaForJudge) — so the FK reaches the judge without either (a) being truncated + // head-first away when the schema is large, or (b) starving the feature implementation the judge + // must also see. Best-effort: a non-boringstack layout (or a schema not yet generated) simply has + // no such file and the judge sees the feature code alone, as before. + try { + const schema = await readFile(join(cwd, APP_SCHEMA_FILE), "utf-8"); + const sliced = sliceSchemaForJudge(schema, camel, Math.floor(maxChars / 2)); + + // Empty slice = oversized schema whose table window couldn't be located → omit it so the + // feature implementation keeps the whole budget (rather than burn half on irrelevant tables). + if (sliced.length > 0) { + const block = `// ${APP_SCHEMA_FILE}\n${sliced}\n`; + + blocks.push(block); + totalLen += block.length; + } + } catch { + // No shared schema present — skip (judge falls back to feature-only code). + } + for (const { relPath, fullPath } of ordered) { const content = await readFile(fullPath, "utf-8"); const block = `// ${relPath}\n${content}\n`; @@ -882,6 +951,40 @@ export async function runFinalAcceptance( return result; } +/** + * The post-login landing route for a plan: the `/${camel}` route of the slice marked `ui.home`, + * or null when none is marked (login keeps the scaffold default `/dashboard`). Derived from the + * PLAN so the redirect is wired ONCE per build at the plan level (see runBoringstackBuild) — + * resume-safe: a resumed build that skips an already-passing home feature still re-applies it, + * unlike per-feature implement() wiring (which resume skips → the redirect would silently stay + * /dashboard, a false-green). At most one home is enforced by plan validation. + */ +export function homeRouteForPlan(plan: IProductPlan): string | null { + const home = plan.slices.find((s) => s.ui.home === true); + + return home ? `/${toCamelCase(home.entity.id)}` : null; +} + +/** + * Wire the plan's post-login landing once per build: if a slice is `home`, point + * DEFAULT_REDIRECT_TO at its route. Called from runBoringstackBuild at the plan level (resume-safe + * — a resumed build that skips an already-passing home feature still applies it). `apply` is + * injectable so the "the wiring actually fires" behaviour is unit-testable without touching the + * real filesystem; it defaults to the FS writer, which throws (never silently skips) on a missing + * login-constants file. No home slice → no-op (login keeps the scaffold default). + */ +export async function wireHomeRedirectForPlan( + cwd: string, + plan: IProductPlan, + apply: (cwd: string, route: string) => Promise = applyHomeRedirect +): Promise { + const route = homeRouteForPlan(plan); + + if (route !== null) { + await apply(cwd, route); + } +} + /** * Run the BoringStack build driver: require an approved plan, derive features * from its slices, and drive them through the greenfield loop @@ -1078,6 +1181,15 @@ export async function runBoringstackBuild(opts: { host.captureMetaBaseline(); } + // Wire the post-login landing at the PLAN level — ONCE per build, resume-safe (a resumed build + // that skips an already-passing home feature still applies it; it is NOT in implement()). Placed + // AFTER BOTH pristine captures — the command baseline (runBoringstackGate above) AND the meta + // baseline (captureMetaBaseline just above) — and after the infra fail-closed, so this harness + // mutation is never swept into either baseline and an infra-aborted build never mutates the + // clone. A home slice → its route (throws if the login constants are missing — never a silent + // skip); no home → no-op (a fresh scaffold already defaults to /dashboard). + await wireHomeRedirectForPlan(cwd, approved); + // Create a lookup function that maps feature ids to their plan slices const sliceFor = (id: string): ISlice | undefined => approved.slices.find((slice) => slice.entity.id === id); diff --git a/packages/core/src/loop/boringstack/refine-prompt.ts b/packages/core/src/loop/boringstack/refine-prompt.ts index e7dc5d18..88d8ec0f 100644 --- a/packages/core/src/loop/boringstack/refine-prompt.ts +++ b/packages/core/src/loop/boringstack/refine-prompt.ts @@ -2,6 +2,41 @@ import type { IFeature } from "../greenfield/greenfield.types"; import type { ISlice } from "../planning/plan-types"; import { toCamelCase } from "./case"; +/** Per-slice layout wiring: where the feature's nav link goes (primary app group vs a demoted + * Settings group) and, if it's the app home, the post-login redirect. v1 implements app-sidebar + * + settings; the other archetypes build as app-sidebar for now (the enum is broad on purpose). */ +function layoutGuidance(slice: ISlice): string { + const layout = slice.ui.layout ?? "app-sidebar"; + const route = `/${toCamelCase(slice.entity.id)}`; + const lines: string[] = []; + + // Plan validation only admits the IMPLEMENTED archetypes (app-sidebar | settings), so those are + // the only values that reach here; anything else is treated as the app-sidebar default. + if (layout === "settings") { + lines.push( + "**Layout**: `settings` — a DEMOTED config area. Add this feature's sidebar link to a " + + "secondary **Settings** group in `AppSidebar` (grouped with the account/profile/settings " + + "links, below the primary app nav), NOT the primary app nav group." + ); + } else { + lines.push( + "**Layout**: `app-sidebar` — a PRIMARY app view. Add its sidebar link to the primary app " + + "nav group at the TOP of `AppSidebar`, above the Settings/account links." + ); + } + + if (slice.ui.home === true) { + lines.push( + "**Home**: this feature is the app's landing view — the harness AUTOMATICALLY points the " + + `post-login redirect (\`DEFAULT_REDIRECT_TO\`) at its route (\`${route}\`). Do NOT edit ` + + "the login page or that constant yourself; just build this feature well as the primary " + + "app view the user lands in." + ); + } + + return lines.join("\n\n"); +} + function productContextSection(slice: ISlice): string { const fieldsList = slice.entity.fields .map((f) => { @@ -52,6 +87,8 @@ ${rulesList} **Navigation**: ${slice.ui.nav} +${layoutGuidance(slice)} + ### Verification Contract **Must remain true**: diff --git a/packages/core/src/loop/boringstack/wire-resource.ts b/packages/core/src/loop/boringstack/wire-resource.ts index 2d593f6e..3157f911 100644 --- a/packages/core/src/loop/boringstack/wire-resource.ts +++ b/packages/core/src/loop/boringstack/wire-resource.ts @@ -238,6 +238,55 @@ export async function wireUiFeature(cwd: string, name: string): Promise { await wireI18nKeys(cwd, name); } +/** + * Point the login page's `DEFAULT_REDIRECT_TO` at `route` (the plan `home` feature's route), so a + * signed-in user lands INTO the app instead of the generic `/dashboard`. The harness writes this + * DETERMINISTICALLY — it is NOT left to the model + a prompt — so the landing can't silently stay + * `/dashboard` and slip past the gate (a false-green). Pure + idempotent + self-healing: it + * matches the export regardless of the current value, and throws if the anchor is missing rather + * than silently no-op'ing (which would re-introduce the false-green). + */ +export function wireHomeRedirect(src: string, route: string): string { + // Anchored to line-start (`^…/m`) so a COMMENTED-OUT `// export const DEFAULT_REDIRECT_TO …` + // can't be matched before the live declaration (which would update the comment and leave the + // real redirect at /dashboard — a silent false-green). + const anchor = /^export const DEFAULT_REDIRECT_TO\s*=\s*"[^"]*";/m; + + if (!anchor.test(src)) { + throw new Error( + "wireHomeRedirect: DEFAULT_REDIRECT_TO not found in LoginPage.constants" + ); + } + + return src.replace(anchor, `export const DEFAULT_REDIRECT_TO = "${route}";`); +} + +/** Apply {@link wireHomeRedirect} to the scaffold's LoginPage constants. Called ONLY for a plan's + * `home` slice, so a MISSING file is a real failure, not something to skip: silently returning + * would leave the landing at `/dashboard` while the build goes green — the exact false-green this + * determinism exists to prevent. So it THROWS rather than no-op'ing. (The boringstack scaffold + * always ships this file; a variant that doesn't must be handled explicitly, not silently.) */ +export async function applyHomeRedirect( + cwd: string, + route: string +): Promise { + const path = join( + cwd, + "apps/ui/src/features/auth/components/LoginPage/LoginPage.constants.ts" + ); + + if (!existsSync(path)) { + throw new Error( + `applyHomeRedirect: LoginPage.constants.ts not found at ${path} — cannot wire the home ` + + "landing, and skipping would silently leave the redirect at /dashboard (a false-green)." + ); + } + + const src = await readFile(path, "utf-8"); + + await writeFile(path, wireHomeRedirect(src, route), "utf-8"); +} + /** * Seed the i18n keys the generated feature page renders (`features..title` * and `.empty`) into every locale's `common.json`. new:feature emits the page using diff --git a/packages/core/src/loop/conventions.ts b/packages/core/src/loop/conventions.ts index 63895d36..1f3ed315 100644 --- a/packages/core/src/loop/conventions.ts +++ b/packages/core/src/loop/conventions.ts @@ -26,6 +26,11 @@ const TOPICS = [ "testing", "api-service", "i18n", + "design-tokens", + "theming", + "responsive", + "accessibility", + "components-ui", ] as const; export type ConventionTopic = (typeof TOPICS)[number]; @@ -87,6 +92,32 @@ export const TOPIC_RULES: Readonly> = // so EITHER pushes the guide: `i18n-locale-keys-used` (defined→used, the dead-key trap) and // `static-translation-key-exists` (used→defined, a `t()` whose key isn't in the locale files). i18n: ["i18n-locale-keys-used", "static-translation-key-exists"], + // Styling/theming/responsive/composition have no dedicated lint rule — they're front-loaded + // via buildConventionGuides (like api-service), so an empty rule list here. + "design-tokens": [], + theming: [], + responsive: [], + // Accessibility maps to the eslint-plugin-jsx-a11y rules the gate runs as ERRORS, so the guide + // PUSHes the moment the model trips one (bare names — topicForRule strips the jsx-a11y/ prefix). + accessibility: [ + "no-static-element-interactions", + "click-events-have-key-events", + "no-noninteractive-element-interactions", + "label-has-associated-control", + "interactive-supports-focus", + "alt-text", + "anchor-has-content", + "heading-has-content", + "aria-props", + "aria-role", + "aria-unsupported-elements", + "role-has-required-aria-props", + "role-supports-aria-props", + "no-redundant-roles", + "anchor-is-valid", + "img-redundant-alt", + ], + "components-ui": [], }; const GUIDES: Readonly> = { @@ -303,6 +334,53 @@ const GUIDES: Readonly> = { "functionality the feature needs (a hollow list-only page), it's pure churn you'll re-add, and the " + "build's i18n edit-guard VETOES a net deletion of session-authored keys anyway. (Removal is only for " + "a genuinely obsolete pre-existing key, or a balanced rename that adds the replacement in the same edit.)", + "design-tokens": + "DESIGN TOKENS (boringstack). NEVER hardcode a color — no hex/rgb, no named colors, no arbitrary " + + "`bg-[#…]`. Every color is a CSS-variable design token exposed as a BARE Tailwind class; pick the " + + "token whose ROLE matches: `bg-background`/`text-foreground` (page), `bg-card`/`bg-panel` " + + "(containers/surfaces), `text-muted-foreground` (secondary/de-emphasized text), `border-border` and " + + "`border-border-strong/40` (dividers — the `/40` opacity variant for subtlety), " + + "`bg-primary text-primary-ink hover:bg-primary-strong` (primary CTA), `bg-secondary`/`bg-accent` " + + "(secondary / highlight), `bg-destructive text-destructive-foreground` (delete/danger), `text-success` " + + "(success), `ring-ring` (focus ring), `rounded-md`/`rounded-xl` (the `--radius` scale), `font-sans` " + + "(Inter — the default) / `font-mono`. Spacing/sizing use the normal Tailwind scale. A raw color value " + + "is a design-system violation — there is a token for every role.", + theming: + "THEMING (boringstack). Light/dark is DATA-ATTRIBUTE driven: tokens flip on " + + '``, so a token class (`bg-background`, `text-foreground`, `bg-card`) is ' + + "AUTOMATICALLY correct in BOTH themes. NEVER write a `dark:` Tailwind variant (`dark:bg-…`) — it is " + + "banned (AGENT_CONTRACT) and redundant; the token already switches. Do not read or set the theme " + + "yourself — the `useTheme()` hook + the existing ThemeToggle own it. To test both themes, toggle " + + '`document.documentElement.setAttribute("data-theme", "dark")` and assert token-classed elements ' + + "still render; never assert a literal color value.", + responsive: + "RESPONSIVE (boringstack). Mobile-first: an UNPREFIXED class is the MOBILE style; add `sm:`/`md:`/`lg:` " + + "for larger screens (`px-4 lg:px-6`, `grid-cols-1 md:grid-cols-2`, `flex-col md:flex-row`). `md:` is the " + + "primary layout breakpoint. Every page MUST be usable at 375px wide — never fix a px width that " + + "overflows small screens; use `w-full`/`max-w-*` + breakpoint prefixes. NAV pattern: the sidebar is " + + '`hidden md:flex` on desktop with a `Sheet` drawer (`@/components/ui/sheet`, `side="left"`) for mobile ' + + "— REUSE that, don't invent a nav. For layout INSIDE a component, container queries (`@container`) are " + + "available (see the Card header).", + accessibility: + "ACCESSIBILITY (boringstack). The gate runs `eslint-plugin-jsx-a11y` as ERRORS — satisfy it on the " + + "first draft, don't discover it at the gate. An icon-only button needs an `aria-label`; a DECORATIVE " + + 'icon (lucide) needs `aria-hidden="true"`; screen-reader-only text is `className="sr-only"`; every ' + + "heading/anchor must have content; a `