eslint-rules.mjs

Design convention lint rules

Tier 2designengineeringBlocks the commit

Catch hardcoded colours, font sizes, and interface strings. Flag spacing that does not adapt to right-to-left layouts.

Add to your project
Add "Design convention lint rules" from ntent to this repo.

Fetch https://ntent.app/r/f/eslint-rules as plain text. Write it verbatim to scripts/eslint-rules.mjs. Then import it from eslint.config.mjs.

It needs ESLint flat config. If this repo does not have it, stop and tell me.

Then check it: Write text-[13px] in a component. pnpm lint fails and points at it.

Reads https://ntent.app/r/f/eslint-rules

Read the code
eslint-rules.mjsscripts/eslint-rules.mjs · import it from eslint.config.mjs
177 lines
/**
 * Design-system and copy guardrails as ESLint errors.
 *
 * These are the rules from the constitution that a machine can check. Import
 * this from your eslint.config.mjs:
 *
 *   import { designRules, copyRules, boundaryRules } from './scripts/eslint-rules.mjs'
 *
 *   export default [
 *     ...tseslint.configs.recommended,
 *     { files: ['**\/*.{ts,tsx}'],
 *       rules: { 'no-restricted-syntax': ['error', ...designRules, ...copyRules],
 *                ...boundaryRules } },
 *     { files: ['**\/*.stories.tsx', '**\/kitchen-sink/**'],
 *       rules: { 'no-restricted-syntax': ['error', ...designRules] } },   // see below
 *   ]
 *
 * THE STRUCTURE MATTERS MORE THAN THE RULES.
 *
 * The rules are split into named groups rather than one flat list, because
 * exemptions differ. A component catalogue legitimately hardcodes specimen
 * strings ("you@company.com") while still being held to every token rule. The
 * second config block above re-declares no-restricted-syntax WITHOUT the copy
 * half, rather than switching the rule off. A hex in the catalogue still fails.
 *
 * The general move: when a rule is right in general and wrong in one place,
 * narrow it by re-declaring a subset. Never disable it wholesale, and never
 * reach for an inline eslint-disable, which is invisible three weeks later.
 *
 * SEPARATE CORRECTNESS FROM TASTE. rtlRules below are correctness: a component
 * using pl-4 has not told you whether it works in Arabic. designRules are taste:
 * a hardcoded hex is ugly and unmaintainable but it renders. A sandbox or an
 * experiment may relax taste. Nothing relaxes correctness.
 *
 * EVERY RULE HERE HAS A PROBE. See scripts/probes/. If you add a rule, add a
 * probe line that must fail and one that must pass, then run the probe. A
 * selector that silently matches nothing reads exactly like a passing rule.
 */

// ------------------------------------------------------- CORRECTNESS -------
// These never relax, in any override, anywhere.

export const rtlRules = [
  {
    // Physical direction does not mirror in a right-to-left language. Using the
    // logical property costs nothing today and is the entire difference between
    // "we support Arabic" being a week and being a quarter.
    selector: "Literal[value=/(?:^|\\s)-?(?:p|m)(?:l|r)-/]",
    message:
      'No physical directional spacing. Use ps-/pe-/ms-/me- so the layout mirrors in RTL. ' +
      'This is a correctness rule, not a style preference: pl-4 is wrong in Arabic.',
  },
  {
    selector: "Literal[value=/(?:^|\\s)(?:left|right)-[0-9]/]",
    message: 'No physical left-/right- insets. Use start-/end-.',
  },
  {
    selector: "Literal[value=/(?:^|\\s)text-(?:left|right)(?:\\s|$)/]",
    message: "No physical text alignment. Use text-start / text-end.",
  },
  {
    selector: "Literal[value=/(?:^|\\s)(?:border-l|border-r)(?:-|\\s|$)/]",
    message: 'No physical border sides. Use border-s / border-e.',
  },
]

// ------------------------------------------------------------- TASTE -------
// A hardcoded value that a token already names.

export const tokenRules = [
  {
    // Arbitrary hex inside a Tailwind class. Colour is authored in one place,
    // your globals.css token block, and nowhere else.
    selector: "Literal[value=/\\[#[0-9a-fA-F]{3,8}\\]/]",
    message:
      'No arbitrary hex colour. Use a semantic token (bg-card, text-muted-foreground). ' +
      'If the colour you need does not exist, add a token to globals.css rather than a one-off.',
  },
  {
    // The same thing in a style object or a CSS-in-JS value.
    selector: "Property[key.name=/^(color|background|backgroundColor|borderColor|fill|stroke)$/] > Literal[value=/^#[0-9a-fA-F]{3,8}$/]",
    message: 'No hex colour literal. Use a token from the theme.',
  },
  {
    // Arbitrary type sizes bypass the type scale, which is what keeps the
    // hierarchy legible and what dynamic-type accessibility settings scale.
    selector: "Literal[value=/(?:^|\\s)text-\\[[0-9.]+(?:px|rem|em)\\]/]",
    message: 'No arbitrary font size. Use the type scale (text-sm, text-base, text-lg).',
  },
  {
    // The descendant form, not [value.type='Literal'], on purpose. The narrow
    // version catches fontSize: 12 and sails straight past
    // fontSize: isCompact ? 11 : 13, which is exactly how a raw size survives an
    // audit. Any numeric literal anywhere inside the value is a raw size,
    // however it is wrapped. theme.type.sm reads as a MemberExpression and passes.
    selector: "Property[key.name=/^(fontSize|lineHeight)$/] Literal[raw=/^[0-9.]+$/]",
    message:
      'No raw fontSize or lineHeight, including inside a ternary. ' +
      'Use the type scale. Chrome that must NOT scale with dynamic type gets its own named token.',
  },
  {
    // raw, not value. esquery regex tests do not apply to NUMERIC attribute
    // values, so [value=/^[0-9]+$/] silently matches nothing on a number and
    // reads as a working rule. This one cost the Synacor repo a real audit.
    selector:
      "Property[key.name=/^(padding|paddingTop|paddingBottom|margin|marginTop|marginBottom|gap|rowGap|columnGap)$/] > Literal[raw=/^(4|8|12|16|20|24|32|40)$/]",
    message:
      'This spacing value has an exact token. Use it. ' +
      'Off-scale optical nudges (1, 2, 3, 5, 6) are legitimate and are deliberately not flagged, ' +
      'so the rule stays worth obeying.',
  },
]

export const designRules = [...tokenRules, ...rtlRules]

// -------------------------------------------------------------- COPY -------
// Split out because the component catalogue is exempt from these and from
// nothing else.

export const copyRules = [
  {
    // Accessibility text is user-visible text. It is the half everyone forgets,
    // because it never appears in a screenshot review: a hardcoded aria-label
    // sails past design review, past QA, and reaches a screen reader user in
    // English on an Arabic build.
    //
    // Scoped to the four unambiguous props. title and label are too overloaded
    // (chart configs, demo specs, option objects) to error on.
    selector:
      "JSXAttribute[name.name=/^(aria-label|aria-description|placeholder|alt)$/] > Literal[value=/[A-Za-z]{2}/]",
    message:
      'User-visible strings go through t(), and accessibility labels are user-visible: ' +
      'a screen reader reads them aloud. Add the key to every locale file.',
  },
  {
    // Em dashes read as machine-written and do not localise cleanly. Rephrase:
    // two sentences, or a comma, colon or parentheses.
    selector: "JSXText[value=/\\u2014/]",
    message: 'No em dash in user-facing copy. Use a period, comma, colon, or parentheses.',
  },
]

// ---------------------------------------------------------- BOUNDARIES -----

/**
 * One-way import boundaries. Spread these into a config block's `rules`.
 *
 * The pattern match here is the readable half: it catches the shapes people
 * actually write and misses ones they could contrive. For a boundary that
 * really matters, pair it with a script in pre-commit that resolves paths
 * properly. Two layers on purpose: the lint rule for fast feedback in the
 * editor, the script for the gate you actually trust.
 */
export const boundaryRules = {
  'no-restricted-imports': [
    'error',
    {
      patterns: [
        {
          group: ['**/lib/server/*', '**/lib/server/**'],
          message:
            'Server-only code must not be imported from a client component. ' +
            'It will be bundled and shipped to the browser, secrets included. ' +
            'Move the call behind a server action or a route handler.',
        },
        {
          group: ['**/experiments/*/*', '**/experiments/*/**'],
          message:
            'Nothing outside experiments/ may import from inside it. ' +
            'An experiment is allowed to break the design system precisely because none of it ' +
            'can leak. Promote what is approved into src/components, rewritten to obey the rules.',
        },
      ],
    },
  ],
}

Success check: Write text-[13px] in a component. pnpm lint fails and points at it.

Included in