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>
This commit is contained in:
Devin 2026-05-10 09:23:05 +00:00
parent 67c9ecb589
commit a0fcb65f5d
7 changed files with 183 additions and 3 deletions

18
web/eslint-local/index.js Normal file
View File

@ -0,0 +1,18 @@
// @ts-check
/**
* Local ESLint plugin for the trading web app.
*
* Rules implement the preventive guards from docs/ui/UI_AUDIT.md §5.
* Wired into eslint.config.js as plugin "bytelyst-trading".
*/
import truncateNeedsTitle from './rules/truncate-needs-title.js';
import gridNeedsMinmax from './rules/grid-needs-minmax.js';
import noButtonWithStackedChildren from './rules/no-button-with-stacked-children.js';
export default {
rules: {
'truncate-needs-title': truncateNeedsTitle,
'grid-needs-minmax': gridNeedsMinmax,
'no-button-with-stacked-children': noButtonWithStackedChildren,
},
};

View File

@ -0,0 +1,50 @@
// @ts-check
/**
* Warn when a CSS `gridTemplateColumns` value uses a bare `Nfr` track
* without `minmax(0, ...)`. Default `min-width: auto` makes grid items
* refuse to shrink below their content, causing overflow.
*
* Pattern F in docs/ui/UI_AUDIT.md.
*/
const HAS_FR_RE = /\b\d*\.?\d*fr\b/;
const HAS_MINMAX_RE = /minmax\s*\(/;
function checkValue(context, node, raw) {
if (typeof raw !== 'string' || !HAS_FR_RE.test(raw)) return;
if (!HAS_MINMAX_RE.test(raw)) {
context.report({ node, messageId: 'bare', data: { value: raw } });
return;
}
// Has minmax somewhere — check whether *every* fr is wrapped.
const stripped = raw.replace(/minmax\s*\([^)]*\)/g, 'MM');
if (HAS_FR_RE.test(stripped)) {
context.report({ node, messageId: 'bare', data: { value: raw } });
}
}
export default {
meta: {
type: 'suggestion',
docs: { description: 'grid 1fr tracks should be wrapped in minmax(0, ...) to allow shrinking' },
schema: [],
messages: {
bare: 'gridTemplateColumns "{{ value }}" has a bare Nfr track. Wrap each in minmax(0, Nfr) so cell content can shrink below min-content.',
},
},
create(context) {
function visitProperty(node) {
if (!node.key) return;
const keyName = node.key.name || (node.key.type === 'Literal' && node.key.value);
if (keyName !== 'gridTemplateColumns') return;
const v = node.value;
if (!v) return;
if (v.type === 'Literal' && typeof v.value === 'string') {
checkValue(context, v, v.value);
} else if (v.type === 'TemplateLiteral') {
const raw = v.quasis.map(q => q.value.cooked).join('${...}');
checkValue(context, v, raw);
}
}
return { Property: visitProperty };
},
};

View File

@ -0,0 +1,57 @@
// @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 } });
}
},
};
},
};

View File

@ -0,0 +1,47 @@
// @ts-check
/**
* Warn when a JSX element uses Tailwind's `truncate` (or `line-clamp-*` /
* `text-ellipsis`) without a paired `title=` or `aria-label=` so users have
* no way to reveal full content on hover/focus.
*
* Pattern E in docs/ui/UI_AUDIT.md.
*/
const TRUNCATE_RE = /\b(truncate|line-clamp-\d+|text-ellipsis)\b/;
function classNameValue(attr) {
if (!attr || !attr.value) return null;
if (attr.value.type === 'Literal') return String(attr.value.value || '');
if (attr.value.type === 'JSXExpressionContainer') {
const e = attr.value.expression;
if (e.type === 'TemplateLiteral') return e.quasis.map(q => q.value.cooked).join(' ');
if (e.type === 'Literal') return String(e.value || '');
}
return null;
}
export default {
meta: {
type: 'suggestion',
docs: { description: 'truncate without a title/aria-label is a UX regression' },
schema: [],
messages: {
missing: 'Element uses `truncate`/`line-clamp-*` but has no `title=` or `aria-label=`. Add one so users can reveal the full text on hover/focus.',
},
},
create(context) {
return {
JSXOpeningElement(node) {
const cls = node.attributes.find(a => a.type === 'JSXAttribute' && a.name && a.name.name === 'className');
if (!cls) return;
const val = classNameValue(cls);
if (!val || !TRUNCATE_RE.test(val)) return;
const hasAffordance = node.attributes.some(a =>
a.type === 'JSXAttribute' && a.name && (a.name.name === 'title' || a.name.name === 'aria-label')
);
if (!hasAffordance) {
context.report({ node: cls, messageId: 'missing' });
}
},
};
},
};

View File

@ -7,9 +7,13 @@ import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores } from 'eslint/config'
import bytelystTrading from './eslint-local/index.js'
export default defineConfig([globalIgnores(['dist', '.vite', 'node_modules']), { export default defineConfig([globalIgnores(['dist', '.vite', 'node_modules', 'eslint-local']), {
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
plugins: {
'bytelyst-trading': bytelystTrading,
},
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
@ -26,5 +30,9 @@ export default defineConfig([globalIgnores(['dist', '.vite', 'node_modules']), {
'react-hooks/set-state-in-effect': 'off', 'react-hooks/set-state-in-effect': 'off',
'react-hooks/exhaustive-deps': 'off', 'react-hooks/exhaustive-deps': 'off',
'react-refresh/only-export-components': 'off', 'react-refresh/only-export-components': 'off',
// UI audit guardrails — see docs/ui/UI_AUDIT.md §5
'bytelyst-trading/truncate-needs-title': 'warn',
'bytelyst-trading/grid-needs-minmax': 'warn',
'bytelyst-trading/no-button-with-stacked-children': 'warn',
}, },
}, ...storybook.configs["flat/recommended"]]) }, ...storybook.configs["flat/recommended"]])

View File

@ -267,7 +267,7 @@ export function CodeStrategyEditor({
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--primary)', marginBottom: 10 }}> <div style={{ fontSize: 13, fontWeight: 700, color: 'var(--primary)', marginBottom: 10 }}>
Backtest Results Backtest Results
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 12 }}>
{[ {[
['Total Return', fmt(result.totalReturn, '%')], ['Total Return', fmt(result.totalReturn, '%')],
['Win Rate', fmt(result.winRate, '%')], ['Win Rate', fmt(result.winRate, '%')],

View File

@ -139,7 +139,7 @@ export function ScreenerView() {
key={i} key={i}
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: '100px 1fr 90px 90px 110px 80px 110px', gridTemplateColumns: '100px minmax(0, 1fr) 90px 90px 110px 80px 110px',
padding: '12px 16px', padding: '12px 16px',
borderBottom: i < 5 ? '1px solid var(--border)' : 'none', borderBottom: i < 5 ? '1px solid var(--border)' : 'none',
alignItems: 'center', alignItems: 'center',