learning_ai_invt_trdg/web/eslint-local/rules/truncate-needs-title.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

48 lines
1.6 KiB
JavaScript

// @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' });
}
},
};
},
};