@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/README.md CHANGED
@@ -63,6 +63,34 @@ export default {
63
63
  }
64
64
  ```
65
65
 
66
+ ### The lint rules — `@estiva-app/ui/eslint`
67
+
68
+ The UI Guardrails' rules ship with the package, as an ESLint plugin, so an app
69
+ gets a new rule with a version bump. Each rule names the component to use
70
+ instead. Today there is one: `estiva/no-raw-button`.
71
+
72
+ ```js
73
+ // eslint.config.js — alongside your own rules
74
+ import estiva from '@estiva-app/ui/eslint'
75
+ export default [
76
+ // …your config, with a parser that reads TSX
77
+ { files: ['src/**/*.tsx'], ignores: ['**/*.test.tsx'], ...estiva.configs.recommended },
78
+ ]
79
+ ```
80
+
81
+ A place that keeps a raw element on purpose says why, on the line above:
82
+
83
+ ```tsx
84
+ // @estiva-escape: the reason, at least ten characters
85
+ <button …>
86
+ ```
87
+
88
+ or, as a JSX child, `{/* @estiva-escape: the reason */}`. Not `eslint-disable`:
89
+ it switches the rule off without saying why, and the gate counts it as a
90
+ failure. `countGates(results)` counts errors and escapes for
91
+ `.gates-count.json`; lint with `settings: { estiva: { reportEscapes: true } }`
92
+ for the escapes to be counted.
93
+
66
94
  ## The rules
67
95
 
68
96
  ### Forking is allowed
@@ -16,9 +16,22 @@ export interface InputChipProps {
16
16
  leading?: ReactNode;
17
17
  /** Draws the ✕; absent, the chip is display-only. */
18
18
  onRemove?: () => void;
19
+ /**
20
+ * The ✕'s name for a screen reader. `Remove <label>` when not given. A chip
21
+ * whose ✕ does something other than remove it names what it does — a
22
+ * launcher's scope chip leaves the scope ("Leave Ship").
23
+ */
24
+ removeLabel?: string;
25
+ /**
26
+ * Cut a long label with an ellipsis once the chip is capped, by a
27
+ * `max-w-*` on `className`. Off unless asked: cutting clips up to 4px of a
28
+ * letter's soft edge at 1x even when the label fits (measured 2026-09-15),
29
+ * so a chip that is never capped keeps every pixel.
30
+ */
31
+ truncate?: boolean;
19
32
  className?: string;
20
33
  }
21
- export declare function InputChip({ label, leading, onRemove, className }: InputChipProps): import("react").JSX.Element;
34
+ export declare function InputChip({ label, leading, onRemove, removeLabel, truncate, className }: InputChipProps): import("react").JSX.Element;
22
35
  /**
23
36
  * A multi-select input: chips for the chosen, a typeahead for the rest —
24
37
  * Peek's PersonChipInput (2026-09-01), generalised on the way in, and on Base
@@ -1 +1 @@
1
- {"version":3,"file":"ChipInput.d.ts","sourceRoot":"","sources":["../src/ChipInput.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAyC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAuB7E;;;;;;;;;;GAUG;AACH,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,mDAAmD;IACnD,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,EAAE,cAAc,+BAwBhF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,eAAe,GAAG,eAAe;IACzE,KAAK,EAAE,CAAC,EAAE,CAAA;IACV,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,IAAI,CAAA;IAC7B,4CAA4C;IAC5C,OAAO,EAAE,CAAC,EAAE,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,qDAAqD;IACrD,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,SAAS,CAAA;IACtC,6CAA6C;IAC7C,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,SAAS,CAAA;IACrC,0EAA0E;IAC1E,eAAe,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,OAAO,CAAA;IAC5C;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,yEAAyE;IACzE,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED,wBAAgB,SAAS,CAAC,CAAC,SAAS,eAAe,GAAG,eAAe,EAAE,EACrE,KAAK,EACL,QAAQ,EACR,OAAO,EACP,WAAuB,EACvB,SAAS,EACT,UAAe,EACf,WAAW,EACX,UAAU,EACV,GAAG,IAAI,EACR,EAAE,cAAc,CAAC,CAAC,CAAC,+BAkJnB"}
1
+ {"version":3,"file":"ChipInput.d.ts","sourceRoot":"","sources":["../src/ChipInput.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAyC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAuB7E;;;;;;;;;;GAUG;AACH,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,mDAAmD;IACnD,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,QAAQ,EAAE,SAAS,EAAE,EAAE,cAAc,+BAyBvG;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAA;IACb,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,eAAe,GAAG,eAAe;IACzE,KAAK,EAAE,CAAC,EAAE,CAAA;IACV,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,IAAI,CAAA;IAC7B,4CAA4C;IAC5C,OAAO,EAAE,CAAC,EAAE,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;IACrB,qDAAqD;IACrD,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,SAAS,CAAA;IACtC,6CAA6C;IAC7C,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,SAAS,CAAA;IACrC,0EAA0E;IAC1E,eAAe,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,OAAO,CAAA;IAC5C;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,yEAAyE;IACzE,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED,wBAAgB,SAAS,CAAC,CAAC,SAAS,eAAe,GAAG,eAAe,EAAE,EACrE,KAAK,EACL,QAAQ,EACR,OAAO,EACP,WAAuB,EACvB,SAAS,EACT,UAAe,EACf,WAAW,EACX,UAAU,EACV,GAAG,IAAI,EACR,EAAE,cAAc,CAAC,CAAC,CAAC,+BAkJnB"}
@@ -0,0 +1,61 @@
1
+ import type { AST, Rule } from 'eslint';
2
+ /**
3
+ * The escape marker (UIG-3, seam S4 in docs/GATES.md §16): one written reason
4
+ * why one element stays as it is.
5
+ *
6
+ * A line comment directly above the element,
7
+ *
8
+ * // @estiva-escape: <reason>
9
+ *
10
+ * or, where the element is a JSX child, the JSX comment on the line above,
11
+ *
12
+ * {/* @estiva-escape: <reason> *\/}
13
+ *
14
+ * with at least ten characters of reason, spaces not counted.
15
+ *
16
+ * Not `eslint-disable`. A directive switches a rule off without saying it was
17
+ * meant, and the count of escapes (`.gates-count.json`) could not see it. A
18
+ * marker written inside a directive that switches one of these rules off is
19
+ * reported for that reason.
20
+ *
21
+ * The token lint's older notes (`eslint-disable-next-line <rule> -- @estiva-escape:
22
+ * <reason>`, Katerina's ruling A2 of 15 September) are not read here: they
23
+ * escape the token lint's rules, which are not this plugin's, and they stay as
24
+ * they are (Katerina, 15 September).
25
+ */
26
+ export declare const ESCAPE_MARKER = "@estiva-escape";
27
+ /** Characters of reason, spaces not counted. */
28
+ export declare const MIN_REASON = 10;
29
+ /** The key under ESLint's `settings` this plugin reads. */
30
+ export declare const SETTINGS_KEY = "estiva";
31
+ /**
32
+ * Every rule spreads these into its `meta.messages`, because `isEscaped`
33
+ * reports through the rule that called it.
34
+ */
35
+ 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.";
38
+ readonly escaped: "Escaped: {{reason}}";
39
+ };
40
+ export interface EstivaSettings {
41
+ /**
42
+ * Report every sanctioned escape as a message with the id `escaped`, so a
43
+ * count can read them. Off in the lint anyone runs; on only in the count.
44
+ */
45
+ reportEscapes?: boolean;
46
+ }
47
+ interface Located {
48
+ loc?: AST.SourceLocation | null;
49
+ range?: [number, number];
50
+ }
51
+ /**
52
+ * Whether the element at `node` carries a valid escape on the line above.
53
+ *
54
+ * Every rule of this plugin calls it before it reports. It reports, through
55
+ * that rule, a marker with too short a reason and a marker inside an
56
+ * eslint-disable directive for this rule; either way the element is not
57
+ * escaped, so the rule reports it too.
58
+ */
59
+ export declare function isEscaped(context: Rule.RuleContext, node: Located): boolean;
60
+ export {};
61
+ //# sourceMappingURL=escape.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,45 @@
1
+ import type { ESLint, Linter } from 'eslint';
2
+ export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape';
3
+ /** The name the configs register the plugin under, so every rule id is `estiva/<rule>`. */
4
+ export declare const PLUGIN_KEY = "estiva";
5
+ declare const plugin: {
6
+ meta: {
7
+ name: string;
8
+ version: string;
9
+ };
10
+ rules: {
11
+ 'no-raw-button': import("eslint").Rule.RuleModule;
12
+ };
13
+ configs: {
14
+ recommended: Linter.Config;
15
+ strict: Linter.Config;
16
+ };
17
+ };
18
+ export default plugin;
19
+ export interface GateRuleCount {
20
+ errors: number;
21
+ warnings: number;
22
+ escapes: number;
23
+ }
24
+ export interface GateCount {
25
+ /** Per rule id, including rules with nothing to report. */
26
+ rules: Record<string, GateRuleCount>;
27
+ /**
28
+ * Reports of these rules that an `eslint-disable` directive silenced. Not
29
+ * an escape: a gate fails on any of these (docs/GATES.md §16, S4).
30
+ */
31
+ disabled: {
32
+ filePath: string;
33
+ line: number;
34
+ ruleId: string;
35
+ }[];
36
+ }
37
+ /**
38
+ * Count what a lint of these rules found, for `.gates-count.json` (seam S3).
39
+ *
40
+ * Run the lint with `settings: { estiva: { reportEscapes: true } }` for the
41
+ * escapes to be counted; without it they are silent and count 0. A marker
42
+ * with no reason, or inside a directive, counts as an error of its rule.
43
+ */
44
+ export declare function countGates(results: ESLint.LintResult[]): GateCount;
45
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/eslint/index.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAG5C,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,YAAY,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,UAAU,CAAA;AAIlG,2FAA2F;AAC3F,eAAO,MAAM,UAAU,WAAW,CAAA;AAMlC,QAAA,MAAM,MAAM;;;;;;;;aAGK;QAAE,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAA;KAAE;CAC7C,CAAA;AAqBzB,eAAe,MAAM,CAAA;AAErB,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,SAAS;IACxB,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAA;IACpC;;;OAGG;IACH,QAAQ,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC/D;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,UAAU,EAAE,GAAG,SAAS,CAgBlE"}
@@ -0,0 +1,118 @@
1
+ // src/eslint/index.ts
2
+ import { createRequire } from "node:module";
3
+
4
+ // src/eslint/escape.ts
5
+ var ESCAPE_MARKER = "@estiva-escape";
6
+ var MIN_REASON = 10;
7
+ var SETTINGS_KEY = "estiva";
8
+ var ESCAPE_MESSAGES = {
9
+ escapeWithoutReason: `An escape needs its reason, at least ${MIN_REASON} characters: \`// ${ESCAPE_MARKER}: <why this stays>\`. An escape with no reason hides nothing.`,
10
+ 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.`,
11
+ escaped: "Escaped: {{reason}}"
12
+ };
13
+ var DIRECTIVE = /^\s*eslint-disable(?:-next-line|-line)?(?=\s|$)([^]*)$/;
14
+ function directiveRules(rest) {
15
+ const dashes = rest.search(/(?:^|\s)--(?:\s|$)/);
16
+ return (dashes === -1 ? rest : rest.slice(0, dashes)).split(/[\s,]+/).filter(Boolean);
17
+ }
18
+ var BETWEEN = /^[\s}]*$/;
19
+ function isEscaped(context, node) {
20
+ const { sourceCode } = context;
21
+ if (!node.loc || !node.range) return false;
22
+ const line = node.loc.start.line;
23
+ const nodeStart = node.range[0];
24
+ 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)));
25
+ if (!comment?.loc) return false;
26
+ const at = comment.value.indexOf(ESCAPE_MARKER);
27
+ if (at === -1) return false;
28
+ const directive = DIRECTIVE.exec(comment.value);
29
+ if (directive) {
30
+ const rules2 = directiveRules(directive[1]);
31
+ if (rules2.length > 0 && !rules2.includes(context.id)) return false;
32
+ context.report({ loc: comment.loc, messageId: "escapeInDirective" });
33
+ return false;
34
+ }
35
+ const reason = comment.value.slice(at + ESCAPE_MARKER.length).replace(/^:/, "").trim();
36
+ if (reason.replace(/\s/g, "").length < MIN_REASON) {
37
+ context.report({ loc: comment.loc, messageId: "escapeWithoutReason" });
38
+ return false;
39
+ }
40
+ const settings = context.settings[SETTINGS_KEY];
41
+ if (settings?.reportEscapes) context.report({ loc: comment.loc, messageId: "escaped", data: { reason } });
42
+ return true;
43
+ }
44
+
45
+ // src/eslint/no-raw-button.ts
46
+ var noRawButton = {
47
+ meta: {
48
+ type: "problem",
49
+ docs: { description: "A raw <button> where @estiva-app/ui has one" },
50
+ schema: [],
51
+ messages: {
52
+ raw: "Use `Button` from @estiva-app/ui instead of a raw <button>.",
53
+ ...ESCAPE_MESSAGES
54
+ }
55
+ },
56
+ create(context) {
57
+ return {
58
+ // ESLint's types know ESTree's nodes, not JSX's; the parser hands this one over.
59
+ JSXOpeningElement(node) {
60
+ const element = node;
61
+ if (element.name.type !== "JSXIdentifier" || element.name.name !== "button") return;
62
+ if (isEscaped(context, element)) return;
63
+ context.report({ loc: element.loc, messageId: "raw" });
64
+ }
65
+ };
66
+ }
67
+ };
68
+
69
+ // src/eslint/index.ts
70
+ var { version } = createRequire(import.meta.url)("../../package.json");
71
+ var PLUGIN_KEY = "estiva";
72
+ var rules = {
73
+ "no-raw-button": noRawButton
74
+ };
75
+ var plugin = {
76
+ meta: { name: "@estiva-app/ui/eslint", version },
77
+ rules,
78
+ configs: {}
79
+ };
80
+ var ruleIds = Object.keys(rules).map((name) => `${PLUGIN_KEY}/${name}`);
81
+ plugin.configs.recommended = {
82
+ name: "@estiva-app/ui/recommended",
83
+ plugins: { [PLUGIN_KEY]: plugin },
84
+ rules: { [`${PLUGIN_KEY}/no-raw-button`]: "error" }
85
+ };
86
+ plugin.configs.strict = {
87
+ name: "@estiva-app/ui/strict",
88
+ plugins: { [PLUGIN_KEY]: plugin },
89
+ rules: Object.fromEntries(ruleIds.map((id) => [id, "error"]))
90
+ };
91
+ var index_default = plugin;
92
+ function countGates(results) {
93
+ const counts = Object.fromEntries(ruleIds.map((id) => [id, { errors: 0, warnings: 0, escapes: 0 }]));
94
+ const disabled = [];
95
+ for (const result of results) {
96
+ for (const message of result.messages) {
97
+ const count = message.ruleId ? counts[message.ruleId] : void 0;
98
+ if (!count) continue;
99
+ if (message.messageId === "escaped") count.escapes += 1;
100
+ else if (message.severity === 2) count.errors += 1;
101
+ else count.warnings += 1;
102
+ }
103
+ for (const message of result.suppressedMessages ?? []) {
104
+ if (message.ruleId && counts[message.ruleId]) disabled.push({ filePath: result.filePath, line: message.line, ruleId: message.ruleId });
105
+ }
106
+ }
107
+ return { rules: counts, disabled };
108
+ }
109
+ export {
110
+ ESCAPE_MARKER,
111
+ MIN_REASON,
112
+ PLUGIN_KEY,
113
+ SETTINGS_KEY,
114
+ countGates,
115
+ index_default as default,
116
+ isEscaped
117
+ };
118
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/eslint/index.ts", "../../src/eslint/escape.ts", "../../src/eslint/no-raw-button.ts"],
4
+ "sourcesContent": ["/// <reference types=\"node\" />\n/**\n * `@estiva-app/ui/eslint` \u2014 the UI Guardrails' lint rules, as a plugin (UIG-3,\n * seam S1 in docs/GATES.md \u00A716).\n *\n * A plugin rather than config, so every app \u2014 Peek, Ship, Leaf \u2014 gets a new\n * rule through an ordinary version bump, and an app's editor hook runs the\n * very rule code its CI runs.\n *\n * import estiva from '@estiva-app/ui/eslint'\n * export default [{ files: ['src/**\\/*.tsx'], ...estiva.configs.recommended }]\n *\n * Register it under `estiva` (the configs do): the rule ids are\n * `estiva/<rule>`, and `countGates` counts those ids.\n *\n * Built on its own by build.mjs, for Node, into `dist/eslint/`; nothing here\n * reaches the components' browser bundle.\n */\nimport { createRequire } from 'node:module'\nimport type { ESLint, Linter } from 'eslint'\nimport { noRawButton } from './no-raw-button'\n\nexport { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'\n\nconst { version } = createRequire(import.meta.url)('../../package.json') as { version: string }\n\n/** The name the configs register the plugin under, so every rule id is `estiva/<rule>`. */\nexport const PLUGIN_KEY = 'estiva'\n\nconst rules = {\n 'no-raw-button': noRawButton,\n}\n\nconst plugin = {\n meta: { name: '@estiva-app/ui/eslint', version },\n rules,\n configs: {} as { recommended: Linter.Config; strict: Linter.Config },\n} satisfies ESLint.Plugin\n\nconst ruleIds = Object.keys(rules).map((name) => `${PLUGIN_KEY}/${name}`)\n\n/**\n * `recommended` switches every rule on at the level it was ruled at: an error\n * blocks, a warning is reported and never blocks. `strict` makes every rule an\n * error. With one rule, an error, the two are the same today; they part when\n * the first warning-level rule arrives (UIG-25).\n */\nplugin.configs.recommended = {\n name: '@estiva-app/ui/recommended',\n plugins: { [PLUGIN_KEY]: plugin },\n rules: { [`${PLUGIN_KEY}/no-raw-button`]: 'error' },\n}\nplugin.configs.strict = {\n name: '@estiva-app/ui/strict',\n plugins: { [PLUGIN_KEY]: plugin },\n rules: Object.fromEntries(ruleIds.map((id) => [id, 'error'])),\n}\n\nexport default plugin\n\nexport interface GateRuleCount {\n errors: number\n warnings: number\n escapes: number\n}\n\nexport interface GateCount {\n /** Per rule id, including rules with nothing to report. */\n rules: Record<string, GateRuleCount>\n /**\n * Reports of these rules that an `eslint-disable` directive silenced. Not\n * an escape: a gate fails on any of these (docs/GATES.md \u00A716, S4).\n */\n disabled: { filePath: string; line: number; ruleId: string }[]\n}\n\n/**\n * Count what a lint of these rules found, for `.gates-count.json` (seam S3).\n *\n * Run the lint with `settings: { estiva: { reportEscapes: true } }` for the\n * escapes to be counted; without it they are silent and count 0. A marker\n * with no reason, or inside a directive, counts as an error of its rule.\n */\nexport function countGates(results: ESLint.LintResult[]): GateCount {\n const counts: Record<string, GateRuleCount> = Object.fromEntries(ruleIds.map((id) => [id, { errors: 0, warnings: 0, escapes: 0 }]))\n const disabled: GateCount['disabled'] = []\n for (const result of results) {\n for (const message of result.messages) {\n const count = message.ruleId ? counts[message.ruleId] : undefined\n if (!count) continue\n if (message.messageId === 'escaped') count.escapes += 1\n else if (message.severity === 2) count.errors += 1\n else count.warnings += 1\n }\n for (const message of result.suppressedMessages ?? []) {\n if (message.ruleId && counts[message.ruleId]) disabled.push({ filePath: result.filePath, line: message.line, ruleId: message.ruleId })\n }\n }\n return { rules: counts, disabled }\n}\n", "import type { AST, Rule } from 'eslint'\n\n/**\n * The escape marker (UIG-3, seam S4 in docs/GATES.md \u00A716): one written reason\n * why one element stays as it is.\n *\n * A line comment directly above the element,\n *\n * // @estiva-escape: <reason>\n *\n * or, where the element is a JSX child, the JSX comment on the line above,\n *\n * {/* @estiva-escape: <reason> *\\/}\n *\n * with at least ten characters of reason, spaces not counted.\n *\n * Not `eslint-disable`. A directive switches a rule off without saying it was\n * meant, and the count of escapes (`.gates-count.json`) could not see it. A\n * marker written inside a directive that switches one of these rules off is\n * reported for that reason.\n *\n * The token lint's older notes (`eslint-disable-next-line <rule> -- @estiva-escape:\n * <reason>`, Katerina's ruling A2 of 15 September) are not read here: they\n * escape the token lint's rules, which are not this plugin's, and they stay as\n * they are (Katerina, 15 September).\n */\nexport const ESCAPE_MARKER = '@estiva-escape'\n\n/** Characters of reason, spaces not counted. */\nexport const MIN_REASON = 10\n\n/** The key under ESLint's `settings` this plugin reads. */\nexport const SETTINGS_KEY = 'estiva'\n\n/**\n * Every rule spreads these into its `meta.messages`, because `isEscaped`\n * reports through the rule that called it.\n */\nexport const ESCAPE_MESSAGES = {\n escapeWithoutReason: `An escape needs its reason, at least ${MIN_REASON} characters: \\`// ${ESCAPE_MARKER}: <why this stays>\\`. An escape with no reason hides nothing.`,\n 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.`,\n escaped: 'Escaped: {{reason}}',\n} as const\n\nexport interface EstivaSettings {\n /**\n * Report every sanctioned escape as a message with the id `escaped`, so a\n * count can read them. Off in the lint anyone runs; on only in the count.\n */\n reportEscapes?: boolean\n}\n\ninterface Located {\n loc?: AST.SourceLocation | null\n range?: [number, number]\n}\n\nconst DIRECTIVE = /^\\s*eslint-disable(?:-next-line|-line)?(?=\\s|$)([^]*)$/\n\n/** The rules a directive names: what comes before its ` -- ` description. None means every rule. */\nfunction directiveRules(rest: string): string[] {\n const dashes = rest.search(/(?:^|\\s)--(?:\\s|$)/)\n return (dashes === -1 ? rest : rest.slice(0, dashes)).split(/[\\s,]+/).filter(Boolean)\n}\n\n/** Only spaces and a JSX expression's closing brace between the marker and the element. */\nconst BETWEEN = /^[\\s}]*$/\n\n/**\n * Whether the element at `node` carries a valid escape on the line above.\n *\n * Every rule of this plugin calls it before it reports. It reports, through\n * that rule, a marker with too short a reason and a marker inside an\n * eslint-disable directive for this rule; either way the element is not\n * escaped, so the rule reports it too.\n */\nexport function isEscaped(context: Rule.RuleContext, node: Located): boolean {\n const { sourceCode } = context\n if (!node.loc || !node.range) return false\n const line = node.loc.start.line\n const nodeStart = node.range[0]\n\n const comment = sourceCode\n .getAllComments()\n .find((c) => c.loc && c.range && c.loc.end.line === line - 1 && BETWEEN.test(sourceCode.text.slice(c.range[1], nodeStart)))\n if (!comment?.loc) return false\n\n const at = comment.value.indexOf(ESCAPE_MARKER)\n if (at === -1) return false\n\n const directive = DIRECTIVE.exec(comment.value)\n if (directive) {\n const rules = directiveRules(directive[1])\n // A directive for other rules (the token lint's notes) is not an escape of this one.\n if (rules.length > 0 && !rules.includes(context.id)) return false\n context.report({ loc: comment.loc, messageId: 'escapeInDirective' })\n return false\n }\n\n const reason = comment.value\n .slice(at + ESCAPE_MARKER.length)\n .replace(/^:/, '')\n .trim()\n if (reason.replace(/\\s/g, '').length < MIN_REASON) {\n context.report({ loc: comment.loc, messageId: 'escapeWithoutReason' })\n return false\n }\n\n const settings = context.settings[SETTINGS_KEY] as EstivaSettings | undefined\n if (settings?.reportEscapes) context.report({ loc: comment.loc, messageId: 'escaped', data: { reason } })\n return true\n}\n", "import type { Rule } from 'eslint'\nimport { ESCAPE_MESSAGES, isEscaped } from './escape'\n\ninterface JSXOpeningElement {\n name: { type: string; name?: string }\n loc: NonNullable<Rule.Node['loc']>\n range: [number, number]\n}\n\n/**\n * A raw `<button>` in an app (UIG-3, the tracer). The package's `Button`,\n * `IconButton`, `MenuItem` and chips are buttons already; an app that writes\n * its own has a look and a behaviour nobody else keeps in step with.\n *\n * Only the JSX element named `button`. `<Button>`, `<Foo.button>` and\n * `createElement('button')` are not this rule's business.\n */\nexport const noRawButton: Rule.RuleModule = {\n meta: {\n type: 'problem',\n docs: { description: 'A raw <button> where @estiva-app/ui has one' },\n schema: [],\n messages: {\n raw: 'Use `Button` from @estiva-app/ui instead of a raw <button>.',\n ...ESCAPE_MESSAGES,\n },\n },\n create(context) {\n return {\n // ESLint's types know ESTree's nodes, not JSX's; the parser hands this one over.\n JSXOpeningElement(node: Rule.Node) {\n const element = node as unknown as JSXOpeningElement\n if (element.name.type !== 'JSXIdentifier' || element.name.name !== 'button') return\n if (isEscaped(context, element)) return\n context.report({ loc: element.loc, messageId: 'raw' })\n },\n }\n },\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["rules"]
7
+ }
@@ -0,0 +1,11 @@
1
+ import type { Rule } from 'eslint';
2
+ /**
3
+ * A raw `<button>` in an app (UIG-3, the tracer). The package's `Button`,
4
+ * `IconButton`, `MenuItem` and chips are buttons already; an app that writes
5
+ * its own has a look and a behaviour nobody else keeps in step with.
6
+ *
7
+ * Only the JSX element named `button`. `<Button>`, `<Foo.button>` and
8
+ * `createElement('button')` are not this rule's business.
9
+ */
10
+ export declare const noRawButton: Rule.RuleModule;
11
+ //# sourceMappingURL=no-raw-button.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"no-raw-button.d.ts","sourceRoot":"","sources":["../../src/eslint/no-raw-button.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AASlC;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,EAAE,IAAI,CAAC,UAqB9B,CAAA"}
package/dist/index.js CHANGED
@@ -604,7 +604,7 @@ function Avatar({ src, name, alt = "", size = 36, label: spoken, className }) {
604
604
  render: /* @__PURE__ */ jsx10("div", {}),
605
605
  className: "w-full h-full flex items-center justify-center font-semibold leading-none",
606
606
  style: {
607
- /* eslint-disable no-restricted-syntax -- the per-person palette (the note at
607
+ /* eslint-disable no-restricted-syntax -- @estiva-escape: the per-person palette (the note at
608
608
  the top): eight hues picked from the name, the one ink that reads on all
609
609
  of them, and a size that follows `size`. None of it can be a token. */
610
610
  // The one ink colour that reads on all eight hues, which are a
@@ -1043,10 +1043,10 @@ var CHIP_BOX = (
1043
1043
  var CHIP_LABEL = "text-caption font-medium text-text-primary";
1044
1044
  var CHIP_REMOVE = "size-4 flex items-center justify-center rounded-full hover:bg-bg-hover text-text-secondary";
1045
1045
  var chipPadding = (leading, removable) => cn(leading ? "pl-[2px]" : "pl-2", removable ? "pr-1" : "pr-2");
1046
- function InputChip({ label, leading, onRemove, className }) {
1046
+ function InputChip({ label, leading, onRemove, removeLabel, truncate, className }) {
1047
1047
  return /* @__PURE__ */ jsxs11("div", { className: cn(CHIP_BOX, chipPadding(!!leading, !!onRemove), className), children: [
1048
1048
  leading && /* @__PURE__ */ jsx18("span", { className: "flex shrink-0 items-center", children: leading }),
1049
- /* @__PURE__ */ jsx18("span", { className: CHIP_LABEL, children: label }),
1049
+ /* @__PURE__ */ jsx18("span", { className: cn(CHIP_LABEL, truncate && "min-w-0 truncate"), children: label }),
1050
1050
  onRemove && /* @__PURE__ */ jsx18(
1051
1051
  BaseButton4,
1052
1052
  {
@@ -1055,8 +1055,8 @@ function InputChip({ label, leading, onRemove, className }) {
1055
1055
  e.stopPropagation();
1056
1056
  onRemove();
1057
1057
  },
1058
- className: CHIP_REMOVE,
1059
- "aria-label": `Remove ${label}`,
1058
+ className: cn(CHIP_REMOVE, truncate && "shrink-0"),
1059
+ "aria-label": removeLabel ?? `Remove ${label}`,
1060
1060
  children: /* @__PURE__ */ jsx18(IconX3, { size: 10, stroke: 1.5 })
1061
1061
  }
1062
1062
  )