From 8a1ef6fe3a8c4eeba192f7420a41d21b00e4cf5a Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 29 Jul 2026 08:48:11 +0200 Subject: [PATCH 01/20] fix(acceptance): emit a real calendar date for date-typed fields (validValue) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live deep-chain build (Organization→Project→Task) parked Task: fast gate green but e2e acceptance timed out with the task-form never hiding — i.e. the create was failing, not a transient hiccup as the build's own note guessed. Root cause: validValue (acceptance-spec.ts) had no case for date/datetime/timestamp types, so a dueDate field got the generic '${name}-${seed}' sample ('dueDate-2'). That passes the UI Zod (z.string) and API TypeBox (t.String), but the service inserts it into a timestamptz column and Postgres throws 'invalid input syntax for type timestamp with time zone' -> create 500s -> onSuccess/closeForm never fires -> form stays visible -> 10s waitFor(hidden) times out -> false park. Confirmed at the boundary against the live DB: 'dueDate-3'::timestamptz -> ERROR: invalid input syntax '2024-06-15'::timestamptz -> 2024-06-15 00:00:00+00 (accepted) Fix: validValue returns '2024-06-15' for date/datetime/timestamp types. Date-only so it stays a substring of whatever timestamp representation the row cell renders back (the shows assertion is toContainText). Boolean fields were already fine (checkbox path). Regression test added. typecheck/lint/format clean, 3193/3206 suite green. Follow-ups (filed): empty-optional-date '' also 500s a timestamptz insert (generated form should coerce '' -> undefined for optional date/number); e2e seed resilience to transient socket hang-ups / vitest worker timeouts under load. --- .../src/loop/acceptance/acceptance-spec.ts | 13 +++++ packages/core/tests/acceptance-spec.test.ts | 49 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/core/src/loop/acceptance/acceptance-spec.ts b/packages/core/src/loop/acceptance/acceptance-spec.ts index 70b19c5e..aeaabdac 100644 --- a/packages/core/src/loop/acceptance/acceptance-spec.ts +++ b/packages/core/src/loop/acceptance/acceptance-spec.ts @@ -50,6 +50,19 @@ function validValue( return String(seed + 1); } + if ( + field.type === "date" || + field.type === "datetime" || + field.type === "timestamp" + ) { + // A real calendar date. Date-typed fields land in a DB date/timestamp column, so the generic + // `${name}-${seed}` sample would pass string-level validation (Zod z.string / TypeBox t.String) + // yet throw at the insert ("invalid input syntax for type timestamp") → the create 500s and the + // form never closes → false e2e park. Date-only ("YYYY-MM-DD") so it stays a substring of + // whatever timestamp representation the row cell renders back for the shows assertion. + return "2024-06-15"; + } + if (/url|website/i.test(field.name)) { return `https://example${seed}.com`; } diff --git a/packages/core/tests/acceptance-spec.test.ts b/packages/core/tests/acceptance-spec.test.ts index 2bc09751..5ff254a7 100644 --- a/packages/core/tests/acceptance-spec.test.ts +++ b/packages/core/tests/acceptance-spec.test.ts @@ -540,3 +540,52 @@ test("fieldIsMentioned matches on WORD BOUNDARIES, not raw substring", () => { // And it isn't tripped by an unrelated word either. expect(fieldIsMentioned(acceptField("name"), "the total amount")).toBe(false); }); + +test("planToAcceptanceSpec: date-typed fields get a REAL calendar date sample (not a `${name}-${seed}` string a timestamp column rejects)", () => { + // Regression: a `date`/`datetime`/`timestamp` field used to fall through to the generic + // "${name}-${seed}" sample (e.g. "dueDate-2"). That passes z.string()/t.String() but the app + // inserts it into a timestamptz column, where Postgres throws "invalid input syntax for type + // timestamp" → the create 500s and the form never closes → false e2e park. + const datePlan: IProductPlan = { + product: "Tracker", + slices: [ + { + entity: { + id: "Task", + desc: "t", + fields: [ + { name: "title", type: "string" }, + { name: "dueDate", type: "date" }, + { name: "startsAt", type: "datetime" }, + ], + relationships: [], + rules: ["title is required"], + }, + ui: { + screens: ["list", "form"], + action: "add", + shows: ["title", "dueDate"], + nav: "Tasks", + }, + verification: { + mustRemainTrue: ["x"], + mustNotHappen: ["a task can be saved without a title"], + acceptanceCheck: "create a task", + }, + }, + ], + }; + + const task = planToAcceptanceSpec(datePlan).entities[0]; + + for (const name of ["dueDate", "startsAt"]) { + const field = task?.fields.find((f) => f.name === name); + + expect(field, `expected field ${name}`).toBeDefined(); + // NOT the generic garbage sample. + expect(field?.valid).not.toContain(`${name}-`); + // A real, parseable YYYY-MM-DD date (what a timestamp column accepts). + expect(field?.valid).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(Number.isNaN(new Date(field?.valid ?? "").getTime())).toBe(false); + } +}); From dab29cf3ce218088e7593315834d3bb5b4aefa70 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 29 Jul 2026 08:55:40 +0200 Subject: [PATCH 02/20] test(acceptance): cover timestamp type + strict calendar-date check (panel r1 finding) - Exercise date, datetime AND timestamp (production branch handles all three). - Replace the weak !isNaN(new Date()) check, which accepts normalized-impossible dates (new Date('2024-02-31') rolls to 2024-03-02), with a strict UTC round-trip that rejects them. Self-checks the checker rejects '2024-02-31' and accepts '2024-06-15'. --- packages/core/tests/acceptance-spec.test.ts | 32 ++++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/core/tests/acceptance-spec.test.ts b/packages/core/tests/acceptance-spec.test.ts index 5ff254a7..c26c1829 100644 --- a/packages/core/tests/acceptance-spec.test.ts +++ b/packages/core/tests/acceptance-spec.test.ts @@ -546,6 +546,29 @@ test("planToAcceptanceSpec: date-typed fields get a REAL calendar date sample (n // "${name}-${seed}" sample (e.g. "dueDate-2"). That passes z.string()/t.String() but the app // inserts it into a timestamptz column, where Postgres throws "invalid input syntax for type // timestamp" → the create 500s and the form never closes → false e2e park. + // A strict calendar-date check: rejects impossible dates that `new Date()` would silently + // normalize (e.g. "2024-02-31" rolls to 2024-03-02, which a naive !isNaN check would accept). + const isRealCalendarDate = (s: string): boolean => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); + + if (!m) { + return false; + } + + const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])]; + const dt = new Date(Date.UTC(year, month - 1, day)); + + return ( + dt.getUTCFullYear() === year && + dt.getUTCMonth() === month - 1 && + dt.getUTCDate() === day + ); + }; + + // Sanity-check the checker itself: it must reject a normalized-impossible date. + expect(isRealCalendarDate("2024-02-31")).toBe(false); + expect(isRealCalendarDate("2024-06-15")).toBe(true); + const datePlan: IProductPlan = { product: "Tracker", slices: [ @@ -557,6 +580,7 @@ test("planToAcceptanceSpec: date-typed fields get a REAL calendar date sample (n { name: "title", type: "string" }, { name: "dueDate", type: "date" }, { name: "startsAt", type: "datetime" }, + { name: "loggedAt", type: "timestamp" }, ], relationships: [], rules: ["title is required"], @@ -578,14 +602,14 @@ test("planToAcceptanceSpec: date-typed fields get a REAL calendar date sample (n const task = planToAcceptanceSpec(datePlan).entities[0]; - for (const name of ["dueDate", "startsAt"]) { + // Cover every date-ish type the production branch handles: date, datetime, timestamp. + for (const name of ["dueDate", "startsAt", "loggedAt"]) { const field = task?.fields.find((f) => f.name === name); expect(field, `expected field ${name}`).toBeDefined(); // NOT the generic garbage sample. expect(field?.valid).not.toContain(`${name}-`); - // A real, parseable YYYY-MM-DD date (what a timestamp column accepts). - expect(field?.valid).toMatch(/^\d{4}-\d{2}-\d{2}$/); - expect(Number.isNaN(new Date(field?.valid ?? "").getTime())).toBe(false); + // A real, valid calendar date (what a timestamp column accepts) — not a normalized impossible one. + expect(isRealCalendarDate(field?.valid ?? "")).toBe(true); } }); From 53e62e59bd3c292a484093586ec4261ef5f0b002 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 29 Jul 2026 09:04:16 +0200 Subject: [PATCH 03/20] fix(acceptance): explicit field TYPE beats name heuristics in validValue (panel r2 finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A date-typed field whose NAME matches the email heuristic (emailVerifiedAt, lastEmailSentAt) was getting user@example.com because the email name-check ran before the date-type branch — an email string 500s a timestamp-column insert, the same false-park class. Reordered so explicit type checks (email/number/date) run first, then name-based heuristics for string fields. Test covers emailVerifiedAt(timestamp) → real date, not an email. --- packages/core/src/loop/acceptance/acceptance-spec.ts | 12 +++++++++--- packages/core/tests/acceptance-spec.test.ts | 10 +++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/core/src/loop/acceptance/acceptance-spec.ts b/packages/core/src/loop/acceptance/acceptance-spec.ts index aeaabdac..6f11d4b8 100644 --- a/packages/core/src/loop/acceptance/acceptance-spec.ts +++ b/packages/core/src/loop/acceptance/acceptance-spec.ts @@ -40,9 +40,10 @@ function validValue( field: { name: string; type: string }, seed: number ): string { - const isEmail = field.type === "email" || /email/i.test(field.name); - - if (isEmail) { + // Explicit TYPE wins over name heuristics. A date-typed field must get a real date even when its + // name matches the email heuristic (e.g. `emailVerifiedAt`, `lastEmailSentAt`) — otherwise it gets + // an email string that 500s the timestamp-column insert, the exact false-park this branch fixes. + if (field.type === "email") { return `user${seed}@example.com`; } @@ -63,6 +64,11 @@ function validValue( return "2024-06-15"; } + // Name-based heuristics for otherwise-untyped (string) fields. + if (/email/i.test(field.name)) { + return `user${seed}@example.com`; + } + if (/url|website/i.test(field.name)) { return `https://example${seed}.com`; } diff --git a/packages/core/tests/acceptance-spec.test.ts b/packages/core/tests/acceptance-spec.test.ts index c26c1829..b100811d 100644 --- a/packages/core/tests/acceptance-spec.test.ts +++ b/packages/core/tests/acceptance-spec.test.ts @@ -581,6 +581,8 @@ test("planToAcceptanceSpec: date-typed fields get a REAL calendar date sample (n { name: "dueDate", type: "date" }, { name: "startsAt", type: "datetime" }, { name: "loggedAt", type: "timestamp" }, + // Name matches the email heuristic but the TYPE is a date — type must win. + { name: "emailVerifiedAt", type: "timestamp" }, ], relationships: [], rules: ["title is required"], @@ -602,13 +604,15 @@ test("planToAcceptanceSpec: date-typed fields get a REAL calendar date sample (n const task = planToAcceptanceSpec(datePlan).entities[0]; - // Cover every date-ish type the production branch handles: date, datetime, timestamp. - for (const name of ["dueDate", "startsAt", "loggedAt"]) { + // Cover every date-ish type the production branch handles, incl. a date field whose name + // matches the email heuristic (emailVerifiedAt) — TYPE must take precedence over name. + for (const name of ["dueDate", "startsAt", "loggedAt", "emailVerifiedAt"]) { const field = task?.fields.find((f) => f.name === name); expect(field, `expected field ${name}`).toBeDefined(); - // NOT the generic garbage sample. + // NOT the generic garbage sample, and NOT an email (the name-precedence trap). expect(field?.valid).not.toContain(`${name}-`); + expect(field?.valid).not.toContain("@"); // A real, valid calendar date (what a timestamp column accepts) — not a normalized impossible one. expect(isRealCalendarDate(field?.valid ?? "")).toBe(true); } From f37b9cd9a83f81787e7c47a06fe37b4669ac39e3 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 29 Jul 2026 09:15:32 +0200 Subject: [PATCH 04/20] fix(acceptance): apply email type-precedence symmetrically to negativesFor (panel r3 finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 reviewers converged: negativesFor still used f.type === 'email' || /email/i.test(name), so a date/number field named like an email (emailVerifiedAt) got a 'not-an-email' negative. That value reaches a timestamp/number column → 500 (not the expected 400/422) → false park — the same class validValue was just fixed for. Extracted a shared isEmailField(field) helper (explicit type wins over name; number/date types are never email) and used it in BOTH validValue and negativesFor so they can never disagree. Test asserts the negatives array: emailVerifiedAt(timestamp) gets NO not-an-email negative, a real email field DOES (positive control). --- .../src/loop/acceptance/acceptance-spec.ts | 43 ++++++++++++------- packages/core/tests/acceptance-spec.test.ts | 18 +++++++- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/core/src/loop/acceptance/acceptance-spec.ts b/packages/core/src/loop/acceptance/acceptance-spec.ts index 6f11d4b8..0c606958 100644 --- a/packages/core/src/loop/acceptance/acceptance-spec.ts +++ b/packages/core/src/loop/acceptance/acceptance-spec.ts @@ -35,15 +35,34 @@ export function testIdsFor(key: string): ITestIds { }; } +function isDateType(type: string): boolean { + return type === "date" || type === "datetime" || type === "timestamp"; +} + +// Whether a field should be treated as an email. Explicit TYPE wins over the name heuristic: a +// `date`/`number`-typed field named like an email (e.g. `emailVerifiedAt`) is NOT an email — else +// both the positive sample and the invalid-email negative feed a non-string value into a +// date/number column and 500 the insert (a false park). Shared by validValue AND negativesFor so +// the two can never disagree about whether a field is an email. +function isEmailField(field: { name: string; type: string }): boolean { + if (field.type === "email") { + return true; + } + + if (field.type === "number" || isDateType(field.type)) { + return false; + } + + return /email/i.test(field.name); +} + // Deterministic valid sample per field, seeded off (entityIndex, fieldName) — no Date/random. function validValue( field: { name: string; type: string }, seed: number ): string { - // Explicit TYPE wins over name heuristics. A date-typed field must get a real date even when its - // name matches the email heuristic (e.g. `emailVerifiedAt`, `lastEmailSentAt`) — otherwise it gets - // an email string that 500s the timestamp-column insert, the exact false-park this branch fixes. - if (field.type === "email") { + // Explicit TYPE wins over name heuristics (see isEmailField). + if (isEmailField(field)) { return `user${seed}@example.com`; } @@ -51,11 +70,7 @@ function validValue( return String(seed + 1); } - if ( - field.type === "date" || - field.type === "datetime" || - field.type === "timestamp" - ) { + if (isDateType(field.type)) { // A real calendar date. Date-typed fields land in a DB date/timestamp column, so the generic // `${name}-${seed}` sample would pass string-level validation (Zod z.string / TypeBox t.String) // yet throw at the insert ("invalid input syntax for type timestamp") → the create 500s and the @@ -64,11 +79,6 @@ function validValue( return "2024-06-15"; } - // Name-based heuristics for otherwise-untyped (string) fields. - if (/email/i.test(field.name)) { - return `user${seed}@example.com`; - } - if (/url|website/i.test(field.name)) { return `https://example${seed}.com`; } @@ -96,7 +106,10 @@ function negativesFor( out.push({ field: f.name, value: "", why: `${f.name} is required` }); } - if (!isForeignKey && (f.type === "email" || /email/i.test(f.name))) { + // Use the SAME email determination as validValue (type wins over name), so a date/number + // field named like an email (emailVerifiedAt) never gets a "not-an-email" negative — that + // value would reach a timestamp/number column and 500 (not the expected 400/422) → false park. + if (!isForeignKey && isEmailField(f)) { out.push({ field: f.name, value: "not-an-email", diff --git a/packages/core/tests/acceptance-spec.test.ts b/packages/core/tests/acceptance-spec.test.ts index b100811d..7b4fabe4 100644 --- a/packages/core/tests/acceptance-spec.test.ts +++ b/packages/core/tests/acceptance-spec.test.ts @@ -581,8 +581,11 @@ test("planToAcceptanceSpec: date-typed fields get a REAL calendar date sample (n { name: "dueDate", type: "date" }, { name: "startsAt", type: "datetime" }, { name: "loggedAt", type: "timestamp" }, - // Name matches the email heuristic but the TYPE is a date — type must win. - { name: "emailVerifiedAt", type: "timestamp" }, + // Name matches the email heuristic but the TYPE is a date — type must win, in BOTH + // the positive sample (validValue) and the negatives (no "not-an-email"). + { name: "emailVerifiedAt", type: "timestamp", optional: true }, + // A genuine email field — positive control: it SHOULD get the not-an-email negative. + { name: "contactEmail", type: "email", optional: true }, ], relationships: [], rules: ["title is required"], @@ -616,4 +619,15 @@ test("planToAcceptanceSpec: date-typed fields get a REAL calendar date sample (n // A real, valid calendar date (what a timestamp column accepts) — not a normalized impossible one. expect(isRealCalendarDate(field?.valid ?? "")).toBe(true); } + + // Negatives must apply the SAME type-precedence: a date-typed field named like an email gets + // NO "not-an-email" negative (that value would 500 a timestamp insert, not 400/422 → false park), + // while a genuinely email-typed field DOES. + const emailNegs = (fieldName: string): boolean => + (task?.negatives ?? []).some( + (n) => n.field === fieldName && n.value === "not-an-email" + ); + + expect(emailNegs("emailVerifiedAt")).toBe(false); + expect(emailNegs("contactEmail")).toBe(true); }); From 4eab46059054d96efcbb421254a918a1c6a4b05b Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 29 Jul 2026 09:31:28 +0200 Subject: [PATCH 05/20] test(acceptance): cover number field named like email (isEmailField type-precedence, panel r4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isEmailField also guards number types (emailCount type=number → number sample + negative-number negative, never email). Added that case to the regression suite: numeric valid value (no '@'), no not-an-email negative, and the '-1' number negative present. --- packages/core/tests/acceptance-spec.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/core/tests/acceptance-spec.test.ts b/packages/core/tests/acceptance-spec.test.ts index 7b4fabe4..530fcdfb 100644 --- a/packages/core/tests/acceptance-spec.test.ts +++ b/packages/core/tests/acceptance-spec.test.ts @@ -586,6 +586,9 @@ test("planToAcceptanceSpec: date-typed fields get a REAL calendar date sample (n { name: "emailVerifiedAt", type: "timestamp", optional: true }, // A genuine email field — positive control: it SHOULD get the not-an-email negative. { name: "contactEmail", type: "email", optional: true }, + // Number type whose name matches the email heuristic — type must win here too: a + // numeric sample + the negative-number negative, never an email value/negative. + { name: "emailCount", type: "number", optional: true }, ], relationships: [], rules: ["title is required"], @@ -630,4 +633,17 @@ test("planToAcceptanceSpec: date-typed fields get a REAL calendar date sample (n expect(emailNegs("emailVerifiedAt")).toBe(false); expect(emailNegs("contactEmail")).toBe(true); + + // A NUMBER field named like an email must also resolve by type, not name: numeric valid sample + // (no "@"), and the negative-number negative — never a not-an-email negative. + const emailCount = task?.fields.find((f) => f.name === "emailCount"); + + expect(emailCount?.valid).not.toContain("@"); + expect(emailCount?.valid).toMatch(/^\d+$/); + expect(emailNegs("emailCount")).toBe(false); + expect( + (task?.negatives ?? []).some( + (n) => n.field === "emailCount" && n.value === "-1" + ) + ).toBe(true); }); From be07036a75febd0de5151f3f3cd0d2f36dfeca89 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Wed, 29 Jul 2026 11:44:40 +0200 Subject: [PATCH 06/20] feat(conventions): front-load design-system guides (tokens/theming/responsive/a11y/components-ui) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 1A of the modern-layout capability. The harness guided 12 topics but nothing on styling — so the model built UI blind to BoringStack's production-ready design system (CSS-var tokens, ShadCN/Radix primitives in components/ui, data-theme theming, mobile-first responsive, and the 14 jsx-a11y rules the gate runs as ERRORS) and only discovered a11y violations at the gate. Adds 5 front-loaded convention topics that CODIFY what the scaffold already ships (lean on it, reinvent nothing): - design-tokens: never hardcode colors; use CSS-var Tailwind tokens by role. - theming: data-theme-driven; never dark: variants. - responsive: mobile-first breakpoints + the Sheet mobile-drawer nav pattern. - accessibility: satisfy jsx-a11y proactively (aria-label/aria-hidden/sr-only, no interactive div, semantic landmarks); mapped to the jsx-a11y rules so it also PUSHes reactively on a trip. - components-ui: prefer @/components/ui Radix primitives, cn()+cva composition, asChild. Synced the pull_conventions tool enum + description. Tests lock each guide's load-bearing content + the a11y rule mapping. typecheck/lint/format clean, 3195 green. Also adds the design spec: docs/superpowers/specs/2026-07-29-modern-layout-capability-design.md --- ...6-07-29-modern-layout-capability-design.md | 80 +++++++++++++++++++ packages/core/src/agent/agent.constants.ts | 7 +- packages/core/src/loop/conventions.ts | 76 ++++++++++++++++++ packages/core/tests/convention-index.test.ts | 45 +++++++++++ 4 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-07-29-modern-layout-capability-design.md 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..dacdbd52 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-modern-layout-capability-design.md @@ -0,0 +1,80 @@ +# 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/muted(+foreground/strong)/accent(+cyan/pink)/destructive/success/border(+strong)/input/ring/card/panel(+strong)/popover`, `--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` 14 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 14 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 (exactly one per plan) +``` +`nav` (existing description) stays. The enum is intentionally broad (anti-lock-in); **v1 implements `app-sidebar` + `settings`**; `app-topnav`/`public`/`focused` are schema-valid and fall back to `app-sidebar`+guidance for now (documented), so the todos app isn't blocked and the vocabulary is future-proof. + +**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 feature with `home: true` sets `DEFAULT_REDIRECT_TO` to its route. Add `LoginPage.constants.ts` to that feature's scope; the refine-prompt instructs the redirect change. Exactly one home per plan (validated). +- **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.) + +**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` / `build.ts` / `refine-prompt.ts` — sidebar grouping, home-redirect wiring, `LoginPage.constants` scope (Part B wiring). +- `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/conventions.ts b/packages/core/src/loop/conventions.ts index 63895d36..024dc768 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,30 @@ 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", + ], + "components-ui": [], }; const GUIDES: Readonly> = { @@ -303,6 +332,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 `