learning_ai_invt_trdg/web/eslint-local/rules/no-button-with-stacked-children.js
Devin a0fcb65f5d chore(web): add local eslint plugin with UI audit guards (UI audit #6)
Adds a local ESLint plugin (web/eslint-local/) with three custom rules
implementing the preventive guardrails from docs/ui/UI_AUDIT.md §5:

  - bytelyst-trading/truncate-needs-title
      flags JSX elements using Tailwind 'truncate' / 'line-clamp-*' /
      'text-ellipsis' without a paired title= or aria-label= (Pattern E)

  - bytelyst-trading/grid-needs-minmax
      flags gridTemplateColumns string values with bare Nfr tracks not
      wrapped in minmax(0, ...). Catches both literal and template-string
      forms; verifies *every* fr is wrapped, not just one (Pattern F)

  - bytelyst-trading/no-button-with-stacked-children
      flags <Button> from @bytelyst/ui wrapping 2+ block children. The
      Button primitive applies whitespace-nowrap + fixed h-{size} which
      collapses stacked content; recommends native <button class="card-button">
      (Pattern A)

All wired into eslint.config.js as 'warn' (not error) so existing code
isn't broken; new violations show up immediately.

Also fixes the two bare-Nfr grids the new rule caught:
  - components/strategy/CodeStrategyEditor.tsx :270 — repeat(5, 1fr)
  - views/ScreenerView.tsx :142 — '100px 1fr 90px ...'

eslint src/ now reports zero bytelyst-trading/* warnings.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-10 09:23:05 +00:00

58 lines
2.3 KiB
JavaScript

// @ts-check
/**
* Warn when a `<Button>` (from @bytelyst/ui) wraps content with multiple
* block-level children (stacked spans/divs/headings).
*
* The Button primitive uses `whitespace-nowrap` + fixed `h-{size}` which
* collapses multi-line content. For card-shaped picker UI, use a native
* `<button>` with the `.card-button` utility class instead (see
* docs/ui/UI_AUDIT.md Pattern A).
*/
const BLOCK_TAGS = new Set(['div', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'section', 'article', 'header', 'footer']);
function isLikelyBlock(child) {
if (child.type !== 'JSXElement') return false;
const opening = child.openingElement;
const name = opening.name && opening.name.type === 'JSXIdentifier' ? opening.name.name : null;
if (!name) return false;
if (BLOCK_TAGS.has(name)) return true;
// <span> with style={{display:'block'}} or className containing 'block'
if (name === 'span') {
for (const attr of opening.attributes) {
if (attr.type !== 'JSXAttribute') continue;
if (attr.name.name === 'className') {
const v = attr.value;
const text = v?.type === 'Literal' ? String(v.value || '')
: v?.type === 'JSXExpressionContainer' && v.expression.type === 'TemplateLiteral'
? v.expression.quasis.map(q => q.value.cooked).join(' ')
: '';
if (/\bblock\b/.test(text)) return true;
}
}
}
return false;
}
export default {
meta: {
type: 'suggestion',
docs: { description: '<Button> with stacked block children collapses content; use native <button class="card-button"> instead' },
schema: [],
messages: {
stacked: '<Button> contains {{ count }} block-level children. The Button primitive applies whitespace-nowrap + fixed height which collapses stacked content. Use a native <button className="card-button"> instead — see docs/ui/UI_AUDIT.md Pattern A.',
},
},
create(context) {
return {
JSXElement(node) {
const opening = node.openingElement;
if (!opening.name || opening.name.type !== 'JSXIdentifier' || opening.name.name !== 'Button') return;
const blockCount = node.children.filter(isLikelyBlock).length;
if (blockCount >= 2) {
context.report({ node: opening, messageId: 'stacked', data: { count: blockCount } });
}
},
};
},
};