Skip to content

Commit f75943a

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(lint): SDUI styling validator (ADR-0065) (#2225)
validateResponsiveStyles — a pure (stack) => Finding[] rule wired into os validate AND os compile so hand-authored and AI-generated pages are held to the same bar (ADR-0019). Error: styled node with no id (CSS can't be scoped -> silently dropped). Warnings: Tailwind-in-className (dead in metadata), smaller breakpoint with no large base, unknown CSS property, unknown/typo'd design token. Visual quality (is it ugly) is explicitly out of scope -> needs render + VLM gate. Unit tests 9/9 (+ full lint suite 42). Loop-verified on the real app-showcase styling-gallery page: it is lint-CLEAN, and a mutated variant is caught on all 4 defects. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f73d40a commit f75943a

6 files changed

Lines changed: 397 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(lint): SDUI styling validator (ADR-0065)
7+
8+
`validateResponsiveStyles` — a pure `(stack) => Finding[]` rule wired into
9+
`os validate` and `os compile`, so hand-authored and AI-generated pages are
10+
held to the same bar (ADR-0019). Catches the deterministic ways a
11+
`responsiveStyles` block silently fails: a styled node with no `id` (CSS can't
12+
be scoped → dropped) is an **error**; warnings cover Tailwind-in-`className`
13+
(silently dead in metadata), a smaller breakpoint with no `large` base, unknown
14+
CSS properties, and unknown/typo'd design tokens. Quality/visual judgement
15+
(is it ugly) is out of scope — that needs render + a VLM gate.

packages/cli/src/commands/compile.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { loadConfig } from '../utils/config.js';
1010
import { lowerCallables } from '../utils/lower-callables.js';
1111
import { validateStackExpressions } from '@objectstack/lint';
1212
import { validateWidgetBindings } from '@objectstack/lint';
13+
import { validateResponsiveStyles } from '@objectstack/lint';
1314
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
1415
import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js';
1516
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
@@ -195,6 +196,37 @@ export default class Compile extends Command {
195196
}
196197
}
197198

199+
// 3c. SDUI scoped-styling correctness (ADR-0065) — a styled node without
200+
// an `id` drops its CSS silently; Tailwind-in-className does nothing
201+
// from metadata. Same bar for hand-authored and AI-generated pages
202+
// (ADR-0019). Errors fail the build; warnings are advisory.
203+
if (!flags.json) printStep('Checking SDUI styling (ADR-0065)...');
204+
const styleFindings = validateResponsiveStyles(result.data as Record<string, unknown>);
205+
const styleErrors = styleFindings.filter((f) => f.severity === 'error');
206+
const styleWarnings = styleFindings.filter((f) => f.severity === 'warning');
207+
if (styleErrors.length > 0) {
208+
if (flags.json) {
209+
console.log(JSON.stringify({ success: false, error: 'SDUI styling validation failed', issues: styleErrors }));
210+
this.exit(1);
211+
}
212+
console.log('');
213+
printError(`SDUI styling check failed (${styleErrors.length} issue${styleErrors.length > 1 ? 's' : ''})`);
214+
for (const f of styleErrors.slice(0, 50)) {
215+
console.log(` • ${f.where}: ${f.message}`);
216+
console.log(chalk.dim(` ${f.hint}`));
217+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
218+
}
219+
this.exit(1);
220+
}
221+
if (styleWarnings.length > 0 && !flags.json) {
222+
console.log('');
223+
for (const w of styleWarnings) {
224+
printWarning(`${w.where}: ${w.message}`);
225+
console.log(chalk.dim(` ${w.hint}`));
226+
console.log(chalk.dim(` rule: ${w.rule} at ${w.path}`));
227+
}
228+
}
229+
198230
// 3d. Flow authoring anti-pattern lint (#1874) — advisory warnings for
199231
// valid-but-fragile flow metadata (e.g. a record-change trigger using a
200232
// date-EQUALITY time condition that only fires on the exact day). Guides

packages/cli/src/commands/validate.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/s
77
import { loadConfig } from '../utils/config.js';
88
import { validateStackExpressions } from '@objectstack/lint';
99
import { validateWidgetBindings } from '@objectstack/lint';
10+
import { validateResponsiveStyles } from '@objectstack/lint';
1011
import {
1112
printHeader,
1213
printKV,
@@ -133,6 +134,36 @@ export default class Validate extends Command {
133134
this.exit(1);
134135
}
135136

137+
// 3b. SDUI scoped-styling correctness (ADR-0065) — a styled node's
138+
// responsiveStyles must be scopable (needs an `id`), reference real
139+
// CSS properties + design tokens, and carry a `large` base;
140+
// Tailwind-in-className silently does nothing. Same bar for
141+
// hand-authored and AI-generated pages (ADR-0019).
142+
if (!flags.json) printStep('Checking SDUI styling (ADR-0065)...');
143+
const styleFindings = validateResponsiveStyles(result.data as Record<string, unknown>);
144+
const styleErrors = styleFindings.filter((f) => f.severity === 'error');
145+
const styleWarnings = styleFindings.filter((f) => f.severity === 'warning');
146+
147+
if (styleErrors.length > 0) {
148+
if (flags.json) {
149+
console.log(JSON.stringify({
150+
valid: false,
151+
errors: styleErrors,
152+
warnings: [...widgetWarnings, ...styleWarnings],
153+
duration: timer.elapsed(),
154+
}, null, 2));
155+
this.exit(1);
156+
}
157+
console.log('');
158+
printError(`SDUI styling check failed (${styleErrors.length} issue${styleErrors.length > 1 ? 's' : ''})`);
159+
for (const f of styleErrors.slice(0, 50)) {
160+
console.log(` • ${f.where}: ${f.message}`);
161+
console.log(chalk.dim(` ${f.hint}`));
162+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
163+
}
164+
this.exit(1);
165+
}
166+
136167
// 4. Collect and display stats
137168
const stats = collectMetadataStats(config);
138169

@@ -141,7 +172,7 @@ export default class Validate extends Command {
141172
valid: true,
142173
manifest: config.manifest,
143174
stats,
144-
warnings: [...exprWarnings, ...widgetWarnings],
175+
warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings],
145176
duration: timer.elapsed(),
146177
}, null, 2));
147178
return;
@@ -156,6 +187,9 @@ export default class Validate extends Command {
156187
for (const f of widgetWarnings) {
157188
warnings.push(`${f.where}: ${f.message}`);
158189
}
190+
for (const f of styleWarnings) {
191+
warnings.push(`${f.where}: ${f.message}`);
192+
}
159193
if (stats.objects === 0) {
160194
warnings.push('No objects defined — this stack has no data model');
161195
}

packages/lint/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,13 @@ export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-wid
2525

2626
export { validateStackExpressions } from './validate-expressions.js';
2727
export type { ExprIssue } from './validate-expressions.js';
28+
29+
export {
30+
validateResponsiveStyles,
31+
STYLE_NODE_MISSING_ID,
32+
STYLE_CLASSNAME_TAILWIND,
33+
STYLE_RESPONSIVE_NO_BASE,
34+
STYLE_UNKNOWN_CSS_PROPERTY,
35+
STYLE_UNKNOWN_TOKEN,
36+
} from './validate-responsive-styles.js';
37+
export type { StyleFinding, StyleSeverity } from './validate-responsive-styles.js';
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
validateResponsiveStyles,
6+
STYLE_NODE_MISSING_ID,
7+
STYLE_CLASSNAME_TAILWIND,
8+
STYLE_RESPONSIVE_NO_BASE,
9+
STYLE_UNKNOWN_CSS_PROPERTY,
10+
STYLE_UNKNOWN_TOKEN,
11+
} from './validate-responsive-styles.js';
12+
13+
/** Wrap component nodes into a minimal stack with one page. */
14+
const stackWith = (...components: any[]) => ({
15+
pages: [{ name: 'pricing', regions: [{ name: 'main', components }] }],
16+
});
17+
18+
const rules = (findings: ReturnType<typeof validateResponsiveStyles>) => findings.map((f) => f.rule);
19+
20+
describe('validateResponsiveStyles (ADR-0065)', () => {
21+
it('passes a clean page styled with responsiveStyles + tokens', () => {
22+
const findings = validateResponsiveStyles(stackWith({
23+
id: 'card', type: 'flex',
24+
responsiveStyles: {
25+
large: { display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', padding: 'var(--space-6)', backgroundColor: 'var(--surface)', border: '1px solid hsl(var(--primary))' },
26+
small: { padding: 'var(--space-4)' },
27+
},
28+
properties: {
29+
children: [
30+
{ id: 'price', type: 'element:text', responsiveStyles: { large: { fontSize: '40px', color: 'var(--text-strong)' } }, properties: { content: '$29' } },
31+
],
32+
},
33+
}));
34+
expect(findings).toEqual([]);
35+
});
36+
37+
it('errors when a styled node has no id (CSS cannot be scoped)', () => {
38+
const findings = validateResponsiveStyles(stackWith({
39+
type: 'flex', responsiveStyles: { large: { padding: 'var(--space-4)' } },
40+
}));
41+
expect(rules(findings)).toContain(STYLE_NODE_MISSING_ID);
42+
expect(findings[0].severity).toBe('error');
43+
});
44+
45+
it('warns when a smaller breakpoint has no large base', () => {
46+
const findings = validateResponsiveStyles(stackWith({
47+
id: 'x', type: 'flex', responsiveStyles: { small: { padding: 'var(--space-2)' } },
48+
}));
49+
expect(rules(findings)).toContain(STYLE_RESPONSIVE_NO_BASE);
50+
});
51+
52+
it('warns on Tailwind-looking className (silently dead in metadata)', () => {
53+
const findings = validateResponsiveStyles(stackWith({
54+
id: 'x', type: 'flex', className: 'flex flex-col gap-4 md:grid-cols-2 bg-primary',
55+
}));
56+
expect(rules(findings)).toContain(STYLE_CLASSNAME_TAILWIND);
57+
});
58+
59+
it('warns on an unknown CSS property (typo)', () => {
60+
const findings = validateResponsiveStyles(stackWith({
61+
id: 'x', type: 'flex', responsiveStyles: { large: { flexDirektion: 'column' } },
62+
}));
63+
expect(rules(findings)).toContain(STYLE_UNKNOWN_CSS_PROPERTY);
64+
});
65+
66+
it('warns on an unknown design token (typo)', () => {
67+
const findings = validateResponsiveStyles(stackWith({
68+
id: 'x', type: 'flex', responsiveStyles: { large: { padding: 'var(--spcae-6)' } },
69+
}));
70+
expect(rules(findings)).toContain(STYLE_UNKNOWN_TOKEN);
71+
});
72+
73+
it('resolves known tokens (incl. hsl(var(--primary))) without complaint', () => {
74+
const findings = validateResponsiveStyles(stackWith({
75+
id: 'x', type: 'flex',
76+
responsiveStyles: { large: { color: 'hsl(var(--primary))', boxShadow: '0 0 0 3px hsl(var(--primary) / 0.25), var(--shadow-lg)', borderRadius: 'var(--radius-xl)' } },
77+
}));
78+
expect(findings).toEqual([]);
79+
});
80+
81+
it('recurses into nested properties.children', () => {
82+
const findings = validateResponsiveStyles(stackWith({
83+
id: 'root', type: 'flex', responsiveStyles: { large: { display: 'flex' } },
84+
properties: { children: [
85+
{ type: 'flex', responsiveStyles: { large: { gap: 'var(--space-2)' } } }, // missing id, nested
86+
] },
87+
}));
88+
expect(rules(findings)).toContain(STYLE_NODE_MISSING_ID);
89+
});
90+
91+
it('does not flag a plain non-Tailwind className', () => {
92+
const findings = validateResponsiveStyles(stackWith({
93+
id: 'x', type: 'flex', className: 'my-custom-scope', responsiveStyles: { large: { display: 'flex' } },
94+
}));
95+
expect(rules(findings)).not.toContain(STYLE_CLASSNAME_TAILWIND);
96+
});
97+
});

0 commit comments

Comments
 (0)