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.",