Skip to content
Merged
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
58 changes: 41 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,53 @@ Both share the same prompts and Claude pipeline.
CSS / SCSS / HTML → Claude Audit → Review & curate → Clean tokens → Export → Apply
```

1. **Audit** — Claude analyzes your CSS, groups near-duplicate colors into clusters, detects fonts, flags spacing values that don't fit a 4px grid, identifies duplicate transition/animation declarations, and lints layout patterns for accessibility and modern-CSS pitfalls (see [CSS layout linting](#css-layout-linting)).
1. **Audit** — Claude analyzes your CSS, groups near-duplicate colors into clusters, detects fonts, flags spacing values that don't fit a 4px grid, identifies duplicate transition/animation declarations, and lints layout patterns for accessibility, modern-CSS pitfalls, and `@property` type mismatches (see [CSS layout linting](#css-layout-linting)).
2. **Curate** — Review each cluster. Pick the canonical color, rename tokens, include or exclude entries, and select which fonts to keep. (CLI applies sensible defaults: include every cluster, keep non-system fonts, use the suggested 4px scale.)
3. **Export** — Generate production-ready output in any format.
4. **Apply** — Rewrite your source CSS in place so raw values reference the generated tokens (`#1976d2` → `var(--color-primary)`). Deterministic, no LLM — adoption becomes a reviewable git diff. See [`mint-ds apply`](#applying-tokens-to-source-css).

## CSS layout linting

Beyond color, font, and spacing tokens, the audit also lints your CSS for layout accessibility issues and modern-CSS pitfalls. These findings are returned in the raw `AuditReport` (write it to disk with `--report`); the accessibility and overflow checks also feed the chaos score.

| Category | Rule | Severity | What it flags |
| -------------------------- | ----------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **Layout accessibility** | `order` breaks DOM order | warning | A grid/flex child reordered with `order` so the visual order no longer matches the DOM — breaks keyboard navigation and SR flow |
| | reordering without tabindex | warning | An element visually reordered via `order` with no matching `tabindex` adjustment, so keyboard users navigate in DOM order |
| **Modern best practices** | `grid-when-flexbox-wrap-would-work` | suggestion | Single-column grid that a `flex` + `flex-wrap` layout would handle more simply |
| | `legacy-centering` | suggestion | `margin: 0 auto` + fixed width, or absolute-position + `transform` centering, where modern `flex`/`grid` centering is cleaner |
| | `flex-min-width-zero-hack` | suggestion | `min-width: 0` on a flex item — the classic overflow workaround that often hides a layout misunderstanding |
| | `fragile-nested-selectors` | suggestion | Selectors coupled to a brittle DOM shape (deep `>` / `+` chains) that a small HTML refactor would break |
| **Feature adoption** | `use-css-layers` | info | Large stylesheets (20+ rules) with no `@layer` organization |
| | `use-container-queries` | info | Component-scoped width `@media` queries that a `@container` query would express better |
| **Overflow & wrap safety** | `flex-wrap-missing` | warning | Flex container without `flex-wrap` — items can't wrap and may overflow on narrow viewports |
| | `missing-overflow-wrap` | suggestion | Sized grid/flex container (fixed width/height) with no `overflow` handling, so content can be clipped |

The chaos score gains **+1** when there are 3 or more layout-accessibility issues and **+1** when there are 4 or more overflow-safety issues. Adoption suggestions are informational only and never affect the score.
Beyond color, font, and spacing tokens, the audit also lints your CSS for layout accessibility issues, modern-CSS pitfalls, and broken `@property` type contracts. These findings are returned in the raw `AuditReport` (write it to disk with `--report`); the accessibility and overflow checks also feed the chaos score.

| Category | Rule | Severity | What it flags |
| -------------------------- | ----------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Layout accessibility** | `order` breaks DOM order | warning | A grid/flex child reordered with `order` so the visual order no longer matches the DOM — breaks keyboard navigation and SR flow |
| | reordering without tabindex | warning | An element visually reordered via `order` with no matching `tabindex` adjustment, so keyboard users navigate in DOM order |
| **Modern best practices** | `grid-when-flexbox-wrap-would-work` | suggestion | Single-column grid that a `flex` + `flex-wrap` layout would handle more simply |
| | `legacy-centering` | suggestion | `margin: 0 auto` + fixed width, or absolute-position + `transform` centering, where modern `flex`/`grid` centering is cleaner |
| | `flex-min-width-zero-hack` | suggestion | `min-width: 0` on a flex item — the classic overflow workaround that often hides a layout misunderstanding |
| | `fragile-nested-selectors` | suggestion | Selectors coupled to a brittle DOM shape (deep `>` / `+` chains) that a small HTML refactor would break |
| **Feature adoption** | `use-css-layers` | info | Large stylesheets (20+ rules) with no `@layer` organization |
| | `use-container-queries` | info | Component-scoped width `@media` queries that a `@container` query would express better |
| **Overflow & wrap safety** | `flex-wrap-missing` | warning | Flex container without `flex-wrap` — items can't wrap and may overflow on narrow viewports |
| | `missing-overflow-wrap` | suggestion | Sized grid/flex container (fixed width/height) with no `overflow` handling, so content can be clipped |
| **@property type safety** | `invalid-initial-value` | warning | An `initial-value` that doesn't parse as the declared `syntax`, or a missing one on a non-universal syntax — the browser rejects the whole registration |
| | `fallback-type-mismatch` | warning | A `var()` fallback that contradicts the registered syntax (`var(--my-color, 14px)` on a `<color>`), so the fallback can never apply |
| | `property-type-mismatch` | suggestion | A registered property used where its declared syntax can't apply — a `<length>` property assigned to `color` |

The chaos score gains **+1** when there are 3 or more layout-accessibility issues and **+1** when there are 4 or more overflow-safety issues. Adoption suggestions and `@property` findings are informational for the score and never affect it.

### `@property` type safety

An `@property` at-rule declares a type contract for a custom property. Mint reads every registration in your CSS and checks it — and its `var()` usages — against the declared `syntax` descriptor:

<!-- prettier-ignore-start -->
```css
@property --spacing-unit {
syntax: '<length>';
inherits: false;
initial-value: red; /* invalid-initial-value — the browser drops this registration */
}

.button {
background-color: var(--brand-color, 14px); /* fallback-type-mismatch — <color> can't fall back to a length */
color: var(--spacing-unit); /* property-type-mismatch — <length> used as a color */
}
```
<!-- prettier-ignore-end -->

`invalid-initial-value` is the one worth fixing first: when the `initial-value` doesn't match the declared `syntax`, the browser rejects the registration outright and the property silently reverts to unregistered behaviour — losing both the type contract and the ability to animate or transition it. Registrations declaring the universal syntax (`*`) are skipped, since any value satisfies them. Findings land in `propertyTypeIssues` on the `AuditReport`.

## Example — Frankenstein

Expand Down
48 changes: 48 additions & 0 deletions lib/__fixtures__/at-property.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/* Fixture for STEP 13 — CSS @property type checking.
Covers one clean registration plus one case per reported rule. */

/* Valid: initial-value parses as the declared syntax. */
@property --brand-color {
syntax: '<color>';
inherits: false;
initial-value: #6366f1;
}

/* invalid-initial-value: declared <length>, initial-value is a color.
The browser rejects this registration outright. */
@property --spacing-unit {
syntax: '<length>';
inherits: false;
initial-value: red;
}

/* invalid-initial-value: non-universal syntax with no initial-value at all. */
@property --card-radius {
syntax: '<length>';
inherits: true;
}

/* Universal syntax — not type-checkable, STEP 13 must skip it. */
@property --anything {
syntax: '*';
inherits: false;
}

.button {
/* fallback-type-mismatch: <color> property falling back to a length. */
background-color: var(--brand-color, 14px);
padding: var(--spacing-unit, 8px);
border-radius: var(--card-radius, 4px);
}

.card {
/* property-type-mismatch: a <length> property used as a color value. */
color: var(--spacing-unit);
gap: var(--spacing-unit, 12px);
}

.badge {
/* Correct usage — must not be reported. */
background-color: var(--brand-color, #4f46e5);
border-radius: var(--card-radius, 6px);
}
33 changes: 31 additions & 2 deletions lib/__tests__/audit-summary.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,16 @@ describe('formatLintSummary', () => {
modernPracticeIssues: [],
adoptionSuggestions: [],
overflowSafetyIssues: [],
propertyTypeIssues: [],
}
expect(formatLintSummary(audit)).toBe('')
})

it('includes the @property type count when issues are present', () => {
const audit = { propertyTypeIssues: [{}, {}, {}] }
expect(formatLintSummary(audit)).toBe('3 property types')
})

it('includes the layout a11y count when issues are present', () => {
const audit = { layoutA11yIssues: [{}, {}] }
expect(formatLintSummary(audit)).toBe('2 layout a11y')
Expand All @@ -34,15 +40,16 @@ describe('formatLintSummary', () => {
)
})

it('labels all four categories in a stable order', () => {
it('labels all five categories in a stable order', () => {
const audit = {
layoutA11yIssues: [{}],
modernPracticeIssues: [{}],
adoptionSuggestions: [{}],
overflowSafetyIssues: [{}],
propertyTypeIssues: [{}],
}
expect(formatLintSummary(audit)).toBe(
'1 layout a11y · 1 modern-practice · 1 adoption · 1 overflow'
'1 layout a11y · 1 modern-practice · 1 adoption · 1 overflow · 1 property types'
)
})
})
Expand Down Expand Up @@ -88,11 +95,33 @@ describe('collectLintGroups', () => {
layoutA11yIssues: [{ selector: '.a', reason: 'r', severity: 'warning' }],
modernPracticeIssues: [],
adoptionSuggestions: [{ selector: '', reason: 'r', severity: 'info' }],
propertyTypeIssues: [
{ selector: '.btn', reason: 'r', severity: 'warning' },
],
}
expect(collectLintGroups(audit).map((g) => g.key)).toEqual([
'layoutA11yIssues',
'adoptionSuggestions',
'overflowSafetyIssues',
'propertyTypeIssues',
])
})

it('labels the @property category for display', () => {
const issue = {
selector: '.button',
property: 'background-color',
rule: 'fallback-type-mismatch',
severity: 'warning',
reason: '`<color>` property has a `<length>` fallback',
declaredSyntax: '<color>',
}
expect(collectLintGroups({ propertyTypeIssues: [issue] })).toEqual([
{
key: 'propertyTypeIssues',
label: '@property type safety',
issues: [issue],
},
])
})
})
110 changes: 110 additions & 0 deletions lib/__tests__/css-auditor.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -549,3 +549,113 @@ describe('CssAuditor overflowSafetyIssues integration', () => {
expect(result.overflowSafetyIssues).toBeUndefined()
})
})

describe('buildAuditPrompt @property type checking', () => {
it('includes STEP 13 for @property type checking', () => {
const css = 'body { color: red; }'
const prompt = buildAuditPrompt(css)
expect(prompt.content).toContain('STEP 13')
expect(prompt.content).toContain('@PROPERTY TYPE CHECKING')
})

it('instructs LLM to validate initial-value against the declared syntax', () => {
const css =
'@property --gap { syntax: "<length>"; initial-value: red; inherits: false; }'
const prompt = buildAuditPrompt(css)
expect(prompt.content).toContain('invalid-initial-value')
expect(prompt.content).toContain('initial-value')
})

it('instructs LLM to detect var() fallbacks that contradict the syntax', () => {
const css = '.btn { color: var(--brand, 14px); }'
const prompt = buildAuditPrompt(css)
expect(prompt.content).toContain('fallback-type-mismatch')
})

it('instructs LLM to flag registered properties used in a foreign context', () => {
const css = '.btn { background-color: var(--spacing-unit); }'
const prompt = buildAuditPrompt(css)
expect(prompt.content).toContain('property-type-mismatch')
})

it('documents the syntax descriptors it can type-check', () => {
const css = 'body { color: red; }'
const prompt = buildAuditPrompt(css)
for (const syntax of [
'<color>',
'<length>',
'<number>',
'<integer>',
'<percentage>',
'<angle>',
'<time>',
]) {
expect(prompt.content).toContain(syntax)
}
})

it('includes propertyTypeIssues in the output example', () => {
const css = 'body { color: red; }'
const prompt = buildAuditPrompt(css)
expect(prompt.content).toContain('propertyTypeIssues')
expect(prompt.content).toContain('declaredSyntax')
})

it('caps the number of reported propertyTypeIssues', () => {
const css = 'body { color: red; }'
const prompt = buildAuditPrompt(css)
expect(prompt.content).toContain('up to 8 propertyTypeIssues')
})
})

describe('CssAuditor propertyTypeIssues integration', () => {
const baseReport = {
brand: 'test',
chaosScore: 3,
summary: '',
colorClusters: [],
fonts: [],
spacing: { found: [], suggestedScale: {}, nonScaleValues: [] },
lineHeights: { found: [], suggestedScale: {}, unitlessMix: false },
layoutA11yIssues: [],
modernPracticeIssues: [],
adoptionSuggestions: [],
overflowSafetyIssues: [],
}

const auditWith = async (report) => {
const sendPromptSpy = vi.fn().mockResolvedValue(JSON.stringify(report))
const auditor = new CssAuditor(
{ sendPrompt: sendPromptSpy },
{ maxTokens: { audit: 3000, parse: 4000, export: 6000 } }
)
return auditor.audit({ content: 'test', system: 'sys' })
}

it('audit parses response with propertyTypeIssues', async () => {
const issue = {
selector: '.button',
property: 'background-color',
rule: 'fallback-type-mismatch',
severity: 'warning',
reason: 'Registered as `<color>` but the fallback `14px` is a `<length>`',
propertyName: '--brand-color',
declaredSyntax: '<color>',
}
const result = await auditWith({
...baseReport,
propertyTypeIssues: [issue],
})
expect(result.propertyTypeIssues).toEqual([issue])
})

it('audit handles empty propertyTypeIssues array', async () => {
const result = await auditWith({ ...baseReport, propertyTypeIssues: [] })
expect(result.propertyTypeIssues).toEqual([])
})

it('audit handles missing propertyTypeIssues gracefully', async () => {
const result = await auditWith(baseReport)
expect(result.propertyTypeIssues).toBeUndefined()
})
})
Loading
Loading