@estiva-app/ui 0.35.2 → 0.36.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.
Files changed (123) hide show
  1. package/dist/eslint/escape.d.ts +23 -6
  2. package/dist/eslint/escape.d.ts.map +1 -1
  3. package/dist/eslint/index.js +70 -9
  4. package/dist/eslint/index.js.map +2 -2
  5. package/dist/eslint/no-raw-element.d.ts +7 -0
  6. package/dist/eslint/no-raw-element.d.ts.map +1 -1
  7. package/dist/eslint/no-restyled-part.d.ts +8 -0
  8. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  9. package/dist/gates/app-checks.d.ts.map +1 -1
  10. package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
  11. package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
  12. package/dist/gates/chunk-SYVKGOWM.js +1131 -0
  13. package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
  14. package/dist/gates/ci.d.ts +18 -0
  15. package/dist/gates/ci.d.ts.map +1 -0
  16. package/dist/gates/cli.js +20 -3
  17. package/dist/gates/cli.js.map +2 -2
  18. package/dist/gates/create-app.d.ts.map +1 -1
  19. package/dist/gates/create-app.js +1 -1
  20. package/dist/gates/escapes.d.ts +23 -0
  21. package/dist/gates/escapes.d.ts.map +1 -0
  22. package/dist/gates/gate-config.d.ts.map +1 -1
  23. package/dist/gates/hook.d.ts +2 -0
  24. package/dist/gates/hook.d.ts.map +1 -1
  25. package/dist/gates/index.d.ts +5 -1
  26. package/dist/gates/index.d.ts.map +1 -1
  27. package/dist/gates/index.js +37 -183
  28. package/dist/gates/index.js.map +4 -4
  29. package/dist/gates/name-escapes.d.ts +14 -0
  30. package/dist/gates/name-escapes.d.ts.map +1 -0
  31. package/dist/gates/status.d.ts +3 -1
  32. package/dist/gates/status.d.ts.map +1 -1
  33. package/dist/gates/token-lint.d.ts.map +1 -1
  34. package/dist/gates/token-style.d.ts +21 -0
  35. package/dist/gates/token-style.d.ts.map +1 -0
  36. package/dist/index.js +13 -13
  37. package/dist/index.js.map +1 -1
  38. package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
  39. package/dist/registry/app.d.ts.map +1 -1
  40. package/dist/registry/behaviours.d.ts +71 -0
  41. package/dist/registry/behaviours.d.ts.map +1 -0
  42. package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
  43. package/dist/registry/build.d.ts.map +1 -1
  44. package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
  45. package/dist/registry/chunk-EVJW26UR.js.map +7 -0
  46. package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
  47. package/dist/registry/chunk-SIB44CES.js.map +7 -0
  48. package/dist/registry/{chunk-5K2TRR4X.js → chunk-XK747STJ.js} +18 -3
  49. package/dist/registry/chunk-XK747STJ.js.map +7 -0
  50. package/dist/registry/cli.js +7 -6
  51. package/dist/registry/cli.js.map +2 -2
  52. package/dist/registry/contract.d.ts +13 -1
  53. package/dist/registry/contract.d.ts.map +1 -1
  54. package/dist/registry/index.d.ts +1 -1
  55. package/dist/registry/index.d.ts.map +1 -1
  56. package/dist/registry/index.js +11 -5
  57. package/package.json +4 -1
  58. package/registry.json +293 -17
  59. package/skill/estiva-ui.md +2 -1
  60. package/src/AppShell.mdx +2 -1
  61. package/src/AttachmentCard.tsx +7 -7
  62. package/src/Button.tsx +1 -1
  63. package/src/Card.tsx +2 -2
  64. package/src/ChipInput.mdx +2 -0
  65. package/src/ChipInput.tsx +1 -1
  66. package/src/CommandPalette.tsx +3 -3
  67. package/src/DialogShell.tsx +1 -1
  68. package/src/EmptyState.mdx +4 -3
  69. package/src/Lightbox.tsx +1 -1
  70. package/src/NavItem.tsx +1 -1
  71. package/src/PersonTrigger.tsx +1 -1
  72. package/src/Reaction.tsx +1 -1
  73. package/src/ScrollArea.mdx +4 -0
  74. package/src/Select.tsx +1 -1
  75. package/src/TopBar.mdx +1 -1
  76. package/src/eslint/escape.ts +43 -14
  77. package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
  78. package/src/eslint/index.test.ts +2 -2
  79. package/src/eslint/no-copied-look.test.ts +1 -1
  80. package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
  81. package/src/eslint/no-handmade-empty-state.test.ts +2 -2
  82. package/src/eslint/no-handmade-header.test.ts +2 -2
  83. package/src/eslint/no-native-title.test.ts +2 -2
  84. package/src/eslint/no-raw-element.test.ts +47 -15
  85. package/src/eslint/no-raw-element.ts +53 -3
  86. package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
  87. package/src/eslint/no-restyled-part.test.ts +21 -2
  88. package/src/eslint/no-restyled-part.ts +30 -0
  89. package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
  90. package/src/gates/app-checks.ts +4 -3
  91. package/src/gates/ci.ts +152 -0
  92. package/src/gates/cli.ts +19 -3
  93. package/src/gates/count.ts +1 -1
  94. package/src/gates/create-app.test.ts +2 -2
  95. package/src/gates/create-app.ts +22 -16
  96. package/src/gates/escapes.ts +111 -0
  97. package/src/gates/gate-config.ts +2 -0
  98. package/src/gates/gates.test.ts +86 -1
  99. package/src/gates/hook.ts +38 -4
  100. package/src/gates/index.ts +5 -1
  101. package/src/gates/name-escapes.ts +77 -0
  102. package/src/gates/status.ts +45 -2
  103. package/src/gates/token-lint.ts +11 -4
  104. package/src/gates/token-style.ts +79 -0
  105. package/src/how.test.ts +282 -0
  106. package/src/pages.test.ts +6 -0
  107. package/src/registry/app.test.ts +34 -0
  108. package/src/registry/app.ts +52 -2
  109. package/src/registry/behaviours.ts +223 -0
  110. package/src/registry/build.ts +36 -0
  111. package/src/registry/cli.ts +5 -3
  112. package/src/registry/contract.test.ts +27 -2
  113. package/src/registry/contract.ts +28 -3
  114. package/src/registry/index.ts +1 -1
  115. package/src/registry/registry.test.ts +14 -0
  116. package/dist/gates/chunk-DBMUQAR6.js +0 -607
  117. package/dist/gates/chunk-DBMUQAR6.js.map +0 -7
  118. package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
  119. package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
  120. package/dist/registry/chunk-5K2TRR4X.js.map +0 -7
  121. package/dist/registry/chunk-XVII7P6G.js.map +0 -7
  122. /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
  123. /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
@@ -123,7 +123,7 @@ export function CommandPalette({ open, onOpenChange, label, where, modKey = 'Ctr
123
123
  <Dialog.Portal>
124
124
  <Dialog.Backdrop className="fixed inset-0 z-40 bg-scrim" />
125
125
  <Dialog.Viewport className="fixed inset-0 z-50 flex items-start justify-center pt-[16vh]">
126
- {/* @estiva-escape: A3 (Katerina, 24 September: leave CommandPalette alone): the palette's window keeps its own border */}
126
+ {/* @estiva-escape(no-copied-look): A3 (Katerina, 24 September: leave CommandPalette alone): the palette's window keeps its own border */}
127
127
  <Dialog.Popup
128
128
  ref={popupRef}
129
129
  aria-label={label}
@@ -374,7 +374,7 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
374
374
  <div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
375
375
  <IconSearch size={16} stroke={1.5} className="shrink-0 text-text-secondary" />
376
376
  {chip && <LevelChip chip={chip} onBack={back} />}
377
- {/* @estiva-escape: A5 (Katerina, 24 September: leave CommandPalette alone): the palette's search field keeps its own classes */}
377
+ {/* @estiva-escape(no-copied-look): A5 (Katerina, 24 September: leave CommandPalette alone): the palette's search field keeps its own classes */}
378
378
  <Autocomplete.Input
379
379
  ref={inputRef}
380
380
  data-command-palette-field=""
@@ -555,7 +555,7 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
555
555
 
556
556
  return (
557
557
  <div ref={frameRef} tabIndex={-1} onKeyDown={onKeyDown} onFocus={measure} onBlur={measure} onInput={measure} className="flex min-h-0 flex-col outline-none">
558
- {/* @estiva-escape: the palette's own input bar, the twin of its search row; the level chip stands where the field was */}
558
+ {/* @estiva-escape(no-handmade-header): the palette's own input bar, the twin of its search row; the level chip stands where the field was */}
559
559
  <div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
560
560
  {icon != null && <span className="flex shrink-0 items-center text-text-secondary">{icon}</span>}
561
561
  <LevelChip chip={chip} onBack={back} />
@@ -86,7 +86,7 @@ export function DialogShell({ title, onClose, headerContent, footer, children, b
86
86
 
87
87
  {/* Dialog */}
88
88
  <div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
89
- {/* @estiva-escape: A3 (Katerina, 24 September: leave CommandPalette alone): a dialog's window and the palette's are left as they are */}
89
+ {/* @estiva-escape(no-copied-look): A3 (Katerina, 24 September: leave CommandPalette alone): a dialog's window and the palette's are left as they are */}
90
90
  <Parts.Popup
91
91
  ref={popupRef}
92
92
  /*
@@ -36,9 +36,10 @@ need a height from the caller (`className="h-full"`).
36
36
 
37
37
  ## When not
38
38
 
39
- - Data is still on its way → **Skeleton**; empty and loading are different
40
- truths. When the shape of what arrives is unknown, an EmptyState that says so
41
- — "Opening…" — is fine.
39
+ - Data is still on its way → **Skeleton**: **SkeletonList**, or a shape of
40
+ your own from **SkeletonBar**; empty and loading are different truths.
41
+ When the shape of what arrives is unknown, an EmptyState that says so —
42
+ "Opening…" — is fine.
42
43
  - Something went wrong → say what failed, in words. An EmptyState can carry
43
44
  it, in its usual look — never red. An empty state that hides a failure
44
45
  misleads.
package/src/Lightbox.tsx CHANGED
@@ -74,7 +74,7 @@ export function Lightbox({ src, alt, onClose, className }: LightboxProps) {
74
74
  onClick={(e) => e.stopPropagation()}
75
75
  className={cn('max-w-full max-h-full object-contain rounded-lg', className)}
76
76
  />
77
- {/* @estiva-escape: N4 (Katerina, 24 September: record only): the small round button AttachmentCard's remove also draws; a part if a third appears */}
77
+ {/* @estiva-escape(no-copied-look): N4 (Katerina, 24 September: record only): the small round button AttachmentCard's remove also draws; a part if a third appears */}
78
78
  <Dialog.Close
79
79
  aria-label="Close"
80
80
  className="absolute top-4 right-4 size-8 rounded-full bg-bg-elevated border border-border-strong flex items-center justify-center text-text-secondary hover:text-text-primary"
package/src/NavItem.tsx CHANGED
@@ -28,7 +28,7 @@ export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'
28
28
 
29
29
  export function NavItem({ label, href, count, countLabel, active = false, icon, className, ...props }: NavItemProps) {
30
30
  return (
31
- // @estiva-escape: N5 (Katerina, 24 September: record only): a row that fills on hover, as PersonTrigger's row also does
31
+ // @estiva-escape(no-copied-look): N5 (Katerina, 24 September: record only): a row that fills on hover, as PersonTrigger's row also does
32
32
  <a
33
33
  href={href}
34
34
  aria-current={active ? 'page' : undefined}
@@ -60,7 +60,7 @@ export function PersonTrigger({ name, picture, fallback, size, open = false, com
60
60
  )
61
61
  }
62
62
  return (
63
- // @estiva-escape: N5 (Katerina, 24 September: record only): a row that fills on hover, as NavItem also does
63
+ // @estiva-escape(no-copied-look): N5 (Katerina, 24 September: record only): a row that fills on hover, as NavItem also does
64
64
  <BaseButton
65
65
  type="button"
66
66
  aria-haspopup="menu"
package/src/Reaction.tsx CHANGED
@@ -63,7 +63,7 @@ export interface ReactionProps extends Omit<ButtonHTMLAttributes<HTMLButtonEleme
63
63
 
64
64
  export function Reaction({ emoji, count, pressed = false, className, ...props }: ReactionProps) {
65
65
  return (
66
- // @estiva-escape: W1 (Katerina, 24 September: leave alone): a reaction pill shares four words with a field's box by coincidence
66
+ // @estiva-escape(no-copied-look): W1 (Katerina, 24 September: leave alone): a reaction pill shares four words with a field's box by coincidence
67
67
  <Toggle
68
68
  // Controlled: a reaction is yours when the data says so, not when the
69
69
  // pill was last pressed — a write can fail, and another client can
@@ -27,6 +27,10 @@ the content is moving; nothing moves when it appears.
27
27
  - A box that never overflows. A ScrollArea around it changes nothing, and
28
28
  is one more element.
29
29
  - The page itself. The browser owns that bar.
30
+ - A whole list column → **ListColumn**, which scrolls its rows itself.
31
+
32
+ No alternative for the first two: a box that never overflows needs nothing
33
+ around it, and the page's bar is the browser's.
30
34
 
31
35
  ## How
32
36
 
package/src/Select.tsx CHANGED
@@ -74,7 +74,7 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
74
74
  // a native `disabled` button, which Tab skips and a tooltip cannot open on.
75
75
  const held = Boolean(disabledReason)
76
76
  const trigger = (
77
- // @estiva-escape: N1 (Katerina, 24 September: record only): Select's box drops the hover while held, so it cannot take FIELD_SHELL_CLASSES as it is
77
+ // @estiva-escape(no-copied-look): N1 (Katerina, 24 September: record only): Select's box drops the hover while held, so it cannot take FIELD_SHELL_CLASSES as it is
78
78
  <BaseSelect.Trigger
79
79
  aria-label={ariaLabel}
80
80
  aria-required={aria['aria-required']}
package/src/TopBar.mdx CHANGED
@@ -33,7 +33,7 @@ reachable.
33
33
  ## When not
34
34
 
35
35
  - Page-level headers inside the content → not this; this is the frame's
36
- bar, once per app.
36
+ bar, once per app. The top of a column is **ContainerHeader**.
37
37
  - Anything the bar would need to know — who you are, what is searched —
38
38
  stays outside: pass composed slots.
39
39
 
@@ -4,15 +4,21 @@ import type { AST, Rule } from 'eslint'
4
4
  * The escape marker (UIG-3, seam S4 in docs/GATES.md §16): one written reason
5
5
  * why one element stays as it is.
6
6
  *
7
- * A line comment directly above the element,
7
+ * A line comment directly above the element, naming the rule it keeps off,
8
8
  *
9
- * // @estiva-escape: <reason>
9
+ * // @estiva-escape(no-raw-element): <reason>
10
10
  *
11
11
  * or, where the element is a JSX child, the JSX comment on the line above,
12
12
  *
13
- * {/* @estiva-escape: <reason> *\/}
13
+ * {/* @estiva-escape(no-raw-element): <reason> *\/}
14
14
  *
15
- * with at least ten characters of reason, spaces not counted.
15
+ * with at least ten characters of reason, spaces not counted. It escapes only
16
+ * the rules it names (Katerina's ruling B3, 25 September: one reason used to
17
+ * switch off every rule on the element, so a raw button that also wrote a
18
+ * dialog's role by hand passed under one reason). Two rules on one element are
19
+ * named together, `@estiva-escape(no-raw-element, no-rebuilt-behaviour)`, each
20
+ * on purpose. A marker that names no rule escapes nothing, and says which name
21
+ * to write.
16
22
  *
17
23
  * Not `eslint-disable`. A directive switches a rule off without saying it was
18
24
  * meant, and the count of escapes (`.gates-count.json`) could not see it. A
@@ -37,11 +43,29 @@ export const SETTINGS_KEY = 'estiva'
37
43
  * reports through the rule that called it.
38
44
  */
39
45
  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.`,
46
+ escapeWithoutReason: `An escape needs its reason, at least ${MIN_REASON} characters: \`// ${ESCAPE_MARKER}({{rule}}): <why this stays>\`. An escape with no reason hides nothing.`,
47
+ escapeInDirective: `Write the escape as its own comment on the line above: \`// ${ESCAPE_MARKER}({{rule}}): <reason>\`. Inside an eslint-disable comment it switches the rule off instead of recording why.`,
48
+ escapeWithoutRule: `Name the rule this escape keeps off: \`${ESCAPE_MARKER}({{rule}}): <reason>\`. An escape now keeps off only the rules it names.`,
42
49
  escaped: 'Escaped: {{reason}}',
43
50
  } as const
44
51
 
52
+ /** A rule's name as a marker writes it: `no-raw-element` for `estiva/no-raw-element`. */
53
+ export const shortRuleName = (id: string) => id.slice(id.lastIndexOf('/') + 1)
54
+
55
+ /**
56
+ * What a marker says, read from a comment's text: the rules it names (none when
57
+ * it names none) and its reason. Null when the comment carries no marker.
58
+ */
59
+ export function readMarker(text: string): { rules: string[]; reason: string } | null {
60
+ const at = text.indexOf(ESCAPE_MARKER)
61
+ if (at === -1) return null
62
+ const rest = text.slice(at + ESCAPE_MARKER.length)
63
+ const named = /^\s*\(([^)]*)\)/.exec(rest)
64
+ const rules = named ? named[1].split(/[\s,]+/).filter(Boolean).map(shortRuleName) : []
65
+ const reason = (named ? rest.slice(named[0].length) : rest).replace(/^\s*:/, '').trim()
66
+ return { rules, reason }
67
+ }
68
+
45
69
  export interface EstivaSettings {
46
70
  /**
47
71
  * Report every sanctioned escape as a message with the id `escaped`, so a
@@ -85,24 +109,29 @@ export function isEscaped(context: Rule.RuleContext, node: Located): boolean {
85
109
  .find((c) => c.loc && c.range && c.loc.end.line === line - 1 && BETWEEN.test(sourceCode.text.slice(c.range[1], nodeStart)))
86
110
  if (!comment?.loc) return false
87
111
 
88
- const at = comment.value.indexOf(ESCAPE_MARKER)
89
- if (at === -1) return false
112
+ const marker = readMarker(comment.value)
113
+ if (!marker) return false
114
+ const rule = shortRuleName(context.id)
90
115
 
91
116
  const directive = DIRECTIVE.exec(comment.value)
92
117
  if (directive) {
93
118
  const rules = directiveRules(directive[1])
94
119
  // A directive for other rules (the token lint's notes) is not an escape of this one.
95
120
  if (rules.length > 0 && !rules.includes(context.id)) return false
96
- context.report({ loc: comment.loc, messageId: 'escapeInDirective' })
121
+ context.report({ loc: comment.loc, messageId: 'escapeInDirective', data: { rule } })
122
+ return false
123
+ }
124
+
125
+ // Named, but not this rule: another rule on the element may be the one it keeps off.
126
+ if (marker.rules.length > 0 && !marker.rules.includes(rule)) return false
127
+ if (marker.rules.length === 0) {
128
+ context.report({ loc: comment.loc, messageId: 'escapeWithoutRule', data: { rule } })
97
129
  return false
98
130
  }
99
131
 
100
- const reason = comment.value
101
- .slice(at + ESCAPE_MARKER.length)
102
- .replace(/^:/, '')
103
- .trim()
132
+ const { reason } = marker
104
133
  if (reason.replace(/\s/g, '').length < MIN_REASON) {
105
- context.report({ loc: comment.loc, messageId: 'escapeWithoutReason' })
134
+ context.report({ loc: comment.loc, messageId: 'escapeWithoutReason', data: { rule } })
106
135
  return false
107
136
  }
108
137
 
@@ -17,7 +17,7 @@ const tester = new RuleTester({
17
17
  /** Real files of this package, so the rules are tested against what they read. */
18
18
  const src = (name: string) => fileURLToPath(new URL(`../${name}`, import.meta.url))
19
19
  const code = 'export function Probe() {\n return null\n}\n'
20
- const escaped = '// @estiva-escape: a probe file that documents itself in its own page\nexport function Probe() {\n return null\n}\n'
20
+ const escaped = '// @estiva-escape(component-has-a-page, component-has-a-story): a probe file that documents itself in its own page\nexport function Probe() {\n return null\n}\n'
21
21
 
22
22
  describe('the files these rules read', () => {
23
23
  it('Button has a page and a story beside it, and FieldLine and MenuItem have both without a component file', () => {
@@ -145,14 +145,14 @@ describe('an app lint with configs.recommended', () => {
145
145
  })
146
146
 
147
147
  it('passes the same element under an escape', async () => {
148
- const [result] = await lint(component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'))
148
+ const [result] = await lint(component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button type="button">x</button>'))
149
149
  expect(result.messages).toEqual([])
150
150
  })
151
151
  })
152
152
 
153
153
  describe('countGates', () => {
154
154
  it('counts an error, and an escape only when the lint reports escapes', async () => {
155
- const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
155
+ const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape(no-raw-element): a preview drawn from its own palette */}\n <button>y</button>\n </div>')
156
156
  const none = { errors: 0, warnings: 0, escapes: 0 }
157
157
  expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none })
158
158
  expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none })
@@ -65,7 +65,7 @@ tester.run('no-copied-look', noCopiedLook, {
65
65
  {
66
66
  name: 'an escape with its reason',
67
67
  filename: app,
68
- code: probe(`<>\n {/* @estiva-escape: recorded in GATES.md, a label strip Katerina left as it is */}\n <span className="${SECTION_LABEL}">x</span>\n </>`),
68
+ code: probe(`<>\n {/* @estiva-escape(no-copied-look): recorded in GATES.md, a label strip Katerina left as it is */}\n <span className="${SECTION_LABEL}">x</span>\n </>`),
69
69
  },
70
70
  ],
71
71
  invalid: [
@@ -29,15 +29,15 @@ tester.run('no-hand-rolled-behaviour', noHandRolledBehaviour, {
29
29
  },
30
30
  {
31
31
  name: 'the import of a portal, kept with its reason',
32
- code: "// @estiva-escape: the toast keeps its own portal until migration stage 6\nimport { createPortal } from 'react-dom'\nexport const Probe = () => null\n",
32
+ code: "// @estiva-escape(no-hand-rolled-behaviour): the toast keeps its own portal until migration stage 6\nimport { createPortal } from 'react-dom'\nexport const Probe = () => null\n",
33
33
  },
34
34
  {
35
35
  name: 'the call, kept with its reason',
36
- code: 'export function probe(to: HTMLElement) {\n // @estiva-escape: the toast keeps its own portal until migration stage 6\n return createPortal(null, to)\n}\n',
36
+ code: 'export function probe(to: HTMLElement) {\n // @estiva-escape(no-hand-rolled-behaviour): the toast keeps its own portal until migration stage 6\n return createPortal(null, to)\n}\n',
37
37
  },
38
38
  {
39
39
  name: 'a global listener kept with its reason',
40
- code: 'export function probe() {\n // @estiva-escape: the frame measures the window, which no floating part owns\n window.addEventListener("resize", () => {})\n}\n',
40
+ code: 'export function probe() {\n // @estiva-escape(no-hand-rolled-behaviour): the frame measures the window, which no floating part owns\n window.addEventListener("resize", () => {})\n}\n',
41
41
  },
42
42
  ],
43
43
  invalid: [
@@ -91,7 +91,7 @@ tester.run('no-handmade-empty-state', noHandmadeEmptyState, {
91
91
  { name: 'a box holding more than the line', code: jsx('<div>{rows.length === 0 ? <div className="px-3"><p>Nothing yet.</p><Button>Add one</Button></div> : rows.map((r) => <p key={r}>{r}</p>)}</div>') },
92
92
  {
93
93
  name: 'an escape with its reason',
94
- code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape: a printed report keeps its own empty line, set in the print style\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
94
+ code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape(no-handmade-empty-state): a printed report keeps its own empty line, set in the print style\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
95
95
  },
96
96
 
97
97
  // the right scope
@@ -118,7 +118,7 @@ tester.run('no-handmade-empty-state', noHandmadeEmptyState, {
118
118
  },
119
119
  {
120
120
  name: 'an escape with no reason is not an escape',
121
- code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
121
+ code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape(no-handmade-empty-state)\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
122
122
  errors: [{ messageId: 'escapeWithoutReason' }, ...handmade],
123
123
  },
124
124
 
@@ -33,7 +33,7 @@ tester.run('no-handmade-header', noHandmadeHeader, {
33
33
  { name: 'a column of rows', code: component('<div className="flex h-full flex-col">\n<div className="flex flex-col px-3"><span>{title}</span></div>\n<div />\n</div>') },
34
34
  {
35
35
  name: 'an escape with its reason',
36
- code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape: a print header that must not follow the theme */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
36
+ code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape(no-handmade-header): a print header that must not follow the theme */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
37
37
  },
38
38
  ],
39
39
  invalid: [
@@ -82,7 +82,7 @@ tester.run('no-handmade-header', noHandmadeHeader, {
82
82
  },
83
83
  {
84
84
  name: 'an escape with no reason escapes nothing',
85
- code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape: */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
85
+ code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape(no-handmade-header): */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
86
86
  errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'handmade' }],
87
87
  },
88
88
  ],
@@ -40,7 +40,7 @@ tester.run('no-native-title', noNativeTitle, {
40
40
  { name: "the app's own component with a title of its own (Peek's NewTopicBanner)", filename: app, code: component('<NewTopicBanner title={name} />', "import { NewTopicBanner } from '@/components/Banner'\n") },
41
41
  { name: "a story's title is the Storybook's, an object key", code: "const meta = { title: 'Primitives/Tooltip', component: Tooltip }\nexport default meta\n" },
42
42
  { name: 'a prop of another name', code: component('<span aria-label={name} data-title={name}>{name}</span>') },
43
- { name: 'an escape with its reason', code: component('<>\n{/* @estiva-escape: the print view has no pointer, and the address must survive a copy */}\n<span title={at}>{at}</span>\n</>') },
43
+ { name: 'an escape with its reason', code: component('<>\n{/* @estiva-escape(no-native-title): the print view has no pointer, and the address must survive a copy */}\n<span title={at}>{at}</span>\n</>') },
44
44
  ],
45
45
  invalid: [
46
46
  {
@@ -54,7 +54,7 @@ tester.run('no-native-title', noNativeTitle, {
54
54
  { name: "a part that hands it on to its element: the apps' status probe", code: component('<Button title="Delete">x</Button>', "import { Button } from '@estiva-app/ui'\n"), errors: native },
55
55
  { name: "through the app's re-export", filename: app, code: component('<IconButton title="Close" aria-label="Close">x</IconButton>', "import { IconButton } from '@/components/ui/IconButton'\n"), errors: native },
56
56
  { name: "through the app's wrapper that hands its props on", filename: app, code: component('<Button title="Delete">x</Button>', "import { Button } from '@/components/ui/Button'\n"), errors: native },
57
- { name: 'an escape with no reason is not an escape', code: component('<>\n{/* @estiva-escape */}\n<span title={at}>{at}</span>\n</>'), errors: [{ messageId: 'escapeWithoutReason' }, ...native] },
57
+ { name: 'an escape with no reason is not an escape', code: component('<>\n{/* @estiva-escape(no-native-title) */}\n<span title={at}>{at}</span>\n</>'), errors: [{ messageId: 'escapeWithoutReason' }, ...native] },
58
58
  ],
59
59
  })
60
60
 
@@ -25,6 +25,10 @@ tester.run('no-raw-element', noRawElement, {
25
25
  { name: "the package's Button", code: component('<Button>Save</Button>') },
26
26
  { name: 'a member expression named button', code: component('<Foo.button>Save</Foo.button>') },
27
27
  { name: 'a name that starts with button', code: component('<buttonish />') },
28
+ // R5: a handler that only stops the click passes; a part may take one
29
+ { name: 'a wrapper that only stops the click', code: component('<div onClick={(e) => e.stopPropagation()} onPointerDown={(event) => { event.stopPropagation(); event.preventDefault() }}><Button>Save</Button></div>') },
30
+ { name: 'a press handler on a part', code: component('<Card onClick={open}>x</Card>') },
31
+ { name: 'a clickable element, escaped with its reason', code: component(' // @estiva-escape(no-raw-element): a surface that holds headings and lists, which a button cannot hold\n <div onClick={edit}>x</div>') },
28
32
  {
29
33
  name: 'the elements that are not controls',
30
34
  code: component(' <section>\n <h1>Title</h1>\n <p>Text <strong>and</strong> <em>more</em></p>\n <ul><li>One</li></ul>\n <img src="x.png" alt="" />\n <hr />\n <kbd>Enter</kbd>\n <svg><path d="M0 0" /></svg>\n <time dateTime="2026-09-16">today</time>\n </section>'),
@@ -37,31 +41,45 @@ tester.run('no-raw-element', noRawElement, {
37
41
  { name: 'controls written as false', code: component('<audio src="a.mp3" controls={false} />') },
38
42
  {
39
43
  name: 'a line comment escape directly above',
40
- code: component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'),
44
+ code: component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button type="button">x</button>'),
45
+ },
46
+ {
47
+ name: 'a marker naming two rules keeps off each of them, written with or without the plugin name',
48
+ code: component(' // @estiva-escape(no-rebuilt-behaviour, estiva/no-raw-element): a preview drawn from its own palette\n <button type="button">x</button>'),
41
49
  },
42
50
  {
43
51
  name: 'a JSX comment escape on the line above a child',
44
- code: component(' <div>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button type="button">x</button>\n </div>'),
52
+ code: component(' <div>\n {/* @estiva-escape(no-raw-element): a preview drawn from its own palette */}\n <button type="button">x</button>\n </div>'),
45
53
  },
46
54
  {
47
55
  name: 'a JSX comment escape over several lines',
48
- code: component(' <div>\n {/*\n @estiva-escape: a preview drawn from its own palette\n */}\n <button type="button">x</button>\n </div>'),
56
+ code: component(' <div>\n {/*\n @estiva-escape(no-raw-element): a preview drawn from its own palette\n */}\n <button type="button">x</button>\n </div>'),
49
57
  },
50
58
  {
51
59
  name: 'an escape above an opening tag that spans lines',
52
- code: component(' // @estiva-escape: a preview drawn from its own palette\n <button\n type="button"\n onClick={() => {}}\n >\n x\n </button>'),
60
+ code: component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button\n type="button"\n onClick={() => {}}\n >\n x\n </button>'),
53
61
  },
54
62
  {
55
63
  name: 'exactly ten characters of reason, spaces not counted',
56
- code: component(' // @estiva-escape: ab cd ef gh ij\n <button>x</button>'),
64
+ code: component(' // @estiva-escape(no-raw-element): ab cd ef gh ij\n <button>x</button>'),
57
65
  },
58
66
  {
59
67
  name: 'an element with no part, escaped',
60
- code: component(' // @estiva-escape: a map from another service, until the package has a frame\n <iframe src="https://example.com" title="Map" />'),
68
+ code: component(' // @estiva-escape(no-raw-element): a map from another service, until the package has a frame\n <iframe src="https://example.com" title="Map" />'),
61
69
  },
62
70
  ],
63
71
  invalid: [
64
72
  { name: 'a raw button', code: component(' <button type="button">x</button>'), errors: [{ messageId: 'raw', line: 3 }] },
73
+ {
74
+ name: 'a clickable div names Button and Card with href (R5)',
75
+ code: component(' <div onClick={open}>x</div>'),
76
+ errors: [{ message: 'A clickable <div onClick> is a button made by hand: it takes no focus and answers no key. Use `Button` from @estiva-app/ui; for a whole card that opens something, `Card` with `href`. A handler that only stops the click (`e.stopPropagation()`) is fine.' }],
77
+ },
78
+ {
79
+ name: 'a press that does more than stop the click, on any plain element',
80
+ code: component(' <li onMouseDown={(e) => { e.preventDefault(); onSearch() }}>\n <span onPointerDown={handle} />\n </li>'),
81
+ errors: [{ messageId: 'clickable', data: { element: '<li onMouseDown>' } }, { messageId: 'clickable', data: { element: '<span onPointerDown>' } }],
82
+ },
65
83
  {
66
84
  name: 'an opening tag that spans lines (what grep misses)',
67
85
  code: component(' <input\n type="text"\n value={x}\n />'),
@@ -106,7 +124,7 @@ tester.run('no-raw-element', noRawElement, {
106
124
 
107
125
  {
108
126
  name: 'an escape with no reason is an error, and hides nothing',
109
- code: component(' // @estiva-escape:\n <button>x</button>'),
127
+ code: component(' // @estiva-escape(no-raw-element):\n <button>x</button>'),
110
128
  errors: [
111
129
  { messageId: 'escapeWithoutReason', line: 3 },
112
130
  { messageId: 'raw', line: 4 },
@@ -114,22 +132,36 @@ tester.run('no-raw-element', noRawElement, {
114
132
  },
115
133
  {
116
134
  name: 'nine characters of reason is too short',
117
- code: component(' <div>\n {/* @estiva-escape: ab cd ef gh i */}\n <button>x</button>\n </div>'),
135
+ code: component(' <div>\n {/* @estiva-escape(no-raw-element): ab cd ef gh i */}\n <button>x</button>\n </div>'),
118
136
  errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'raw' }],
119
137
  },
120
138
  {
121
139
  name: 'a marker with no colon and no reason',
122
- code: component(' // @estiva-escape\n <button>x</button>'),
140
+ code: component(' // @estiva-escape(no-raw-element)\n <button>x</button>'),
123
141
  errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'raw' }],
124
142
  },
143
+ // B3 (Katerina, 25 September): a marker keeps off only the rules it names.
144
+ {
145
+ name: 'a marker that names no rule escapes nothing, and says which name to write',
146
+ code: component(' // @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
147
+ errors: [
148
+ { line: 3, message: 'Name the rule this escape keeps off: `@estiva-escape(no-raw-element): <reason>`. An escape now keeps off only the rules it names.' },
149
+ { messageId: 'raw', line: 4 },
150
+ ],
151
+ },
152
+ {
153
+ name: 'a marker that names another rule does not keep this one off',
154
+ code: component(' // @estiva-escape(no-rebuilt-behaviour): a preview drawn from its own palette\n <button>x</button>'),
155
+ errors: [{ messageId: 'raw', line: 4 }],
156
+ },
125
157
  {
126
158
  name: 'an escape two lines above does not reach the element',
127
- code: component(' // @estiva-escape: a preview drawn from its own palette\n\n <button>x</button>'),
159
+ code: component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n\n <button>x</button>'),
128
160
  errors: [{ messageId: 'raw', line: 5 }],
129
161
  },
130
162
  {
131
163
  name: 'an escape above a sibling does not reach the next element',
132
- code: component(' <div>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <span />\n <button>x</button>\n </div>'),
164
+ code: component(' <div>\n {/* @estiva-escape(no-raw-element): a preview drawn from its own palette */}\n <span />\n <button>x</button>\n </div>'),
133
165
  errors: [{ messageId: 'raw', line: 6 }],
134
166
  },
135
167
  {
@@ -139,23 +171,23 @@ tester.run('no-raw-element', noRawElement, {
139
171
  },
140
172
  {
141
173
  name: 'a marker inside a directive that switches every rule off is refused',
142
- code: component(' // eslint-disable-next-line -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
174
+ code: component(' // eslint-disable-next-line -- @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button>x</button>'),
143
175
  // The directive silences the element's own report; the refusal sits on the directive's line.
144
176
  errors: [{ messageId: 'escapeInDirective', line: 3 }],
145
177
  },
146
178
  {
147
179
  name: 'a marker inside a directive that names this rule is refused',
148
- code: component(' // eslint-disable-next-line rule-to-test/no-raw-element -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
180
+ code: component(' // eslint-disable-next-line rule-to-test/no-raw-element -- @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button>x</button>'),
149
181
  errors: [{ messageId: 'escapeInDirective', line: 3 }],
150
182
  },
151
183
  {
152
184
  name: "the token lint's note for another rule is not an escape of this one",
153
- code: component(' // eslint-disable-next-line no-console -- @estiva-escape: its hand-written type waits for that\n <button>x</button>'),
185
+ code: component(' // eslint-disable-next-line no-console -- @estiva-escape(no-raw-element): its hand-written type waits for that\n <button>x</button>'),
154
186
  errors: [{ messageId: 'raw', line: 4 }],
155
187
  },
156
188
  {
157
189
  name: 'with reportEscapes on, a sanctioned escape is reported for the count',
158
- code: component(' // @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
190
+ code: component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button>x</button>'),
159
191
  settings: { estiva: { reportEscapes: true } },
160
192
  errors: [{ messageId: 'escaped', data: { reason: 'a preview drawn from its own palette' }, line: 3 }],
161
193
  },
@@ -111,7 +111,43 @@ function isOn(element: JSXOpeningElement, name: string): boolean {
111
111
  return attr !== undefined && literal(attr) !== false
112
112
  }
113
113
 
114
- type Found = { report: 'raw'; element: string; part: RawElementPart } | { report: 'noPart'; element: string } | null
114
+ type Found = { report: 'raw'; element: string; part: RawElementPart } | { report: 'noPart'; element: string } | { report: 'clickable'; element: string } | null
115
+
116
+ /** The pointer's press, as a handler on a plain element makes it a control (R5). */
117
+ const CLICK_HANDLERS = ['onClick', 'onMouseDown', 'onMouseUp', 'onPointerDown', 'onPointerUp', 'onDoubleClick']
118
+
119
+ interface Expression {
120
+ type: string
121
+ [key: string]: unknown
122
+ }
123
+
124
+ /** `e.stopPropagation()` or `e.preventDefault()`: a handler that only keeps the click from what is behind. */
125
+ function onlyStops(handler: Expression | undefined): boolean {
126
+ if (!handler || (handler.type !== 'ArrowFunctionExpression' && handler.type !== 'FunctionExpression')) return false
127
+ const body = handler.body as Expression
128
+ const calls = body.type === 'BlockStatement' ? (body.body as Expression[]).map((s) => (s.type === 'ExpressionStatement' ? (s.expression as Expression) : s)) : [body]
129
+ return (
130
+ calls.length > 0 &&
131
+ calls.every((call) => {
132
+ if (call.type !== 'CallExpression') return false
133
+ const callee = call.callee as Expression
134
+ const property = callee.type === 'MemberExpression' ? (callee.property as Expression) : undefined
135
+ return property?.type === 'Identifier' && (property.name === 'stopPropagation' || property.name === 'preventDefault')
136
+ })
137
+ )
138
+ }
139
+
140
+ /** A plain element made clickable by hand: the first press handler that does more than stop the click. */
141
+ function clickHandler(element: JSXOpeningElement): string | undefined {
142
+ for (const name of CLICK_HANDLERS) {
143
+ const attr = attribute(element, name)
144
+ if (!attr) continue
145
+ const value = attr.value
146
+ const handler = value?.type === 'JSXExpressionContainer' ? (value.expression as Expression | undefined) : undefined
147
+ if (!onlyStops(handler)) return name
148
+ }
149
+ return undefined
150
+ }
115
151
 
116
152
  /** What this element is to the rule: a raw element with a part, one with none yet, or none of its business. */
117
153
  export function classify(element: JSXOpeningElement): Found {
@@ -131,7 +167,14 @@ export function classify(element: JSXOpeningElement): Found {
131
167
  return part ? { report: 'raw', element: shown, part } : { report: 'noPart', element: shown }
132
168
  }
133
169
 
134
- if (!Object.hasOwn(RAW_ELEMENT_PARTS, name)) return null
170
+ if (!Object.hasOwn(RAW_ELEMENT_PARTS, name)) {
171
+ // A plain element with a press handler is a button drawn by hand (Katerina's ruling R5,
172
+ // 25 September): no focus, no keys, no look anybody keeps in step. `<option>` and
173
+ // `<summary>` live inside a control the rule already reads.
174
+ if (!/^[a-z][a-z0-9]*$/.test(name) || name === 'option' || name === 'summary') return null
175
+ const handler = clickHandler(element)
176
+ return handler ? { report: 'clickable', element: `<${name} ${handler}>` } : null
177
+ }
135
178
  if ((name === 'audio' || name === 'video') && !isOn(element, 'controls')) return null
136
179
  if (name === 'img' && !attribute(element, 'usemap') && !attribute(element, 'useMap')) return null
137
180
 
@@ -150,6 +193,10 @@ export function classify(element: JSXOpeningElement): Found {
150
193
  * that says so: the part is made in the package, not by hand in an app
151
194
  * (Katerina, 16 September).
152
195
  *
196
+ * A plain element with a press handler (`<div onClick>`) is a control too, and
197
+ * is refused naming `Button` (R5, 25 September); a handler that only stops the
198
+ * click reaching what is behind passes.
199
+ *
153
200
  * Only a JSX element written with a lowercase name. `<Button>`,
154
201
  * `<Foo.button>` and `createElement('button')` are not this rule's business.
155
202
  */
@@ -160,6 +207,8 @@ export const noRawElement: Rule.RuleModule = {
160
207
  schema: [],
161
208
  messages: {
162
209
  raw: 'Use `{{use}}` from @estiva-app/ui instead of a raw {{element}}.{{more}}',
210
+ clickable:
211
+ 'A clickable {{element}} is a button made by hand: it takes no focus and answers no key. Use `Button` from @estiva-app/ui; for a whole card that opens something, `Card` with `href`. A handler that only stops the click (`e.stopPropagation()`) is fine.',
163
212
  noPart: '@estiva-app/ui has no part for a raw {{element}} yet. Do not build one here: ask Katerina, and it gets made in @estiva-app/ui.',
164
213
  ...ESCAPE_MESSAGES,
165
214
  },
@@ -172,7 +221,8 @@ export const noRawElement: Rule.RuleModule = {
172
221
  const found = classify(element)
173
222
  if (!found) return
174
223
  if (isEscaped(context, element)) return
175
- if (found.report === 'noPart') context.report({ loc: element.loc, messageId: 'noPart', data: { element: found.element } })
224
+ if (found.report === 'clickable') context.report({ loc: element.loc, messageId: 'clickable', data: { element: found.element } })
225
+ else if (found.report === 'noPart') context.report({ loc: element.loc, messageId: 'noPart', data: { element: found.element } })
176
226
  else context.report({ loc: element.loc, messageId: 'raw', data: { element: found.element, use: found.part.use, more: found.part.more ?? '' } })
177
227
  },
178
228
  }