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:
parent
67c9ecb589
commit
a0fcb65f5d
18
web/eslint-local/index.js
Normal file
18
web/eslint-local/index.js
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
50
web/eslint-local/rules/grid-needs-minmax.js
Normal file
50
web/eslint-local/rules/grid-needs-minmax.js
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
57
web/eslint-local/rules/no-button-with-stacked-children.js
Normal file
57
web/eslint-local/rules/no-button-with-stacked-children.js
Normal 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 } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
47
web/eslint-local/rules/truncate-needs-title.js
Normal file
47
web/eslint-local/rules/truncate-needs-title.js
Normal 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' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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"]])
|
||||||
|
|||||||
@ -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, '%')],
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user