@estiva-app/ui 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,6 +19,10 @@
19
19
  "types": "./dist/index.d.ts",
20
20
  "default": "./dist/index.js"
21
21
  },
22
+ "./eslint": {
23
+ "types": "./dist/eslint/index.d.ts",
24
+ "default": "./dist/eslint/index.js"
25
+ },
22
26
  "./tailwind-preset": "./tailwind-preset.js",
23
27
  "./tokens.css": "./tokens.css",
24
28
  "./package.json": "./package.json",
package/src/Avatar.tsx CHANGED
@@ -118,7 +118,7 @@ export function Avatar({ src, name, alt = '', size = 36, label: spoken, classNam
118
118
  */
119
119
  className="w-full h-full flex items-center justify-center font-semibold leading-none"
120
120
  style={{
121
- /* eslint-disable no-restricted-syntax -- the per-person palette (the note at
121
+ /* eslint-disable no-restricted-syntax -- @estiva-escape: the per-person palette (the note at
122
122
  the top): eight hues picked from the name, the one ink that reads on all
123
123
  of them, and a size that follows `size`. None of it can be a token. */
124
124
  // The one ink colour that reads on all eight hues, which are a
@@ -55,7 +55,7 @@ export function AvatarGroup({ members, size = 24 }: AvatarGroupProps) {
55
55
  <span
56
56
  key={i}
57
57
  className="relative flex rounded-sm"
58
- /* eslint-disable-next-line no-restricted-syntax -- the ring's width is the `ring` prop, so it cannot be a class; its colour is the surface token */
58
+ /* eslint-disable-next-line no-restricted-syntax -- @estiva-escape: the ring's width is the `ring` prop, so it cannot be a class; its colour is the surface token */
59
59
  style={{ marginRight: -overlap, boxShadow: `0 0 0 ${ring}px var(--bg-surface)` }}
60
60
  >
61
61
  {/* Each face says whose it is: a stack stands on its own, with no
package/src/ChipInput.mdx CHANGED
@@ -65,6 +65,15 @@ import { ChipInput } from '@estiva-app/ui'
65
65
  <ChipInput aria-labelledby="to-label" value={chosen} onChange={setChosen} options={directory} />
66
66
  ```
67
67
 
68
+ - **`InputChip` on its own** names its ✕ `Remove <label>`. Where the ✕ does
69
+ something else, say what with `removeLabel` (a scope chip: "Leave Ship").
70
+ - To cap a long label, give the chip a `max-w-*` and `truncate`: the label is
71
+ cut and the ✕ keeps its size. `truncate` is off unless asked, because
72
+ cutting clips a letter's soft edge by up to 4px at 1x even when the label
73
+ fits.
74
+
75
+ <Canvas of={ChipInputStories.ALongLabelCut} />
76
+
68
77
  ## Keys
69
78
 
70
79
  | Input | What happens |
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { useState } from 'react'
3
+ import { IconSquareRounded } from '@tabler/icons-react'
3
4
  import { Avatar } from './Avatar'
4
5
  import { ChipInput, InputChip, type ChipInputOption } from './ChipInput'
5
6
 
@@ -97,3 +98,20 @@ export const TheChipItself: Story = {
97
98
  </div>
98
99
  ),
99
100
  }
101
+
102
+ /** A chip capped in width, its long label cut, and a ✕ named for what it does. */
103
+ export const ALongLabelCut: Story = {
104
+ parameters: { controls: { disable: true } },
105
+ render: () => (
106
+ <div className="flex items-center gap-2">
107
+ <InputChip
108
+ label="A label much longer than the chip may be"
109
+ leading={<IconSquareRounded size={16} stroke={1.5} className="text-text-secondary" />}
110
+ onRemove={() => {}}
111
+ removeLabel="Leave the label"
112
+ truncate
113
+ className="max-w-44"
114
+ />
115
+ </div>
116
+ ),
117
+ }
@@ -215,4 +215,22 @@ describe('InputChip', () => {
215
215
  render(<InputChip label="Label" />)
216
216
  expect(screen.queryByRole('button')).toBeNull()
217
217
  })
218
+
219
+ it('names its ✕ with removeLabel when given', async () => {
220
+ const user = userEvent.setup()
221
+ const onRemove = vi.fn()
222
+ render(<InputChip label="Label" onRemove={onRemove} removeLabel="Leave Label" />)
223
+ await user.click(screen.getByRole('button', { name: 'Leave Label' }))
224
+ expect(onRemove).toHaveBeenCalledTimes(1)
225
+ expect(screen.queryByRole('button', { name: 'Remove Label' })).toBeNull()
226
+ })
227
+
228
+ it('cuts its label only when asked, and then the ✕ never gives way', () => {
229
+ const { rerender } = render(<InputChip label="Label" onRemove={() => {}} />)
230
+ expect(screen.getByText('Label').className).not.toMatch(/\btruncate\b/)
231
+ expect(screen.getByRole('button').className).not.toMatch(/\bshrink-0\b/)
232
+ rerender(<InputChip label="Label" onRemove={() => {}} truncate className="max-w-[160px]" />)
233
+ expect(screen.getByText('Label').className).toMatch(/\bmin-w-0\b.*\btruncate\b/)
234
+ expect(screen.getByRole('button').className).toMatch(/\bshrink-0\b/)
235
+ })
218
236
  })
package/src/ChipInput.tsx CHANGED
@@ -38,14 +38,27 @@ export interface InputChipProps {
38
38
  leading?: ReactNode
39
39
  /** Draws the ✕; absent, the chip is display-only. */
40
40
  onRemove?: () => void
41
+ /**
42
+ * The ✕'s name for a screen reader. `Remove <label>` when not given. A chip
43
+ * whose ✕ does something other than remove it names what it does — a
44
+ * launcher's scope chip leaves the scope ("Leave Ship").
45
+ */
46
+ removeLabel?: string
47
+ /**
48
+ * Cut a long label with an ellipsis once the chip is capped, by a
49
+ * `max-w-*` on `className`. Off unless asked: cutting clips up to 4px of a
50
+ * letter's soft edge at 1x even when the label fits (measured 2026-09-15),
51
+ * so a chip that is never capped keeps every pixel.
52
+ */
53
+ truncate?: boolean
41
54
  className?: string
42
55
  }
43
56
 
44
- export function InputChip({ label, leading, onRemove, className }: InputChipProps) {
57
+ export function InputChip({ label, leading, onRemove, removeLabel, truncate, className }: InputChipProps) {
45
58
  return (
46
59
  <div className={cn(CHIP_BOX, chipPadding(!!leading, !!onRemove), className)}>
47
60
  {leading && <span className="flex shrink-0 items-center">{leading}</span>}
48
- <span className={CHIP_LABEL}>{label}</span>
61
+ <span className={cn(CHIP_LABEL, truncate && 'min-w-0 truncate')}>{label}</span>
49
62
  {/* Base UI's `Button`, as every button in the package is (D6). Base UI
50
63
  has no chip of its own — its only chips are `Combobox.Chip` and
51
64
  `ChipRemove`, which throw outside a combobox — so the ✕ is the one
@@ -57,8 +70,9 @@ export function InputChip({ label, leading, onRemove, className }: InputChipProp
57
70
  e.stopPropagation()
58
71
  onRemove()
59
72
  }}
60
- className={CHIP_REMOVE}
61
- aria-label={`Remove ${label}`}
73
+ // In a capped chip the label gives way, never the ✕.
74
+ className={cn(CHIP_REMOVE, truncate && 'shrink-0')}
75
+ aria-label={removeLabel ?? `Remove ${label}`}
62
76
  >
63
77
  <IconX size={10} stroke={1.5} />
64
78
  </BaseButton>
@@ -32,6 +32,23 @@ function Controlled({ initial = 'todo', ...rest }: { initial?: string } & Partia
32
32
  return <Select value={value} onChange={setValue} options={STATUSES} ariaLabel="Status" {...rest} />
33
33
  }
34
34
 
35
+ /**
36
+ * The open list, once it has focus.
37
+ *
38
+ * The list is findable as soon as it opens, but Base UI moves focus into it an
39
+ * animation frame later. A key pressed before that frame goes to the trigger:
40
+ * Tab then moves focus into the list instead of closing it, and an arrow or End
41
+ * is lost. A person presses keys at a list that has focus, so these tests wait
42
+ * for it. CI failed "Tab closes the list" this way on 15 September (PR #36);
43
+ * with every frame 100ms late the three tests that use this failed 3 runs of 3,
44
+ * focus measured still on the trigger when Tab was pressed.
45
+ */
46
+ async function focusedList() {
47
+ const list = await screen.findByRole('listbox')
48
+ await waitFor(() => expect(list.contains(document.activeElement)).toBe(true))
49
+ return list
50
+ }
51
+
35
52
  describe('Select', () => {
36
53
  it('is a button naming itself, showing the chosen label', () => {
37
54
  render(<Controlled initial="in_progress" />)
@@ -65,7 +82,7 @@ describe('Select', () => {
65
82
  const trigger = screen.getByRole('combobox', { name: 'Status' })
66
83
  trigger.focus()
67
84
  await user.keyboard('{ArrowDown}')
68
- expect(await screen.findByRole('listbox')).toBeTruthy()
85
+ expect(await focusedList()).toBeTruthy()
69
86
  await user.keyboard('{ArrowDown}{Enter}')
70
87
  expect(onChange).toHaveBeenCalledWith('in_progress')
71
88
  expect(document.activeElement).toBe(trigger)
@@ -130,7 +147,7 @@ describe('Select', () => {
130
147
  render(<Controlled initial="in_progress" />)
131
148
  screen.getByRole('combobox', { name: 'Status' }).focus()
132
149
  await user.keyboard('{ArrowDown}')
133
- expect(await screen.findByRole('listbox')).toBeTruthy()
150
+ expect(await focusedList()).toBeTruthy()
134
151
  const highlighted = () => screen.getAllByRole('option').find((o) => o.getAttribute('data-highlighted') !== null)?.textContent
135
152
  await user.keyboard('{End}')
136
153
  expect(highlighted()).toContain('Done')
@@ -142,7 +159,7 @@ describe('Select', () => {
142
159
  const user = userEvent.setup()
143
160
  render(<Controlled />)
144
161
  await user.click(screen.getByRole('combobox', { name: 'Status' }))
145
- expect(await screen.findByRole('listbox')).toBeTruthy()
162
+ expect(await focusedList()).toBeTruthy()
146
163
  await user.tab()
147
164
  await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull())
148
165
  })
@@ -0,0 +1,112 @@
1
+ import type { AST, Rule } from 'eslint'
2
+
3
+ /**
4
+ * The escape marker (UIG-3, seam S4 in docs/GATES.md §16): one written reason
5
+ * why one element stays as it is.
6
+ *
7
+ * A line comment directly above the element,
8
+ *
9
+ * // @estiva-escape: <reason>
10
+ *
11
+ * or, where the element is a JSX child, the JSX comment on the line above,
12
+ *
13
+ * {/* @estiva-escape: <reason> *\/}
14
+ *
15
+ * with at least ten characters of reason, spaces not counted.
16
+ *
17
+ * Not `eslint-disable`. A directive switches a rule off without saying it was
18
+ * meant, and the count of escapes (`.gates-count.json`) could not see it. A
19
+ * marker written inside a directive that switches one of these rules off is
20
+ * reported for that reason.
21
+ *
22
+ * The token lint's older notes (`eslint-disable-next-line <rule> -- @estiva-escape:
23
+ * <reason>`, Katerina's ruling A2 of 15 September) are not read here: they
24
+ * escape the token lint's rules, which are not this plugin's, and they stay as
25
+ * they are (Katerina, 15 September).
26
+ */
27
+ export const ESCAPE_MARKER = '@estiva-escape'
28
+
29
+ /** Characters of reason, spaces not counted. */
30
+ export const MIN_REASON = 10
31
+
32
+ /** The key under ESLint's `settings` this plugin reads. */
33
+ export const SETTINGS_KEY = 'estiva'
34
+
35
+ /**
36
+ * Every rule spreads these into its `meta.messages`, because `isEscaped`
37
+ * reports through the rule that called it.
38
+ */
39
+ export const ESCAPE_MESSAGES = {
40
+ escapeWithoutReason: `An escape needs its reason, at least ${MIN_REASON} characters: \`// ${ESCAPE_MARKER}: <why this stays>\`. An escape with no reason hides nothing.`,
41
+ escapeInDirective: `Write the escape as its own comment on the line above: \`// ${ESCAPE_MARKER}: <reason>\`. Inside an eslint-disable comment it switches the rule off instead of recording why.`,
42
+ escaped: 'Escaped: {{reason}}',
43
+ } as const
44
+
45
+ export interface EstivaSettings {
46
+ /**
47
+ * Report every sanctioned escape as a message with the id `escaped`, so a
48
+ * count can read them. Off in the lint anyone runs; on only in the count.
49
+ */
50
+ reportEscapes?: boolean
51
+ }
52
+
53
+ interface Located {
54
+ loc?: AST.SourceLocation | null
55
+ range?: [number, number]
56
+ }
57
+
58
+ const DIRECTIVE = /^\s*eslint-disable(?:-next-line|-line)?(?=\s|$)([^]*)$/
59
+
60
+ /** The rules a directive names: what comes before its ` -- ` description. None means every rule. */
61
+ function directiveRules(rest: string): string[] {
62
+ const dashes = rest.search(/(?:^|\s)--(?:\s|$)/)
63
+ return (dashes === -1 ? rest : rest.slice(0, dashes)).split(/[\s,]+/).filter(Boolean)
64
+ }
65
+
66
+ /** Only spaces and a JSX expression's closing brace between the marker and the element. */
67
+ const BETWEEN = /^[\s}]*$/
68
+
69
+ /**
70
+ * Whether the element at `node` carries a valid escape on the line above.
71
+ *
72
+ * Every rule of this plugin calls it before it reports. It reports, through
73
+ * that rule, a marker with too short a reason and a marker inside an
74
+ * eslint-disable directive for this rule; either way the element is not
75
+ * escaped, so the rule reports it too.
76
+ */
77
+ export function isEscaped(context: Rule.RuleContext, node: Located): boolean {
78
+ const { sourceCode } = context
79
+ if (!node.loc || !node.range) return false
80
+ const line = node.loc.start.line
81
+ const nodeStart = node.range[0]
82
+
83
+ const comment = sourceCode
84
+ .getAllComments()
85
+ .find((c) => c.loc && c.range && c.loc.end.line === line - 1 && BETWEEN.test(sourceCode.text.slice(c.range[1], nodeStart)))
86
+ if (!comment?.loc) return false
87
+
88
+ const at = comment.value.indexOf(ESCAPE_MARKER)
89
+ if (at === -1) return false
90
+
91
+ const directive = DIRECTIVE.exec(comment.value)
92
+ if (directive) {
93
+ const rules = directiveRules(directive[1])
94
+ // A directive for other rules (the token lint's notes) is not an escape of this one.
95
+ if (rules.length > 0 && !rules.includes(context.id)) return false
96
+ context.report({ loc: comment.loc, messageId: 'escapeInDirective' })
97
+ return false
98
+ }
99
+
100
+ const reason = comment.value
101
+ .slice(at + ESCAPE_MARKER.length)
102
+ .replace(/^:/, '')
103
+ .trim()
104
+ if (reason.replace(/\s/g, '').length < MIN_REASON) {
105
+ context.report({ loc: comment.loc, messageId: 'escapeWithoutReason' })
106
+ return false
107
+ }
108
+
109
+ const settings = context.settings[SETTINGS_KEY] as EstivaSettings | undefined
110
+ if (settings?.reportEscapes) context.report({ loc: comment.loc, messageId: 'escaped', data: { reason } })
111
+ return true
112
+ }
@@ -0,0 +1,82 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { ESLint, type Linter } from 'eslint'
3
+ import { parser } from 'typescript-eslint'
4
+ import { describe, expect, it } from 'vitest'
5
+ import estiva, { countGates, PLUGIN_KEY } from './index'
6
+
7
+ /**
8
+ * The plugin as an app uses it: a flat config with `configs.recommended`,
9
+ * linting text the way Peek's editor hook does (`lintText`). The rule's own
10
+ * cases are in no-raw-button.test.ts.
11
+ */
12
+ const tsx: Linter.Config = {
13
+ files: ['**/*.tsx'],
14
+ languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
15
+ }
16
+
17
+ async function lint(code: string, extra: Linter.Config[] = []) {
18
+ const eslint = new ESLint({
19
+ cwd: process.cwd(),
20
+ overrideConfigFile: true,
21
+ overrideConfig: [tsx, estiva.configs.recommended, ...extra],
22
+ })
23
+ return eslint.lintText(code, { filePath: 'src/Probe.tsx' })
24
+ }
25
+
26
+ const component = (body: string) => `export function Probe() {\n return (\n${body}\n )\n}\n`
27
+ const countMode: Linter.Config = { settings: { estiva: { reportEscapes: true } } }
28
+
29
+ describe('the plugin object', () => {
30
+ it('names itself and carries the package version', () => {
31
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
32
+ expect(estiva.meta).toEqual({ name: '@estiva-app/ui/eslint', version: pkg.version })
33
+ })
34
+
35
+ it('has no-raw-button, and both configs switch it on as an error under estiva/', () => {
36
+ expect(Object.keys(estiva.rules)).toEqual(['no-raw-button'])
37
+ for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
38
+ expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
39
+ expect(config.rules).toEqual({ 'estiva/no-raw-button': 'error' })
40
+ }
41
+ })
42
+ })
43
+
44
+ describe('an app lint with configs.recommended', () => {
45
+ it('reports a raw <button>, naming Button', async () => {
46
+ const [result] = await lint(component(' <button type="button">x</button>'))
47
+ expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
48
+ ['estiva/no-raw-button', 2, 'Use `Button` from @estiva-app/ui instead of a raw <button>.'],
49
+ ])
50
+ })
51
+
52
+ it('passes the same element under an escape', async () => {
53
+ const [result] = await lint(component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'))
54
+ expect(result.messages).toEqual([])
55
+ })
56
+ })
57
+
58
+ describe('countGates', () => {
59
+ it('counts an error, and an escape only when the lint reports escapes', async () => {
60
+ const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
61
+ expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-button': { errors: 1, warnings: 0, escapes: 0 } })
62
+ expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-button': { errors: 1, warnings: 0, escapes: 1 } })
63
+ })
64
+
65
+ it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
66
+ const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-button\n <button>x</button>'), [countMode])
67
+ const count = countGates(results)
68
+ expect(count.rules['estiva/no-raw-button']).toEqual({ errors: 0, warnings: 0, escapes: 0 })
69
+ expect(count.disabled).toEqual([{ filePath: results[0].filePath, line: 4, ruleId: 'estiva/no-raw-button' }])
70
+ })
71
+
72
+ it('counts a marker inside that directive as an error of the rule', async () => {
73
+ const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-button -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'), [countMode])
74
+ const count = countGates(results)
75
+ expect(count.rules['estiva/no-raw-button']).toEqual({ errors: 1, warnings: 0, escapes: 0 })
76
+ expect(count.disabled).toHaveLength(1)
77
+ })
78
+
79
+ it('lists every rule of the plugin, even with nothing found', () => {
80
+ expect(countGates([])).toEqual({ rules: { 'estiva/no-raw-button': { errors: 0, warnings: 0, escapes: 0 } }, disabled: [] })
81
+ })
82
+ })
@@ -0,0 +1,100 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * `@estiva-app/ui/eslint` — the UI Guardrails' lint rules, as a plugin (UIG-3,
4
+ * seam S1 in docs/GATES.md §16).
5
+ *
6
+ * A plugin rather than config, so every app — Peek, Ship, Leaf — gets a new
7
+ * rule through an ordinary version bump, and an app's editor hook runs the
8
+ * very rule code its CI runs.
9
+ *
10
+ * import estiva from '@estiva-app/ui/eslint'
11
+ * export default [{ files: ['src/**\/*.tsx'], ...estiva.configs.recommended }]
12
+ *
13
+ * Register it under `estiva` (the configs do): the rule ids are
14
+ * `estiva/<rule>`, and `countGates` counts those ids.
15
+ *
16
+ * Built on its own by build.mjs, for Node, into `dist/eslint/`; nothing here
17
+ * reaches the components' browser bundle.
18
+ */
19
+ import { createRequire } from 'node:module'
20
+ import type { ESLint, Linter } from 'eslint'
21
+ import { noRawButton } from './no-raw-button'
22
+
23
+ export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'
24
+
25
+ const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
26
+
27
+ /** The name the configs register the plugin under, so every rule id is `estiva/<rule>`. */
28
+ export const PLUGIN_KEY = 'estiva'
29
+
30
+ const rules = {
31
+ 'no-raw-button': noRawButton,
32
+ }
33
+
34
+ const plugin = {
35
+ meta: { name: '@estiva-app/ui/eslint', version },
36
+ rules,
37
+ configs: {} as { recommended: Linter.Config; strict: Linter.Config },
38
+ } satisfies ESLint.Plugin
39
+
40
+ const ruleIds = Object.keys(rules).map((name) => `${PLUGIN_KEY}/${name}`)
41
+
42
+ /**
43
+ * `recommended` switches every rule on at the level it was ruled at: an error
44
+ * blocks, a warning is reported and never blocks. `strict` makes every rule an
45
+ * error. With one rule, an error, the two are the same today; they part when
46
+ * the first warning-level rule arrives (UIG-25).
47
+ */
48
+ plugin.configs.recommended = {
49
+ name: '@estiva-app/ui/recommended',
50
+ plugins: { [PLUGIN_KEY]: plugin },
51
+ rules: { [`${PLUGIN_KEY}/no-raw-button`]: 'error' },
52
+ }
53
+ plugin.configs.strict = {
54
+ name: '@estiva-app/ui/strict',
55
+ plugins: { [PLUGIN_KEY]: plugin },
56
+ rules: Object.fromEntries(ruleIds.map((id) => [id, 'error'])),
57
+ }
58
+
59
+ export default plugin
60
+
61
+ export interface GateRuleCount {
62
+ errors: number
63
+ warnings: number
64
+ escapes: number
65
+ }
66
+
67
+ export interface GateCount {
68
+ /** Per rule id, including rules with nothing to report. */
69
+ rules: Record<string, GateRuleCount>
70
+ /**
71
+ * Reports of these rules that an `eslint-disable` directive silenced. Not
72
+ * an escape: a gate fails on any of these (docs/GATES.md §16, S4).
73
+ */
74
+ disabled: { filePath: string; line: number; ruleId: string }[]
75
+ }
76
+
77
+ /**
78
+ * Count what a lint of these rules found, for `.gates-count.json` (seam S3).
79
+ *
80
+ * Run the lint with `settings: { estiva: { reportEscapes: true } }` for the
81
+ * escapes to be counted; without it they are silent and count 0. A marker
82
+ * with no reason, or inside a directive, counts as an error of its rule.
83
+ */
84
+ export function countGates(results: ESLint.LintResult[]): GateCount {
85
+ const counts: Record<string, GateRuleCount> = Object.fromEntries(ruleIds.map((id) => [id, { errors: 0, warnings: 0, escapes: 0 }]))
86
+ const disabled: GateCount['disabled'] = []
87
+ for (const result of results) {
88
+ for (const message of result.messages) {
89
+ const count = message.ruleId ? counts[message.ruleId] : undefined
90
+ if (!count) continue
91
+ if (message.messageId === 'escaped') count.escapes += 1
92
+ else if (message.severity === 2) count.errors += 1
93
+ else count.warnings += 1
94
+ }
95
+ for (const message of result.suppressedMessages ?? []) {
96
+ if (message.ruleId && counts[message.ruleId]) disabled.push({ filePath: result.filePath, line: message.line, ruleId: message.ruleId })
97
+ }
98
+ }
99
+ return { rules: counts, disabled }
100
+ }
@@ -0,0 +1,118 @@
1
+ import { RuleTester } from 'eslint'
2
+ import { parser } from 'typescript-eslint'
3
+ import { describe, it } from 'vitest'
4
+ import { noRawButton } from './no-raw-button'
5
+
6
+ RuleTester.describe = describe
7
+ RuleTester.it = it
8
+ RuleTester.itOnly = it.only
9
+
10
+ const tester = new RuleTester({
11
+ languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
12
+ // A directive for a rule these cases do not load is not what they test.
13
+ linterOptions: { reportUnusedDisableDirectives: 'off' },
14
+ })
15
+
16
+ const component = (body: string) => `export function Probe() {\n return (\n${body}\n )\n}\n`
17
+
18
+ tester.run('no-raw-button', noRawButton, {
19
+ valid: [
20
+ { name: "the package's Button", code: component('<Button>Save</Button>') },
21
+ { name: 'a member expression named button', code: component('<Foo.button>Save</Foo.button>') },
22
+ { name: 'a name that starts with button', code: component('<buttonish />') },
23
+ {
24
+ name: 'a line comment escape directly above',
25
+ code: component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'),
26
+ },
27
+ {
28
+ name: 'a JSX comment escape on the line above a child',
29
+ code: component(' <div>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button type="button">x</button>\n </div>'),
30
+ },
31
+ {
32
+ name: 'a JSX comment escape over several lines',
33
+ code: component(' <div>\n {/*\n @estiva-escape: a preview drawn from its own palette\n */}\n <button type="button">x</button>\n </div>'),
34
+ },
35
+ {
36
+ name: 'an escape above an opening tag that spans lines',
37
+ code: component(' // @estiva-escape: a preview drawn from its own palette\n <button\n type="button"\n onClick={() => {}}\n >\n x\n </button>'),
38
+ },
39
+ {
40
+ name: 'exactly ten characters of reason, spaces not counted',
41
+ code: component(' // @estiva-escape: ab cd ef gh ij\n <button>x</button>'),
42
+ },
43
+ ],
44
+ invalid: [
45
+ { name: 'a raw button', code: component(' <button type="button">x</button>'), errors: [{ messageId: 'raw', line: 3 }] },
46
+ { name: 'a self-closing raw button', code: component(' <button />'), errors: [{ messageId: 'raw' }] },
47
+ {
48
+ name: 'an opening tag that spans lines (what grep misses)',
49
+ code: component(' <button\n type="button"\n >\n x\n </button>'),
50
+ errors: [{ messageId: 'raw', line: 3 }],
51
+ },
52
+ {
53
+ name: 'a raw button nested in other elements',
54
+ code: component(' <div>\n <span>\n <button>x</button>\n </span>\n </div>'),
55
+ errors: [{ messageId: 'raw', line: 5 }],
56
+ },
57
+ {
58
+ name: 'the message names the component',
59
+ code: component(' <button>x</button>'),
60
+ errors: [{ message: 'Use `Button` from @estiva-app/ui instead of a raw <button>.' }],
61
+ },
62
+ {
63
+ name: 'an escape with no reason is an error, and hides nothing',
64
+ code: component(' // @estiva-escape:\n <button>x</button>'),
65
+ errors: [
66
+ { messageId: 'escapeWithoutReason', line: 3 },
67
+ { messageId: 'raw', line: 4 },
68
+ ],
69
+ },
70
+ {
71
+ name: 'nine characters of reason is too short',
72
+ code: component(' <div>\n {/* @estiva-escape: ab cd ef gh i */}\n <button>x</button>\n </div>'),
73
+ errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'raw' }],
74
+ },
75
+ {
76
+ name: 'a marker with no colon and no reason',
77
+ code: component(' // @estiva-escape\n <button>x</button>'),
78
+ errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'raw' }],
79
+ },
80
+ {
81
+ name: 'an escape two lines above does not reach the element',
82
+ code: component(' // @estiva-escape: a preview drawn from its own palette\n\n <button>x</button>'),
83
+ errors: [{ messageId: 'raw', line: 5 }],
84
+ },
85
+ {
86
+ name: 'an escape above a sibling does not reach the next element',
87
+ code: component(' <div>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <span />\n <button>x</button>\n </div>'),
88
+ errors: [{ messageId: 'raw', line: 6 }],
89
+ },
90
+ {
91
+ name: 'a free comment is not an escape',
92
+ code: component(' // a raw button, on purpose\n <button>x</button>'),
93
+ errors: [{ messageId: 'raw' }],
94
+ },
95
+ {
96
+ name: 'a marker inside a directive that switches every rule off is refused',
97
+ code: component(' // eslint-disable-next-line -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
98
+ // The directive silences the element's own report; the refusal sits on the directive's line.
99
+ errors: [{ messageId: 'escapeInDirective', line: 3 }],
100
+ },
101
+ {
102
+ name: 'a marker inside a directive that names this rule is refused',
103
+ code: component(' // eslint-disable-next-line rule-to-test/no-raw-button -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
104
+ errors: [{ messageId: 'escapeInDirective', line: 3 }],
105
+ },
106
+ {
107
+ name: "the token lint's note for another rule is not an escape of this one",
108
+ code: component(' // eslint-disable-next-line no-console -- @estiva-escape: its hand-written type waits for that\n <button>x</button>'),
109
+ errors: [{ messageId: 'raw', line: 4 }],
110
+ },
111
+ {
112
+ name: 'with reportEscapes on, a sanctioned escape is reported for the count',
113
+ code: component(' // @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
114
+ settings: { estiva: { reportEscapes: true } },
115
+ errors: [{ messageId: 'escaped', data: { reason: 'a preview drawn from its own palette' }, line: 3 }],
116
+ },
117
+ ],
118
+ })
@@ -0,0 +1,39 @@
1
+ import type { Rule } from 'eslint'
2
+ import { ESCAPE_MESSAGES, isEscaped } from './escape'
3
+
4
+ interface JSXOpeningElement {
5
+ name: { type: string; name?: string }
6
+ loc: NonNullable<Rule.Node['loc']>
7
+ range: [number, number]
8
+ }
9
+
10
+ /**
11
+ * A raw `<button>` in an app (UIG-3, the tracer). The package's `Button`,
12
+ * `IconButton`, `MenuItem` and chips are buttons already; an app that writes
13
+ * its own has a look and a behaviour nobody else keeps in step with.
14
+ *
15
+ * Only the JSX element named `button`. `<Button>`, `<Foo.button>` and
16
+ * `createElement('button')` are not this rule's business.
17
+ */
18
+ export const noRawButton: Rule.RuleModule = {
19
+ meta: {
20
+ type: 'problem',
21
+ docs: { description: 'A raw <button> where @estiva-app/ui has one' },
22
+ schema: [],
23
+ messages: {
24
+ raw: 'Use `Button` from @estiva-app/ui instead of a raw <button>.',
25
+ ...ESCAPE_MESSAGES,
26
+ },
27
+ },
28
+ create(context) {
29
+ return {
30
+ // ESLint's types know ESTree's nodes, not JSX's; the parser hands this one over.
31
+ JSXOpeningElement(node: Rule.Node) {
32
+ const element = node as unknown as JSXOpeningElement
33
+ if (element.name.type !== 'JSXIdentifier' || element.name.name !== 'button') return
34
+ if (isEscaped(context, element)) return
35
+ context.report({ loc: element.loc, messageId: 'raw' })
36
+ },
37
+ }
38
+ },
39
+ }
@@ -172,7 +172,7 @@ function Users({ names }: { names: string[] }) {
172
172
  function SwatchBox({ token }: { token: Token }) {
173
173
  const v = `var(${token.cssVar})`
174
174
  const base = 'h-6 w-10 shrink-0 rounded-md'
175
- /* eslint-disable no-restricted-syntax -- this page draws every token from its CSS
175
+ /* eslint-disable no-restricted-syntax -- @estiva-escape: this page draws every token from its CSS
176
176
  variable, so a swatch shows the value the theme holds, including a token no
177
177
  class spells yet. */
178
178
  switch (token.swatch) {