@estiva-app/ui 0.35.1 → 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 (132) 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 +41 -185
  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-KMZIMB3G.js → chunk-XK747STJ.js} +46 -3
  49. package/dist/registry/chunk-XK747STJ.js.map +7 -0
  50. package/dist/registry/cli.js +93 -34
  51. package/dist/registry/cli.js.map +3 -3
  52. package/dist/registry/contract.d.ts +27 -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/dist/registry/siblings.d.ts +9 -0
  58. package/dist/registry/siblings.d.ts.map +1 -1
  59. package/dist/registry/storymap.d.ts +19 -5
  60. package/dist/registry/storymap.d.ts.map +1 -1
  61. package/package.json +4 -1
  62. package/registry.json +293 -17
  63. package/skill/estiva-ui.md +2 -1
  64. package/src/AppShell.mdx +2 -1
  65. package/src/AttachmentCard.tsx +7 -7
  66. package/src/Button.tsx +1 -1
  67. package/src/Card.tsx +2 -2
  68. package/src/ChipInput.mdx +2 -0
  69. package/src/ChipInput.tsx +1 -1
  70. package/src/CommandPalette.tsx +3 -3
  71. package/src/DialogShell.tsx +1 -1
  72. package/src/EmptyState.mdx +4 -3
  73. package/src/Lightbox.tsx +1 -1
  74. package/src/NavItem.tsx +1 -1
  75. package/src/PersonTrigger.tsx +1 -1
  76. package/src/Reaction.tsx +1 -1
  77. package/src/ScrollArea.mdx +4 -0
  78. package/src/Select.tsx +1 -1
  79. package/src/TopBar.mdx +1 -1
  80. package/src/eslint/escape.ts +43 -14
  81. package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
  82. package/src/eslint/index.test.ts +2 -2
  83. package/src/eslint/no-copied-look.test.ts +1 -1
  84. package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
  85. package/src/eslint/no-handmade-empty-state.test.ts +2 -2
  86. package/src/eslint/no-handmade-header.test.ts +2 -2
  87. package/src/eslint/no-native-title.test.ts +2 -2
  88. package/src/eslint/no-raw-element.test.ts +47 -15
  89. package/src/eslint/no-raw-element.ts +53 -3
  90. package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
  91. package/src/eslint/no-restyled-part.test.ts +21 -2
  92. package/src/eslint/no-restyled-part.ts +30 -0
  93. package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
  94. package/src/gates/app-checks.ts +4 -3
  95. package/src/gates/ci.ts +152 -0
  96. package/src/gates/cli.ts +19 -3
  97. package/src/gates/count.ts +1 -1
  98. package/src/gates/create-app.test.ts +2 -2
  99. package/src/gates/create-app.ts +22 -16
  100. package/src/gates/escapes.ts +111 -0
  101. package/src/gates/gate-config.ts +6 -2
  102. package/src/gates/gates.test.ts +93 -1
  103. package/src/gates/hook.ts +44 -6
  104. package/src/gates/index.ts +5 -1
  105. package/src/gates/name-escapes.ts +77 -0
  106. package/src/gates/status.ts +45 -2
  107. package/src/gates/token-lint.ts +11 -4
  108. package/src/gates/token-style.ts +79 -0
  109. package/src/how.test.ts +282 -0
  110. package/src/pages.test.ts +17 -0
  111. package/src/registry/app.test.ts +34 -0
  112. package/src/registry/app.ts +52 -2
  113. package/src/registry/behaviours.ts +223 -0
  114. package/src/registry/build.ts +36 -0
  115. package/src/registry/cli.ts +37 -18
  116. package/src/registry/contract.test.ts +63 -3
  117. package/src/registry/contract.ts +75 -2
  118. package/src/registry/index.ts +1 -1
  119. package/src/registry/registry.test.ts +14 -0
  120. package/src/registry/siblings.test.ts +24 -1
  121. package/src/registry/siblings.ts +23 -0
  122. package/src/registry/storymap.test.ts +52 -31
  123. package/src/registry/storymap.ts +76 -37
  124. package/stories/Choosing.mdx +8 -2
  125. package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
  126. package/dist/gates/chunk-GI6M4P7H.js +0 -606
  127. package/dist/gates/chunk-GI6M4P7H.js.map +0 -7
  128. package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
  129. package/dist/registry/chunk-KMZIMB3G.js.map +0 -7
  130. package/dist/registry/chunk-XVII7P6G.js.map +0 -7
  131. /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
  132. /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
@@ -3,15 +3,21 @@ import type { AST, Rule } from 'eslint';
3
3
  * The escape marker (UIG-3, seam S4 in docs/GATES.md §16): one written reason
4
4
  * why one element stays as it is.
5
5
  *
6
- * A line comment directly above the element,
6
+ * A line comment directly above the element, naming the rule it keeps off,
7
7
  *
8
- * // @estiva-escape: <reason>
8
+ * // @estiva-escape(no-raw-element): <reason>
9
9
  *
10
10
  * or, where the element is a JSX child, the JSX comment on the line above,
11
11
  *
12
- * {/* @estiva-escape: <reason> *\/}
12
+ * {/* @estiva-escape(no-raw-element): <reason> *\/}
13
13
  *
14
- * with at least ten characters of reason, spaces not counted.
14
+ * with at least ten characters of reason, spaces not counted. It escapes only
15
+ * the rules it names (Katerina's ruling B3, 25 September: one reason used to
16
+ * switch off every rule on the element, so a raw button that also wrote a
17
+ * dialog's role by hand passed under one reason). Two rules on one element are
18
+ * named together, `@estiva-escape(no-raw-element, no-rebuilt-behaviour)`, each
19
+ * on purpose. A marker that names no rule escapes nothing, and says which name
20
+ * to write.
15
21
  *
16
22
  * Not `eslint-disable`. A directive switches a rule off without saying it was
17
23
  * meant, and the count of escapes (`.gates-count.json`) could not see it. A
@@ -33,10 +39,21 @@ export declare const SETTINGS_KEY = "estiva";
33
39
  * reports through the rule that called it.
34
40
  */
35
41
  export declare const ESCAPE_MESSAGES: {
36
- readonly escapeWithoutReason: "An escape needs its reason, at least 10 characters: `// @estiva-escape: <why this stays>`. An escape with no reason hides nothing.";
37
- readonly escapeInDirective: "Write the escape as its own comment on the line above: `// @estiva-escape: <reason>`. Inside an eslint-disable comment it switches the rule off instead of recording why.";
42
+ readonly escapeWithoutReason: "An escape needs its reason, at least 10 characters: `// @estiva-escape({{rule}}): <why this stays>`. An escape with no reason hides nothing.";
43
+ readonly escapeInDirective: "Write the escape as its own comment on the line above: `// @estiva-escape({{rule}}): <reason>`. Inside an eslint-disable comment it switches the rule off instead of recording why.";
44
+ readonly escapeWithoutRule: "Name the rule this escape keeps off: `@estiva-escape({{rule}}): <reason>`. An escape now keeps off only the rules it names.";
38
45
  readonly escaped: "Escaped: {{reason}}";
39
46
  };
47
+ /** A rule's name as a marker writes it: `no-raw-element` for `estiva/no-raw-element`. */
48
+ export declare const shortRuleName: (id: string) => string;
49
+ /**
50
+ * What a marker says, read from a comment's text: the rules it names (none when
51
+ * it names none) and its reason. Null when the comment carries no marker.
52
+ */
53
+ export declare function readMarker(text: string): {
54
+ rules: string[];
55
+ reason: string;
56
+ } | null;
40
57
  export interface EstivaSettings {
41
58
  /**
42
59
  * Report every sanctioned escape as a message with the id `escaped`, so a
@@ -1 +1 @@
1
- {"version":3,"file":"escape.d.ts","sourceRoot":"","sources":["../../src/eslint/escape.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAEvC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,aAAa,mBAAmB,CAAA;AAE7C,gDAAgD;AAChD,eAAO,MAAM,UAAU,KAAK,CAAA;AAE5B,2DAA2D;AAC3D,eAAO,MAAM,YAAY,WAAW,CAAA;AAEpC;;;GAGG;AACH,eAAO,MAAM,eAAe;;;;CAIlB,CAAA;AAEV,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,UAAU,OAAO;IACf,GAAG,CAAC,EAAE,GAAG,CAAC,cAAc,GAAG,IAAI,CAAA;IAC/B,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACzB;AAaD;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAmC3E"}
1
+ {"version":3,"file":"escape.d.ts","sourceRoot":"","sources":["../../src/eslint/escape.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAEvC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,aAAa,mBAAmB,CAAA;AAE7C,gDAAgD;AAChD,eAAO,MAAM,UAAU,KAAK,CAAA;AAE5B,2DAA2D;AAC3D,eAAO,MAAM,YAAY,WAAW,CAAA;AAEpC;;;GAGG;AACH,eAAO,MAAM,eAAe;;;;;CAKlB,CAAA;AAEV,yFAAyF;AACzF,eAAO,MAAM,aAAa,GAAI,IAAI,MAAM,WAAsC,CAAA;AAE9E;;;GAGG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAQnF;AAED,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,UAAU,OAAO;IACf,GAAG,CAAC,EAAE,GAAG,CAAC,cAAc,GAAG,IAAI,CAAA;IAC/B,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACzB;AAaD;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAwC3E"}
@@ -9,10 +9,21 @@ var ESCAPE_MARKER = "@estiva-escape";
9
9
  var MIN_REASON = 10;
10
10
  var SETTINGS_KEY = "estiva";
11
11
  var ESCAPE_MESSAGES = {
12
- escapeWithoutReason: `An escape needs its reason, at least ${MIN_REASON} characters: \`// ${ESCAPE_MARKER}: <why this stays>\`. An escape with no reason hides nothing.`,
13
- 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.`,
12
+ escapeWithoutReason: `An escape needs its reason, at least ${MIN_REASON} characters: \`// ${ESCAPE_MARKER}({{rule}}): <why this stays>\`. An escape with no reason hides nothing.`,
13
+ 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.`,
14
+ escapeWithoutRule: `Name the rule this escape keeps off: \`${ESCAPE_MARKER}({{rule}}): <reason>\`. An escape now keeps off only the rules it names.`,
14
15
  escaped: "Escaped: {{reason}}"
15
16
  };
17
+ var shortRuleName = (id) => id.slice(id.lastIndexOf("/") + 1);
18
+ function readMarker(text) {
19
+ const at = text.indexOf(ESCAPE_MARKER);
20
+ if (at === -1) return null;
21
+ const rest = text.slice(at + ESCAPE_MARKER.length);
22
+ const named = /^\s*\(([^)]*)\)/.exec(rest);
23
+ const rules2 = named ? named[1].split(/[\s,]+/).filter(Boolean).map(shortRuleName) : [];
24
+ const reason = (named ? rest.slice(named[0].length) : rest).replace(/^\s*:/, "").trim();
25
+ return { rules: rules2, reason };
26
+ }
16
27
  var DIRECTIVE = /^\s*eslint-disable(?:-next-line|-line)?(?=\s|$)([^]*)$/;
17
28
  function directiveRules(rest) {
18
29
  const dashes = rest.search(/(?:^|\s)--(?:\s|$)/);
@@ -26,18 +37,24 @@ function isEscaped(context, node) {
26
37
  const nodeStart = node.range[0];
27
38
  const comment = sourceCode.getAllComments().find((c) => c.loc && c.range && c.loc.end.line === line - 1 && BETWEEN.test(sourceCode.text.slice(c.range[1], nodeStart)));
28
39
  if (!comment?.loc) return false;
29
- const at = comment.value.indexOf(ESCAPE_MARKER);
30
- if (at === -1) return false;
40
+ const marker = readMarker(comment.value);
41
+ if (!marker) return false;
42
+ const rule = shortRuleName(context.id);
31
43
  const directive = DIRECTIVE.exec(comment.value);
32
44
  if (directive) {
33
45
  const rules2 = directiveRules(directive[1]);
34
46
  if (rules2.length > 0 && !rules2.includes(context.id)) return false;
35
- context.report({ loc: comment.loc, messageId: "escapeInDirective" });
47
+ context.report({ loc: comment.loc, messageId: "escapeInDirective", data: { rule } });
48
+ return false;
49
+ }
50
+ if (marker.rules.length > 0 && !marker.rules.includes(rule)) return false;
51
+ if (marker.rules.length === 0) {
52
+ context.report({ loc: comment.loc, messageId: "escapeWithoutRule", data: { rule } });
36
53
  return false;
37
54
  }
38
- const reason = comment.value.slice(at + ESCAPE_MARKER.length).replace(/^:/, "").trim();
55
+ const { reason } = marker;
39
56
  if (reason.replace(/\s/g, "").length < MIN_REASON) {
40
- context.report({ loc: comment.loc, messageId: "escapeWithoutReason" });
57
+ context.report({ loc: comment.loc, messageId: "escapeWithoutReason", data: { rule } });
41
58
  return false;
42
59
  }
43
60
  const settings = context.settings[SETTINGS_KEY];
@@ -175,6 +192,7 @@ var PLACEMENT = [
175
192
  // Draws nothing: it names the part, so what is inside can answer its hover (`group-hover:`).
176
193
  { what: "a name for hover", pattern: /^(?:group|peer)(?:\/[\w-]+)?$/ }
177
194
  ];
195
+ var PLACEMENT_STYLE = /^(?:margin|padding|inset|scrollMargin)(?:Top|Right|Bottom|Left|Block|Inline|BlockStart|BlockEnd|InlineStart|InlineEnd)?$|^(?:width|height|minWidth|minHeight|maxWidth|maxHeight|inlineSize|blockSize|minInlineSize|minBlockSize|maxInlineSize|maxBlockSize)$|^(?:display|flex|flexGrow|flexShrink|flexBasis|order|gap|rowGap|columnGap|alignSelf|justifySelf|placeSelf|gridArea|gridColumn|gridColumnStart|gridColumnEnd|gridRow|gridRowStart|gridRowEnd)$|^(?:position|top|right|bottom|left|zIndex|transform|translate)$/;
178
196
  var REACHING_VARIANT = /^\[|^(?:before|after|placeholder|file|marker|selection|first-letter|first-line|backdrop|\*|\*\*)$/;
179
197
  function splitClass(token) {
180
198
  const variants = [];
@@ -606,6 +624,7 @@ var noRestyledPart = {
606
624
  schema: [],
607
625
  messages: {
608
626
  restyled: "{{classes}} on {{where}} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in.{{use}} A look for what is around it goes on your own element around it.",
627
+ restyledStyle: "{{keys}} in the `style` of {{where}} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled, by class or by style: only space, size, flex and grid, and position pass in.{{use}} A look for what is around it goes on your own element around it.",
609
628
  emptyStatePadding: "{{classes}} pads {{where}}. EmptyState takes no padding (Katerina, 14 September): its room comes from the box its rows live in, so put the padding on that box.",
610
629
  ...ESCAPE_MESSAGES
611
630
  }
@@ -639,6 +658,19 @@ var noRestyledPart = {
639
658
  }
640
659
  if (padding.length) context.report({ loc: at.loc, messageId: "emptyStatePadding", data: { classes: code(padding), where } });
641
660
  };
661
+ const checkStyle = (element, binding, value, at) => {
662
+ if (binding.props !== "all" || value?.type !== "JSXExpressionContainer") return;
663
+ let object = child(value, "expression");
664
+ if (object?.type === "Identifier") object = constantOf(object, scopeOf(object));
665
+ while (object?.type === "TSAsExpression" || object?.type === "TSSatisfiesExpression") object = child(object, "expression");
666
+ if (object?.type !== "ObjectExpression") return;
667
+ const keys = children(object, "properties").map((property) => property?.type === "Property" && !property.computed ? nameOf(child(property, "key")) : void 0).filter((key) => !!key && !key.startsWith("--") && !PLACEMENT_STYLE.test(key));
668
+ if (!keys.length || escaped(element)) return;
669
+ const props = PART_LOOK_PROPS[binding.part];
670
+ const use = props ? ` Use its ${or(props)}.` : ` \`${binding.part}\` has no prop for this yet: ask Katerina, and it gets made in @estiva-app/ui.`;
671
+ const where = binding.via && binding.via !== binding.part ? `\`${binding.via}\` (it hands its props to \`${binding.part}\`)` : `\`${binding.part}\``;
672
+ context.report({ loc: at.loc, messageId: "restyledStyle", data: { keys: code([...new Set(keys)]), where, use } });
673
+ };
642
674
  return {
643
675
  Program(program) {
644
676
  locals = moduleParts(file, program, parser, /* @__PURE__ */ new Set([file])).locals;
@@ -652,6 +684,7 @@ var noRestyledPart = {
652
684
  if (attribute3.type === "JSXAttribute") {
653
685
  const prop = nameOf(child(attribute3, "name"));
654
686
  if (prop && CLASS_PROP.test(prop)) check(element, binding, prop, child(attribute3, "value"), attribute3);
687
+ else if (prop === "style") checkStyle(element, binding, child(attribute3, "value"), attribute3);
655
688
  continue;
656
689
  }
657
690
  let spread = child(attribute3, "argument");
@@ -1452,6 +1485,28 @@ function isOn(element, name) {
1452
1485
  const attr = attribute2(element, name);
1453
1486
  return attr !== void 0 && literal(attr) !== false;
1454
1487
  }
1488
+ var CLICK_HANDLERS = ["onClick", "onMouseDown", "onMouseUp", "onPointerDown", "onPointerUp", "onDoubleClick"];
1489
+ function onlyStops(handler) {
1490
+ if (!handler || handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression") return false;
1491
+ const body = handler.body;
1492
+ const calls = body.type === "BlockStatement" ? body.body.map((s) => s.type === "ExpressionStatement" ? s.expression : s) : [body];
1493
+ return calls.length > 0 && calls.every((call) => {
1494
+ if (call.type !== "CallExpression") return false;
1495
+ const callee = call.callee;
1496
+ const property = callee.type === "MemberExpression" ? callee.property : void 0;
1497
+ return property?.type === "Identifier" && (property.name === "stopPropagation" || property.name === "preventDefault");
1498
+ });
1499
+ }
1500
+ function clickHandler(element) {
1501
+ for (const name of CLICK_HANDLERS) {
1502
+ const attr = attribute2(element, name);
1503
+ if (!attr) continue;
1504
+ const value = attr.value;
1505
+ const handler = value?.type === "JSXExpressionContainer" ? value.expression : void 0;
1506
+ if (!onlyStops(handler)) return name;
1507
+ }
1508
+ return void 0;
1509
+ }
1455
1510
  function classify(element) {
1456
1511
  if (element.name.type !== "JSXIdentifier" || !element.name.name) return null;
1457
1512
  const name = element.name.name;
@@ -1466,7 +1521,11 @@ function classify(element) {
1466
1521
  const shown = `<input type="${key}">`;
1467
1522
  return part2 ? { report: "raw", element: shown, part: part2 } : { report: "noPart", element: shown };
1468
1523
  }
1469
- if (!Object.hasOwn(RAW_ELEMENT_PARTS, name)) return null;
1524
+ if (!Object.hasOwn(RAW_ELEMENT_PARTS, name)) {
1525
+ if (!/^[a-z][a-z0-9]*$/.test(name) || name === "option" || name === "summary") return null;
1526
+ const handler = clickHandler(element);
1527
+ return handler ? { report: "clickable", element: `<${name} ${handler}>` } : null;
1528
+ }
1470
1529
  if ((name === "audio" || name === "video") && !isOn(element, "controls")) return null;
1471
1530
  if (name === "img" && !attribute2(element, "usemap") && !attribute2(element, "useMap")) return null;
1472
1531
  const part = RAW_ELEMENT_PARTS[name];
@@ -1479,6 +1538,7 @@ var noRawElement = {
1479
1538
  schema: [],
1480
1539
  messages: {
1481
1540
  raw: "Use `{{use}}` from @estiva-app/ui instead of a raw {{element}}.{{more}}",
1541
+ clickable: "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.",
1482
1542
  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.",
1483
1543
  ...ESCAPE_MESSAGES
1484
1544
  }
@@ -1491,7 +1551,8 @@ var noRawElement = {
1491
1551
  const found = classify(element);
1492
1552
  if (!found) return;
1493
1553
  if (isEscaped(context, element)) return;
1494
- if (found.report === "noPart") context.report({ loc: element.loc, messageId: "noPart", data: { element: found.element } });
1554
+ if (found.report === "clickable") context.report({ loc: element.loc, messageId: "clickable", data: { element: found.element } });
1555
+ else if (found.report === "noPart") context.report({ loc: element.loc, messageId: "noPart", data: { element: found.element } });
1495
1556
  else context.report({ loc: element.loc, messageId: "raw", data: { element: found.element, use: found.part.use, more: found.part.more ?? "" } });
1496
1557
  }
1497
1558
  };