diff --git a/CHANGELOG.md b/CHANGELOG.md index f410dbc..0519ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to `@timbal-ai/timbal-react` are documented here. +## [4.2.2] — 2026-07-09 + +### Added + +- **New lint rules `page-missing-inset` and `card-flush-content`.** A + `PageHeader` without a page inset contract (`PageBody`, `AppShell` / + `RoutedAppShell`, `Page`, or lateral `px-*` on the root) is now an error — + the classic failure is titles and tables running flush to the shell/card + edge. A `Card` or `CardContent` with `p-0` / `px-0` holding headings, + forms, or tables is also blocked. New `HOUSE_RULES` entries: + `page-inset-required`, `card-flush-content`. + ## [4.2.1] — 2026-07-08 ### Added diff --git a/package.json b/package.json index fbe02c8..b441f23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@timbal-ai/timbal-react", - "version": "4.2.1", + "version": "4.2.2", "description": "React components and runtime for building Timbal chat and studio apps", "type": "module", "main": "dist/index.cjs", diff --git a/src/design/ui-lint.test.ts b/src/design/ui-lint.test.ts index 36536d5..7cf2acf 100644 --- a/src/design/ui-lint.test.ts +++ b/src/design/ui-lint.test.ts @@ -133,6 +133,44 @@ describe("lintGeneratedUi — button custom fills", () => { }); }); +describe("lintGeneratedUi — page inset", () => { + it("flags PageHeader without PageBody, shell, or lateral padding", () => { + const src = [ + `export function Dashboard() {`, + ` return (`, + `
`, + ` `, + ` `, + `
`, + ` );`, + `}`, + ].join("\n"); + expect(lintGeneratedUi(src).findings.map((f) => f.rule)).toEqual( + expect.arrayContaining(["page-missing-inset"]), + ); + }); + + it("accepts PageHeader inside PageBody", () => { + const src = ``; + expect(lintGeneratedUi(src).findings.map((f) => f.rule)).not.toContain( + "page-missing-inset", + ); + }); + + it("accepts PageHeader inside RoutedAppShell", () => { + const src = ``; + expect(lintGeneratedUi(src).findings.map((f) => f.rule)).not.toContain( + "page-missing-inset", + ); + }); + + it("flags flush Card surfaces with substantive content", () => { + expect( + rules(``), + ).toEqual(expect.arrayContaining(["card-flush-content"])); + }); +}); + describe("lintGeneratedUi — literals & inline styles", () => { it("flags hex and oklch literals", () => { expect(rules(`
`)).toEqual( @@ -311,6 +349,8 @@ describe("HOUSE_RULES lint coverage", () => { "no-chat-wrapping": ["no-chat-wrapping"], "theme-via-generator": ["theme-via-generator"], "button-variants-only": ["button-custom-fill"], + "page-inset-required": ["page-missing-inset"], + "card-flush-content": ["card-flush-content"], }; it("covers every HOUSE_RULES id with a lint check or a prompt-only annotation", () => { diff --git a/src/design/ui-lint.ts b/src/design/ui-lint.ts index cad1e23..9647b29 100644 --- a/src/design/ui-lint.ts +++ b/src/design/ui-lint.ts @@ -197,6 +197,30 @@ function buttonTagSegments( /** Forcing a theme (forcedTheme="dark") — bypasses the theme system. */ const FORCED_THEME_RE = /\bforcedTheme\b/; +/** A Card (or CardContent) stripped of padding — content runs flush to the edge. */ +const CARD_FLUSH_RE = + /]*className="[^"]*\b(?:p-0|px-0)\b/; + +/** Lateral inset on a page root container. */ +const PAGE_LATERAL_INSET_RE = /\bpx-(?:4|6|8)\b/; + +function hasPageInsetContract(source: string): boolean { + return ( + /\b(?:AppShell|RoutedAppShell)\b/.test(source) || + /\bPageBody\b/.test(source) || + /data-slot="(?:page|page-body)"/.test(source) || + /\bPAGE_INSET/.test(source) || + /\bappPageColumn\b/.test(source) || + / /", + }); + } + } + for (let i = 0; i < lines.length; i++) { const line = lines[i]; const lineNo = i + 1; @@ -282,6 +320,18 @@ export function lintGeneratedUi( } } + // ── Card stripped of padding (flush content to the card edge) ─────── + if (CARD_FLUSH_RE.test(line)) { + findings.push({ + rule: "card-flush-content", + severity: "error", + line: lineNo, + message: + "Card or CardContent with p-0/px-0 — content runs flush to the card edge. Use the default Card padding (py-6 + CardHeader/CardContent px-6) or wrap inner content in CardContent. Never put a PageHeader, form, or table directly inside a flush Card.", + snippet: line.trim().slice(0, 120), + }); + } + // ── raw palette colors ────────────────────────────────────────────── const rawColors = line.match(RAW_COLOR_RE); if (rawColors) { diff --git a/src/design/ui-vocabulary.ts b/src/design/ui-vocabulary.ts index 6f6e60b..778adfb 100644 --- a/src/design/ui-vocabulary.ts +++ b/src/design/ui-vocabulary.ts @@ -295,6 +295,20 @@ export const HOUSE_RULES: readonly HouseRule[] = [ good: ``, enforcement: "prompt-only", }, + { + id: "page-inset-required", + rule: "Every page needs lateral + vertical breathing room — never run PageHeader, stats, or tables flush to the shell/card edge.", + why: "Agents hand-roll a flex column with only gap-* and no px-/py-*, so titles and tables hug the border. AppShell/RoutedAppShell apply inset automatically; standalone pages use PageBody inset or Page from app/page.", + slop: ``, + good: ``, + }, + { + id: "card-flush-content", + rule: "Never strip Card padding (p-0 / px-0 on Card or CardContent) when the card holds headings, forms, or tables.", + why: "A flush card looks broken — the reference always keeps CardHeader/CardContent px-6 or the default Card py-6.", + slop: ``, + good: ``, + }, { id: "button-variants-only", rule: "Buttons use the variant system only — default (dark), secondary (white), outline, ghost, destructive, link. Never paint a custom bg-* fill on a Button.",