Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
40 changes: 40 additions & 0 deletions src/design/ui-lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`,
` <div className="flex flex-col gap-5">`,
` <PageHeader title="Supervisor control panel" />`,
` <StatOverview stats={[]} />`,
` </div>`,
` );`,
`}`,
].join("\n");
expect(lintGeneratedUi(src).findings.map((f) => f.rule)).toEqual(
expect.arrayContaining(["page-missing-inset"]),
);
});

it("accepts PageHeader inside PageBody", () => {
const src = `<PageBody><PageHeader title="Team" /></PageBody>`;
expect(lintGeneratedUi(src).findings.map((f) => f.rule)).not.toContain(
"page-missing-inset",
);
});

it("accepts PageHeader inside RoutedAppShell", () => {
const src = `<RoutedAppShell nav={[]}><PageHeader title="Team" /></RoutedAppShell>`;
expect(lintGeneratedUi(src).findings.map((f) => f.rule)).not.toContain(
"page-missing-inset",
);
});

it("flags flush Card surfaces with substantive content", () => {
expect(
rules(`<Card className="p-0"><PageHeader title="Scan" /></Card>`),
).toEqual(expect.arrayContaining(["card-flush-content"]));
});
});

describe("lintGeneratedUi — literals & inline styles", () => {
it("flags hex and oklch literals", () => {
expect(rules(`<div style={{ background: "#ff0066" }} />`)).toEqual(
Expand Down Expand Up @@ -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", () => {
Expand Down
50 changes: 50 additions & 0 deletions src/design/ui-lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
/<Card(?:Content)?\b[^>]*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) ||
/<Page\b/.test(source)
);
}

function pageRootHasLateralInset(source: string): boolean {
return PAGE_LATERAL_INSET_RE.test(
source.slice(source.search(/\breturn\b/)),
);
}

/**
* Hand-authored theme color variable: a CSS custom property the theme system
* owns, assigned a literal color. `--background: oklch(…)`,
Expand Down Expand Up @@ -259,6 +283,20 @@ export function lintGeneratedUi(
const hasChat = /\b(?:TimbalChat|AppChatPanel|Thread)\b/.test(source);
let inButtonTag = false;

if (/<PageHeader\b/.test(source)) {
if (!hasPageInsetContract(source) && !pageRootHasLateralInset(source)) {
const headerLine = lines.findIndex((l) => /<PageHeader\b/.test(l)) + 1;
findings.push({
rule: "page-missing-inset",
severity: "error",
line: headerLine || 1,
message:
"PageHeader without a page inset contract — content will run flush to the shell/card edge. Wrap the page in PageBody (blocks/page-body) and mount inside AppShell/RoutedAppShell (the shell applies inset automatically), or use PageBody with inset for standalone pages, or apply lateral padding (px-4 sm:px-6 lg:px-8) on the page root. Never hand-roll a page column with only gap-* and no px-*.",
snippet: lines[headerLine - 1]?.trim().slice(0, 120) ?? "<PageHeader …>",
});
}
}

for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNo = i + 1;
Expand All @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions src/design/ui-vocabulary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,20 @@ export const HOUSE_RULES: readonly HouseRule[] = [
good: `<SelectTrigger><SelectValue /></SelectTrigger>`,
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: `<PageHeader title="Dashboard" /><StatOverview … />`,
good: `<PageBody><PageHeader title="Dashboard" /><StatOverview … /></PageBody>`,
},
{
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: `<Card className="p-0"><PageHeader … /></Card>`,
good: `<Card><CardHeader … /><CardContent>…</CardContent></Card>`,
},
{
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.",
Expand Down
Loading