Skip to content
Merged
43 changes: 43 additions & 0 deletions create-bestax/src/__tests__/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,49 @@ describe('constants', () => {
});
});

describe('CLAUDE_MD house style', () => {
const md = CLAUDE_MD('my-app', {
bulmaFlavor: 'complete',
iconLibrary: 'none',
});

it('maps common inline-style patterns to concrete helper props', () => {
// the never-inline rule ships with a lookup table, not just a prohibition (#350)
expect(md).toContain('Never inline `style={{}}`');
expect(md).toContain('Helper props instead');
expect(md).toContain('`mt="4"`');
expect(md).toContain('`4`=1rem');
expect(md).toContain('`textAlign="centered"`');
expect(md).toContain('`textColor`');
expect(md).toContain('`bgColor`');
expect(md).toContain('`textSize="1"`');
expect(md).toContain('`textWeight`');
expect(md).toContain('`display="flex"`');
expect(md).toContain('`flexGrow="1"`');
expect(md).toContain('`displayMobile`');
});

it('states the no-helper fallback: a named class in the stylesheet, never inline style', () => {
expect(md).toContain('No helper matches');
expect(md).toContain('`src/App.css`');
expect(md).toContain('pass it via `className`');
expect(md).toContain('still never inline `style`');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('flags Notification as the mixed case: textColor yes, bgColor no', () => {
// Notification omits `backgroundColor` and re-adds only `textColor`, so a
// `bgColor` on it is inert — its background is the semantic `color` prop.
expect(md).toContain('`Notification` is the mixed case');
expect(md).toContain('not `bgColor`');
});

it('documents the gap exception: no flex gap helper, but Grid/Columns take a gap prop', () => {
expect(md).toContain('Flex layouts have no `gap` helper');
expect(md).toContain('`Grid` and `Columns`');
expect(md).toContain('take a `gap` prop');
});
});

describe('DEFAULT_PROJECT_NAME', () => {
it('should be a valid project name', () => {
expect(DEFAULT_PROJECT_NAME).toBeDefined();
Expand Down
27 changes: 24 additions & 3 deletions create-bestax/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,31 @@ ${setupLines.join('\n')}

## House style

- Never inline \`style={{}}\` — use the helper props every component accepts (\`m*\`/\`p*\`
spacing, \`textColor\`/\`bgColor\`, \`display="flex"\`, \`flexDirection\`, \`alignItems\`).
Flex layouts have no \`gap\` helper — space children with margins (\`Grid\` and \`Columns\`
**Never inline \`style={{}}\`** — the components accept helper props that cover the common
cases. Before writing \`style\`, translate each declaration with this table:

| Inline style you're about to write | Helper props instead |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| \`marginTop: '1rem'\` (any margin/padding) | \`mt="4"\` — \`m\`/\`mt\`/\`mx\`/\`p\`/\`py\`/… scale: \`1\`=0.25rem, \`2\`=0.5rem, \`3\`=0.75rem, \`4\`=1rem, \`5\`=1.5rem, \`6\`=3rem (nearest step) |
| \`textAlign: 'center'\` | \`textAlign="centered"\` (also \`left\`, \`right\`, \`justified\`) |
| \`color: '#…'\` | \`textColor\` with the nearest Bulma color: \`primary\`, \`link\`, \`info\`, \`success\`, \`warning\`, \`danger\`, \`white\`, \`black\`, \`grey\` (+ \`grey-light\`, \`grey-dark\`, …) |
| \`backgroundColor: '#…'\` | \`bgColor\` (same palette) |
| \`fontSize: …\` | \`textSize="1"\`…\`"7"\` (\`1\` largest) — for headings use \`Title\`/\`SubTitle\` \`size\` |
| \`fontWeight: …\` | \`textWeight\`: \`light\`, \`normal\`, \`medium\`, \`semibold\`, \`bold\` |
| \`textTransform\`, italics | \`textTransform\`: \`uppercase\`, \`lowercase\`, \`capitalized\`, \`italic\` |
| \`display: 'flex'\` + flex properties | same-named props: \`display="flex"\`, \`flexDirection\`, \`justifyContent\`, \`alignItems\`, \`flexWrap\` |
| \`height: '100%'\` on a flex child | \`flexGrow="1"\` |
| \`display: 'none'\` | \`visibility="hidden"\`, or responsive \`display*\` props (\`displayMobile\`, \`displayTablet\`, …) |

- Spacing, typography, and flex helpers are on every component; \`textColor\`/\`bgColor\` are
on the content components (\`Box\`, \`Block\`, \`Title\`, \`Content\`, \`Hero\`, \`Card\`, …) — the
ones with a semantic \`color\` variant (\`Tag\`, \`Tabs\`, \`Panel\`) take \`color\` instead.
\`Notification\` is the mixed case: it takes \`textColor\`, but its background comes from
the semantic \`color\` prop, not \`bgColor\`.
- Flex layouts have no \`gap\` helper — space children with margins (\`Grid\` and \`Columns\`
take a \`gap\` prop, so prefer that there).
- No helper matches (e.g. \`maxWidth\`, a one-off gradient)? Add a named class to
\`src/App.css\` and pass it via \`className\` — still never inline \`style\`.
- Compose existing components before writing custom CSS; theme via \`Theme\` and \`--bulma-*\`
variables, never hardcoded colors.
- \`Navbar.Burger\`/\`Navbar.Menu\` are controlled — wire \`active\` via state on both, and pair
Expand Down
144 changes: 144 additions & 0 deletions scripts/check-conformance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
* removing styles, shrink it with `--update-baseline`)
* skills-sync the theming skill's references name every registered
* CSS variable and every color-prop component
* style-mapping-sync the inline-style → helper-prop mapping (#350) says
* the same thing in all three deliberate copies
* (CLAUDE_MD template + both JSX-generating skills),
* and names only props that really exist
*/
import { readFile, readdir, writeFile, access } from 'node:fs/promises';
import { join, relative, dirname, basename } from 'node:path';
Expand Down Expand Up @@ -462,6 +466,145 @@ async function checkSkillsSync() {
return violations;
}

// The inline-style → helper-prop mapping (#350) is deliberately triplicated so
// it is in context at generation time: the scaffolded CLAUDE_MD template plus
// the two skills that generate the most JSX. Only the template copy is guarded
// by jest, so this check pins the mapping three ways:
// 1. the load-bearing facts (spacing scale, value sets, the gap rule, the
// named-class fallback) appear verbatim in every copy;
// 2. the copies name the same set of props, so a row added to one skill
// cannot silently go missing from the others;
// 3. every prop named is really declared in bulma-ui/src — a mapping that
// points at a prop the library lacks makes the model emit an inert
// attribute, which is the exact failure #350 exists to prevent.
// Matching strips backslashes first so the TS template's escaped backticks
// compare equal to the markdown copies.
const MAPPING_FILES = [
'create-bestax/src/constants.ts',
'skills/bestax-layout-scaffold/SKILL.md',
'skills/bestax-custom-component/SKILL.md',
];

// Prop tokens in the "Helper props instead" column. Two unambiguous forms:
// `name="value"` (always a prop) and a bare `name` with an internal capital
// (textColor, flexDirection). Bare all-lowercase spans are values, not props
// (`bold`, `centered`, `primary`), and PascalCase spans are component names.
function mappingPropTokens(source) {
const lines = source.replace(/\\`/g, '`').split('\n');
const start = lines.findIndex(
l => l.includes('|') && l.includes('Helper props instead')
);
if (start < 0) return null;
const props = new Set();
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line.startsWith('|')) break;
if (/^\|[\s:|-]+\|$/.test(line)) continue; // separator row
// Column 1 is the CSS declaration being replaced; only scan the rest.
const helper = line.split('|').slice(2, -1).join('|');
for (const m of helper.matchAll(/`([a-z][a-zA-Z0-9]*)="[^"]*"`/g)) {
props.add(m[1]);
}
for (const m of helper.matchAll(/`([a-z][a-zA-Z0-9]*)`/g)) {
if (/[A-Z]/.test(m[1])) props.add(m[1]);
}
}
return props;
}

async function checkStyleMappingSync() {
const violations = [];
const FILES = MAPPING_FILES;
const FACTS = [
'`1`=0.25rem, `2`=0.5rem, `3`=0.75rem, `4`=1rem, `5`=1.5rem, `6`=3rem',
'`textAlign="centered"`',
'`textColor`',
'`bgColor`',
'`textSize="1"`…`"7"`',
'`textWeight`: `light`, `normal`, `medium`, `semibold`, `bold`',
'`textTransform`: `uppercase`, `lowercase`, `capitalized`, `italic`',
'`display="flex"`, `flexDirection`, `justifyContent`, `alignItems`, `flexWrap`',
'`flexGrow="1"`',
'`visibility="hidden"`',
'`displayMobile`',
'no `gap` helper',
'take a `gap` prop',
'named class',
];
for (const rel of FILES) {
const src = (await readFile(join(REPO, rel), 'utf8')).replace(/\\/g, '');
for (const fact of FACTS) {
if (!src.includes(fact)) {
violations.push(
`${rel} is missing "${fact}" from the inline-style → helper-prop ` +
`mapping (#350). The mapping is deliberately triplicated ` +
`(create-bestax CLAUDE_MD template + both skills) so it is ` +
`always in context — apply the same edit to all three copies.`
);
}
}
}

// 2. The copies must name the same props. A row added to (or dropped from)
// one table but not the others is drift the FACTS list cannot see,
// because FACTS only pins rows that exist today.
const tables = new Map();
for (const rel of FILES) {
const props = mappingPropTokens(await readFile(join(REPO, rel), 'utf8'));
if (props === null) {
violations.push(
`${rel} no longer contains an inline-style → helper-prop mapping ` +
`table (no row with a "Helper props instead" header cell). The ` +
`mapping must stay in all of ${FILES.join(', ')} — it is only ` +
`useful when it is in context as JSX is generated (#350).`
);
continue;
}
tables.set(rel, props);
}
if (tables.size !== FILES.length) return violations;

const union = new Set([...tables.values()].flatMap(s => [...s]));
for (const [rel, props] of tables) {
const missing = [...union].filter(p => !props.has(p)).sort();
if (missing.length) {
violations.push(
`${rel}'s mapping table omits ${missing.length} prop(s) named in the ` +
`other copies: ${missing.join(', ')}. Add the matching row(s) — the ` +
`copies in ${FILES.join(', ')} must stay in sync.`
);
}
}

// 3. Every prop named must really exist. Helper props live in the helpers/
// interfaces; component-remapped ones (textColor, bgColor) are declared
// on the components themselves, so scan the whole source tree.
const srcDir = join(REPO, 'bulma-ui', 'src');
const declared = new Set();
for (const file of [
...(await walk(srcDir, '.ts')),
...(await walk(srcDir, '.tsx')),
]) {
const src = await readFile(file, 'utf8');
for (const m of src.matchAll(/^\s+([a-zA-Z][a-zA-Z0-9]*)\?:/gm)) {
declared.add(m[1]);
}
}
for (const [rel, props] of tables) {
const unknown = [...props].filter(p => !declared.has(p)).sort();
if (unknown.length) {
violations.push(
`${rel}'s mapping table names ${unknown.length} prop(s) that are not ` +
`declared anywhere in bulma-ui/src: ${unknown.join(', ')}. Fix the ` +
`name or drop the row — a mapping that points at a prop the ` +
`library does not have makes the model emit an inert attribute.`
);
}
}

return violations;
}

async function checkStoryPerComponent() {
const violations = [];
const modules = parseExportedModules(await readFile(INDEX_TS, 'utf8'));
Expand Down Expand Up @@ -647,6 +790,7 @@ const CHECKS = {
'docs-sections': checkDocsSections,
'scss-conformance': checkScssConformance,
'skills-sync': checkSkillsSync,
'style-mapping-sync': checkStyleMappingSync,
'story-per-component': checkStoryPerComponent,
'compound-family': checkCompoundFamily,
'autodocs-tag': checkAutodocsTag,
Expand Down
31 changes: 28 additions & 3 deletions skills/bestax-custom-component/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,32 @@ free. `references/api.md` documents the helpers.
## Styling ladder — use the lowest rung that works

**Rung 1 — helper props only (default).** House rules: never `style={{}}`. Layout with
`Block`/`Box` and `display="flex"`, `flexDirection`, `alignItems`, `justifyContent`. There is
**no `gap` helper** — space children with `m*`/`p*` margins instead.
`Block`/`Box` and `display="flex"`, `flexDirection`, `alignItems`, `justifyContent`. Flex
layouts have **no `gap` helper** — space children with `m*`/`p*` margins instead (`Grid` and
`Columns` take a `gap` prop). Before writing `style={{ … }}` anywhere, translate each
declaration:

| Inline style you're about to write | Helper props instead |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `marginTop: '1rem'` (any margin/padding) | `mt="4"` — `m`/`mt`/`mx`/`p`/`py`/… scale: `1`=0.25rem, `2`=0.5rem, `3`=0.75rem, `4`=1rem, `5`=1.5rem, `6`=3rem (nearest step) |
| `textAlign: 'center'` | `textAlign="centered"` (also `left`, `right`, `justified`) |
| `color: '#…'` | `textColor` with the nearest Bulma color: `primary`, `link`, `info`, `success`, `warning`, `danger`, `white`, `black`, `grey` (+ `grey-light`, `grey-dark`, …) |
| `backgroundColor: '#…'` | `bgColor` (same palette) |
| `fontSize: …` | `textSize="1"`…`"7"` (`1` largest) — for headings use `Title`/`SubTitle` `size` |
| `fontWeight: …` | `textWeight`: `light`, `normal`, `medium`, `semibold`, `bold` |
| `textTransform`, italics | `textTransform`: `uppercase`, `lowercase`, `capitalized`, `italic` |
| `display: 'flex'` + flex properties | same-named props: `display="flex"`, `flexDirection`, `justifyContent`, `alignItems`, `flexWrap` |
| `height: '100%'` on a flex child | `flexGrow="1"` |
| `display: 'none'` | `visibility="hidden"`, or responsive `display*` props (`displayMobile`, `displayTablet`, …) |

Spacing, typography, and flex helpers are on every component; `textColor`/`bgColor` are on
the content components you'll compose with (`Box`, `Block`, `Title`, `Content`, `Card`, …) —
the ones with a semantic `color` variant (`Tag`, `Tabs`, `Panel`) take `color` instead.
`Notification` is the mixed case: it takes `textColor`, but its background comes from the
semantic `color` prop, not `bgColor`.

A value with no helper equivalent (`maxWidth: 720`, a one-off gradient) moves you to rung 2 —
a named class in a stylesheet — never to inline `style`.

**Rung 2 — a plain CSS file**, scoped under the component's class, consuming `--bulma-*`
variables — never literal colors, so `Theme` and dark mode keep working:
Expand Down Expand Up @@ -153,7 +177,8 @@ render, prop→class mapping, helper-prop passthrough (`m="3"` → `m-3`), and t
- [ ] Inventory checked (catalog + bestax.io/docs/api) and the decision surfaced to the user.
- [ ] All imports from `@allxsmith/bestax-bulma` (no deep/internal paths).
- [ ] Composition first — existing components + helper props before any CSS.
- [ ] No inline `style={{}}` anywhere.
- [ ] No inline `style={{}}` anywhere — translate via the rung-1 mapping table; values with
no helper equivalent get a named class (rung 2).
- [ ] Lowest sufficient ladder rung (helper props → scoped CSS vars → Sass).
- [ ] All colors/radii derived from `--bulma-*` variables — no literals.
- [ ] Renders correctly via `npm run dev`, including dark mode.
Loading
Loading