@grampro/kit 2.4.4 → 2.5.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 (72) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/index.cjs +39 -4
  3. package/package.json +1 -1
  4. package/source/components/accordion/passport.json +11 -7
  5. package/source/components/accordion/react/Accordion.tsx +5 -0
  6. package/source/components/alert/passport.json +3 -3
  7. package/source/components/avatar/passport.json +8 -6
  8. package/source/components/avatar/react/Avatar.tsx +4 -0
  9. package/source/components/badge/passport.json +8 -6
  10. package/source/components/badge/react/Badge.tsx +1 -0
  11. package/source/components/badge/react/Tag.tsx +1 -0
  12. package/source/components/breadcrumb/passport.json +8 -6
  13. package/source/components/breadcrumb/react/Breadcrumb.tsx +1 -0
  14. package/source/components/button/passport.json +8 -6
  15. package/source/components/button/react/Button.tsx +1 -0
  16. package/source/components/card/passport.json +17 -9
  17. package/source/components/card/react/Card.tsx +5 -0
  18. package/source/components/card/react/Stat.tsx +5 -0
  19. package/source/components/checkbox/passport.json +32 -14
  20. package/source/components/checkbox/react/Checkbox.tsx +17 -0
  21. package/source/components/checkbox/react/CheckboxGroup.tsx +6 -0
  22. package/source/components/combobox/passport.json +36 -16
  23. package/source/components/combobox/react/Select.tsx +6 -1
  24. package/source/components/combobox/react/props.ts +23 -0
  25. package/source/components/data-grid/core/types.ts +53 -0
  26. package/source/components/data-grid/passport.json +82 -30
  27. package/source/components/data-grid/react/AskGrid.tsx +10 -0
  28. package/source/components/data-grid/react/DataGrid.tsx +16 -0
  29. package/source/components/date-picker/passport.json +30 -14
  30. package/source/components/date-picker/react/DatePicker.tsx +5 -0
  31. package/source/components/date-picker/react/props.ts +13 -1
  32. package/source/components/dialog/core/types.ts +14 -0
  33. package/source/components/dialog/passport.json +26 -12
  34. package/source/components/dialog/react/Dialog.tsx +4 -0
  35. package/source/components/file-uploader/passport.json +44 -18
  36. package/source/components/file-uploader/react/FileUploader.tsx +39 -0
  37. package/source/components/input/passport.json +14 -8
  38. package/source/components/input/react/Input.tsx +3 -0
  39. package/source/components/menu/passport.json +29 -13
  40. package/source/components/menu/react/Menu.tsx +7 -0
  41. package/source/components/menu/react/items.tsx +4 -0
  42. package/source/components/modal/passport.json +14 -8
  43. package/source/components/modal/react/Modal.tsx +6 -0
  44. package/source/components/number-input/passport.json +23 -11
  45. package/source/components/number-input/react/NumberInput.tsx +13 -0
  46. package/source/components/popover/passport.json +17 -9
  47. package/source/components/popover/react/Popover.tsx +7 -0
  48. package/source/components/progress/passport.json +3 -3
  49. package/source/components/radio-group/passport.json +32 -14
  50. package/source/components/radio-group/react/Radio.tsx +17 -0
  51. package/source/components/radio-group/react/RadioGroup.tsx +13 -0
  52. package/source/components/skeleton/passport.json +8 -6
  53. package/source/components/skeleton/react/Skeleton.tsx +5 -0
  54. package/source/components/spinner/passport.json +3 -3
  55. package/source/components/switch/passport.json +23 -11
  56. package/source/components/switch/react/Switch.tsx +14 -0
  57. package/source/components/tabs/passport.json +11 -7
  58. package/source/components/tabs/react/Tabs.tsx +2 -0
  59. package/source/components/textarea/passport.json +14 -8
  60. package/source/components/textarea/react/Textarea.tsx +3 -0
  61. package/source/components/toaster/passport.json +8 -6
  62. package/source/components/toaster/react/Toaster.tsx +4 -0
  63. package/source/components/tooltip/passport.json +3 -3
  64. package/tools/mcp/README.md +126 -34
  65. package/tools/mcp/figma.cjs +84 -27
  66. package/tools/mcp/install.cjs +270 -0
  67. package/tools/mcp/secret-cli.cjs +174 -0
  68. package/tools/mcp/secret.cjs +289 -0
  69. package/tools/mcp/server.cjs +53 -14
  70. package/tools/passport/cli.cjs +8 -1
  71. package/tools/passport/extract.cjs +7 -3
  72. package/tools/passport/merge.cjs +24 -4
@@ -0,0 +1,289 @@
1
+ /*
2
+ * Credentials, kept by the operating system instead of by the repository.
3
+ *
4
+ * The Figma token used to live in a `.env`, which meant a long-lived
5
+ * credential sitting in a file inside a git working tree — one `git add -A`
6
+ * away from being published. This moves it to whatever the platform already
7
+ * uses for secrets: the Windows Data Protection API, the macOS keychain, or
8
+ * the Secret Service on Linux. Each of those ties the ciphertext to the
9
+ * logged-in user, so a copied file is useless on another machine.
10
+ *
11
+ * Nothing here adds a dependency. Every backend is a command the OS ships
12
+ * with, and the fallback is a 0600 file — worse, but still outside the repo,
13
+ * and it says so rather than pretending otherwise.
14
+ */
15
+ "use strict";
16
+
17
+ const fs = require("fs");
18
+ const os = require("os");
19
+ const path = require("path");
20
+ const { spawnSync } = require("child_process");
21
+
22
+ /** Namespace for keychain/Secret-Service entries. */
23
+ const SERVICE = "grampro-kit";
24
+
25
+ /** Per-user config directory, following each platform's own convention. */
26
+ function configDir() {
27
+ if (process.platform === "win32") {
28
+ return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), SERVICE);
29
+ }
30
+ if (process.platform === "darwin") {
31
+ return path.join(os.homedir(), "Library", "Application Support", SERVICE);
32
+ }
33
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), SERVICE);
34
+ }
35
+
36
+ /** True when a command exists and runs. */
37
+ function available(command, args) {
38
+ try {
39
+ const probe = spawnSync(command, args, { stdio: "ignore", windowsHide: true });
40
+ return !probe.error;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ // --------------------------------------------------------------- windows
47
+
48
+ /*
49
+ * DPAPI, reached through PowerShell because Node has no binding for it.
50
+ *
51
+ * The plaintext goes in over stdin, never as an argument — command lines are
52
+ * readable by other processes on the machine.
53
+ *
54
+ * The script is passed with `-Command` rather than `-EncodedCommand`, which
55
+ * accepts the base64 and then silently runs nothing on at least one shipped
56
+ * 5.1 build (5.1.22621): exit 0, no output, no error. Statements are joined
57
+ * with semicolons for that reason, and quoting is kept to single quotes so
58
+ * nothing needs escaping between here and the shell.
59
+ */
60
+ function powershell(script, input) {
61
+ const run = spawnSync(
62
+ "powershell.exe",
63
+ ["-NoProfile", "-NonInteractive", "-Command", script],
64
+ { input: input === undefined ? "" : input, encoding: "utf8", windowsHide: true },
65
+ );
66
+ if (run.error) throw run.error;
67
+ if (run.status !== 0) {
68
+ throw new Error((run.stderr || "PowerShell failed").trim().split("\n")[0]);
69
+ }
70
+ return run.stdout;
71
+ }
72
+
73
+ const DPAPI_PROTECT = [
74
+ "Add-Type -AssemblyName System.Security",
75
+ "$plain = [Console]::In.ReadToEnd()",
76
+ "$bytes = [System.Text.Encoding]::UTF8.GetBytes($plain)",
77
+ "[Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, 'CurrentUser'))",
78
+ ].join("; ");
79
+
80
+ const DPAPI_UNPROTECT = [
81
+ "Add-Type -AssemblyName System.Security",
82
+ "$sealed = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim())",
83
+ "[System.Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($sealed, $null, 'CurrentUser'))",
84
+ ].join("; ");
85
+
86
+ const dpapiFile = (name) => path.join(configDir(), "secrets", `${name}.dpapi`);
87
+
88
+ const dpapi = {
89
+ label: "Windows Data Protection API (tied to your Windows account)",
90
+ set(name, value) {
91
+ // PowerShell may fold a long string across lines when there is no console
92
+ // to measure, and base64 does not survive that. Whitespace is dropped on
93
+ // the way in and on the way out.
94
+ const sealed = powershell(DPAPI_PROTECT, value).replace(/\s+/g, "");
95
+ if (!sealed) throw new Error("DPAPI returned nothing; the secret was not stored.");
96
+ const file = dpapiFile(name);
97
+ fs.mkdirSync(path.dirname(file), { recursive: true });
98
+ fs.writeFileSync(file, sealed, { encoding: "utf8", mode: 0o600 });
99
+ },
100
+ get(name) {
101
+ const file = dpapiFile(name);
102
+ if (!fs.existsSync(file)) return null;
103
+ // A file carried over from another account will not decrypt; that is the
104
+ // protection working, so it reads as absent rather than as an error.
105
+ try {
106
+ const sealed = fs.readFileSync(file, "utf8").replace(/\s+/g, "");
107
+ return powershell(DPAPI_UNPROTECT, sealed).trim() || null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ },
112
+ remove(name) {
113
+ const file = dpapiFile(name);
114
+ if (!fs.existsSync(file)) return false;
115
+ fs.unlinkSync(file);
116
+ return true;
117
+ },
118
+ list() {
119
+ const dir = path.join(configDir(), "secrets");
120
+ if (!fs.existsSync(dir)) return [];
121
+ return fs.readdirSync(dir).filter((f) => f.endsWith(".dpapi")).map((f) => f.slice(0, -6));
122
+ },
123
+ };
124
+
125
+ // ----------------------------------------------------------------- macos
126
+
127
+ /*
128
+ * The keychain. `security` takes the password as an argument, which is
129
+ * visible in `ps` for the moment the command runs — a real if narrow
130
+ * exposure, and the alternative is an interactive prompt that cannot work
131
+ * from a tool. Any process able to read that argument is already running as
132
+ * this user and could ask the keychain directly.
133
+ */
134
+ const keychain = {
135
+ label: "macOS keychain",
136
+ set(name, value) {
137
+ const run = spawnSync(
138
+ "security",
139
+ ["add-generic-password", "-U", "-a", os.userInfo().username, "-s", `${SERVICE}.${name}`,
140
+ "-D", "api token", "-j", "GramproKit", "-w", value],
141
+ { encoding: "utf8" },
142
+ );
143
+ if (run.status !== 0) throw new Error((run.stderr || "security failed").trim());
144
+ },
145
+ get(name) {
146
+ const run = spawnSync(
147
+ "security",
148
+ ["find-generic-password", "-a", os.userInfo().username, "-s", `${SERVICE}.${name}`, "-w"],
149
+ { encoding: "utf8" },
150
+ );
151
+ return run.status === 0 ? run.stdout.trim() || null : null;
152
+ },
153
+ remove(name) {
154
+ const run = spawnSync(
155
+ "security",
156
+ ["delete-generic-password", "-a", os.userInfo().username, "-s", `${SERVICE}.${name}`],
157
+ { encoding: "utf8", stdio: "ignore" },
158
+ );
159
+ return run.status === 0;
160
+ },
161
+ /* The keychain has no cheap listing by service, so the known names are probed. */
162
+ list() {
163
+ return KNOWN.filter((name) => keychain.get(name) !== null);
164
+ },
165
+ };
166
+
167
+ // ----------------------------------------------------------------- linux
168
+
169
+ /** libsecret's CLI, which every desktop keyring implements. */
170
+ const secretTool = {
171
+ label: "Secret Service (GNOME Keyring, KWallet)",
172
+ set(name, value) {
173
+ const run = spawnSync(
174
+ "secret-tool",
175
+ ["store", "--label", `GramproKit ${name}`, "service", SERVICE, "account", name],
176
+ { input: value, encoding: "utf8" },
177
+ );
178
+ if (run.status !== 0) throw new Error((run.stderr || "secret-tool failed").trim());
179
+ },
180
+ get(name) {
181
+ const run = spawnSync("secret-tool", ["lookup", "service", SERVICE, "account", name], {
182
+ encoding: "utf8",
183
+ });
184
+ return run.status === 0 ? run.stdout.trim() || null : null;
185
+ },
186
+ remove(name) {
187
+ const run = spawnSync("secret-tool", ["clear", "service", SERVICE, "account", name], {
188
+ encoding: "utf8",
189
+ stdio: "ignore",
190
+ });
191
+ return run.status === 0;
192
+ },
193
+ list() {
194
+ return KNOWN.filter((name) => secretTool.get(name) !== null);
195
+ },
196
+ };
197
+
198
+ // -------------------------------------------------------------- fallback
199
+
200
+ /*
201
+ * A 0600 file under the user's config directory. Not encrypted, and it says
202
+ * so when used — but it is outside the repository, which is the failure this
203
+ * whole module exists to prevent.
204
+ */
205
+ const plainFile = {
206
+ label: "a 0600 file in your config directory (not encrypted)",
207
+ file: () => path.join(configDir(), "secrets.json"),
208
+ read() {
209
+ try {
210
+ return JSON.parse(fs.readFileSync(plainFile.file(), "utf8"));
211
+ } catch {
212
+ return {};
213
+ }
214
+ },
215
+ write(all) {
216
+ fs.mkdirSync(configDir(), { recursive: true });
217
+ fs.writeFileSync(plainFile.file(), `${JSON.stringify(all, null, 2)}\n`, {
218
+ encoding: "utf8",
219
+ mode: 0o600,
220
+ });
221
+ },
222
+ set(name, value) {
223
+ const all = plainFile.read();
224
+ all[name] = value;
225
+ plainFile.write(all);
226
+ },
227
+ get(name) {
228
+ return plainFile.read()[name] || null;
229
+ },
230
+ remove(name) {
231
+ const all = plainFile.read();
232
+ if (!(name in all)) return false;
233
+ delete all[name];
234
+ plainFile.write(all);
235
+ return true;
236
+ },
237
+ list() {
238
+ return Object.keys(plainFile.read());
239
+ },
240
+ };
241
+
242
+ /** Secret names this package uses, for the backends that cannot enumerate. */
243
+ const KNOWN = ["figma"];
244
+
245
+ /** The best store this machine offers. Resolved once. */
246
+ let chosen = null;
247
+ function store() {
248
+ if (chosen) return chosen;
249
+ if (process.platform === "win32" && available("powershell.exe", ["-NoProfile", "-Command", "exit 0"])) {
250
+ chosen = dpapi;
251
+ } else if (process.platform === "darwin" && available("security", ["-h"])) {
252
+ chosen = keychain;
253
+ } else if (process.platform === "linux" && available("secret-tool", ["--version"])) {
254
+ chosen = secretTool;
255
+ } else {
256
+ chosen = plainFile;
257
+ }
258
+ return chosen;
259
+ }
260
+
261
+ const setSecret = (name, value) => store().set(name, value);
262
+ const removeSecret = (name) => store().remove(name);
263
+ const listSecrets = () => store().list();
264
+ const describeStore = () => store().label;
265
+ const isEncrypted = () => store() !== plainFile;
266
+
267
+ /**
268
+ * A secret, if this machine holds one. Never throws: a missing or unreadable
269
+ * store is the same answer as an empty one, because every caller has another
270
+ * place to look.
271
+ */
272
+ function getSecret(name) {
273
+ try {
274
+ return store().get(name);
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ module.exports = {
281
+ setSecret,
282
+ getSecret,
283
+ removeSecret,
284
+ listSecrets,
285
+ describeStore,
286
+ isEncrypted,
287
+ configDir,
288
+ KNOWN,
289
+ };
@@ -36,32 +36,69 @@ const SUPPORTED_PROTOCOLS = new Set(["2024-11-05", "2025-03-26", "2025-06-18", "
36
36
 
37
37
  const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
38
38
 
39
- const componentFolders = () =>
40
- fs
39
+ /*
40
+ * The catalogue, read once.
41
+ *
42
+ * Every lookup used to re-read and re-parse passports off disk: naming one
43
+ * component scanned until it matched, so `get_component("DataGrid")` cost ten
44
+ * file reads, and `validate_usage` parsed all twenty-seven on every call. The
45
+ * catalogue ships inside the package and cannot change while the server runs,
46
+ * so it is built once on first use and kept.
47
+ */
48
+ let index = null;
49
+
50
+ function catalogue() {
51
+ if (index) return index;
52
+
53
+ const folders = fs
41
54
  .readdirSync(CATALOGUE, { withFileTypes: true })
42
55
  .filter((e) => e.isDirectory() && fs.existsSync(path.join(CATALOGUE, e.name, "passport.json")))
43
56
  .map((e) => e.name)
44
57
  .sort();
45
58
 
46
- /** Resolve a name the way an agent would write it: "DataGrid", "data-grid", "datagrid". */
47
- function findFolder(name) {
48
- const wanted = String(name || "").toLowerCase().replace(/[^a-z0-9]/g, "");
49
- for (const folder of componentFolders()) {
50
- if (folder.replace(/[^a-z0-9]/g, "") === wanted) return folder;
59
+ const byFolder = new Map();
60
+ const byName = new Map();
61
+
62
+ for (const folder of folders) {
63
+ // The one place that still touches disk — this is what builds the index.
51
64
  const passport = readJson(path.join(CATALOGUE, folder, "passport.json"));
52
- const names = [passport.identity.component, ...(passport.identity.exports || [])];
53
- if (names.some((n) => String(n).toLowerCase() === wanted)) return folder;
65
+ const entry = { folder, passport };
66
+ byFolder.set(folder, entry);
67
+
68
+ // Every spelling an agent might reasonably use.
69
+ const spellings = [folder, passport.identity.component, ...(passport.identity.exports || [])];
70
+ for (const spelling of spellings) {
71
+ byName.set(String(spelling).toLowerCase().replace(/[^a-z0-9]/g, ""), entry);
72
+ }
54
73
  }
55
- return null;
74
+
75
+ index = { folders, byFolder, byName };
76
+ return index;
77
+ }
78
+
79
+ const componentFolders = () => catalogue().folders;
80
+
81
+ /** Resolve a name the way an agent would write it: "DataGrid", "data-grid", "datagrid". */
82
+ function findFolder(name) {
83
+ const entry = catalogue().byName.get(
84
+ String(name || "").toLowerCase().replace(/[^a-z0-9]/g, ""),
85
+ );
86
+ return entry ? entry.folder : null;
56
87
  }
57
88
 
89
+ /** The parsed passport for a folder, from the index rather than from disk. */
90
+ const passportOf = (folder) => catalogue().byFolder.get(folder).passport;
91
+
58
92
  /*
59
93
  * Most passports carry no `purpose`, so the README's opening line stands in.
60
94
  * It is the sentence a human wrote about what the component is for, which is
61
95
  * exactly what an agent needs to pick one.
62
96
  */
97
+ const purposeCache = new Map();
98
+
63
99
  function purposeOf(folder, passport) {
64
100
  if (passport.purpose && passport.purpose.trim()) return passport.purpose.trim();
101
+ if (purposeCache.has(folder)) return purposeCache.get(folder);
65
102
  const readme = path.join(CATALOGUE, folder, "README.md");
66
103
  if (!fs.existsSync(readme)) return "";
67
104
  const line = fs
@@ -69,14 +106,16 @@ function purposeOf(folder, passport) {
69
106
  .split("\n")
70
107
  .map((l) => l.trim())
71
108
  .find((l) => l && !l.startsWith("#") && !l.startsWith("<!--"));
72
- return line ? line.replace(/\s+/g, " ") : "";
109
+ const purpose = line ? line.replace(/\s+/g, " ") : "";
110
+ purposeCache.set(folder, purpose);
111
+ return purpose;
73
112
  }
74
113
 
75
114
  // -------------------------------------------------------------------- tools
76
115
 
77
116
  function listComponents() {
78
117
  return componentFolders().map((folder) => {
79
- const passport = readJson(path.join(CATALOGUE, folder, "passport.json"));
118
+ const passport = passportOf(folder);
80
119
  return {
81
120
  component: passport.identity.component,
82
121
  folder,
@@ -98,7 +137,7 @@ function getComponent(name) {
98
137
  if (!folder) {
99
138
  return { error: `No component named "${name}". Call list_components for the catalogue.` };
100
139
  }
101
- const p = readJson(path.join(CATALOGUE, folder, "passport.json"));
140
+ const p = passportOf(folder);
102
141
  return {
103
142
  component: p.identity.component,
104
143
  folder,
@@ -163,7 +202,7 @@ function validateUsage(code) {
163
202
  const source = String(code || "");
164
203
  const known = new Map();
165
204
  for (const folder of componentFolders()) {
166
- const p = readJson(path.join(CATALOGUE, folder, "passport.json"));
205
+ const p = passportOf(folder);
167
206
  const props = new Set((p.props || []).map((x) => x.name));
168
207
  for (const e of p.events || []) props.add(e.name);
169
208
  const required = (p.props || []).filter((x) => x.required).map((x) => x.name);
@@ -15,7 +15,14 @@ const { generate, check } = require("./index.cjs");
15
15
 
16
16
  const REPO = path.resolve(__dirname, "..", "..");
17
17
  const LIB = path.join(REPO, "source", "components");
18
- const COVERAGE_THRESHOLD = 40; // ratchet upward over time
18
+ /*
19
+ * Ratchet upward over time. 40 was set when coverage counted every inherited
20
+ * HTML attribute in the denominator, which capped some components near 35% and
21
+ * made the gate unreachable by doing the work it asked for. Now that it
22
+ * measures the props this library authors, the library sits at 87% and the
23
+ * weakest component at 75, so this is the floor rather than an aspiration.
24
+ */
25
+ const COVERAGE_THRESHOLD = 75;
19
26
 
20
27
  const args = process.argv.slice(2);
21
28
  const wantCheck = args.includes("--check");
@@ -16,6 +16,7 @@ const fs = require("fs");
16
16
  const path = require("path");
17
17
  const crypto = require("crypto");
18
18
  const { allowlistFor } = require("./policy.cjs");
19
+ const { describable } = require("./merge.cjs");
19
20
 
20
21
  const PASSPORT_VERSION = "1.0.0";
21
22
 
@@ -435,7 +436,10 @@ function extractComponent(ts, program, checker, { name, dir, libRoot, libraryVer
435
436
  .filter((p) => /^on[A-Z]/.test(p.name))
436
437
  .map((p) => ({ name: p.name, ...(p.signature ? { signature: p.signature } : {}) }));
437
438
 
438
- const described = propList.filter((p) => p.description).length;
439
+ // Same rule as the merge step, so the two never disagree: coverage is over
440
+ // the props this library declares, not the HTML attributes it inherits.
441
+ const countable = propList.filter(describable);
442
+ const described = countable.filter((p) => p.description).length;
439
443
 
440
444
  const passport = {
441
445
  passportVersion: PASSPORT_VERSION,
@@ -463,9 +467,9 @@ function extractComponent(ts, program, checker, { name, dir, libRoot, libraryVer
463
467
  safeMutations: {},
464
468
  examples: [],
465
469
  coverage: {
466
- propsTotal: propList.length,
470
+ propsTotal: countable.length,
467
471
  propsDescribed: described,
468
- describedPct: propList.length ? Math.round((described / propList.length) * 100) : 0,
472
+ describedPct: countable.length ? Math.round((described / countable.length) * 100) : 0,
469
473
  authoredSections: [],
470
474
  },
471
475
  };
@@ -72,6 +72,25 @@ function mergeProps(derived, overlayProps, origin) {
72
72
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
73
73
  }
74
74
 
75
+ /*
76
+ * Props whose meaning is the same on every component in the library, and so
77
+ * belong in one page of conventions rather than in 27 passports. A one-line
78
+ * gloss on each would be filler, and filler in a passport is worse than
79
+ * silence: an agent reads it as information.
80
+ */
81
+ const PLUMBING = new Set(["className", "classNames", "style", "ref", "key", "localeText"]);
82
+
83
+ /**
84
+ * Whether a prop counts towards description coverage.
85
+ *
86
+ * Only what this library declares itself. An inherited prop is `aria-label`
87
+ * or `tabIndex` arriving through `extends ButtonHTMLAttributes` - documented
88
+ * by the HTML specification, and not ours to restate. Counting them put the
89
+ * reachable ceiling for some components near 35%, so the gate asked for work
90
+ * that could not move it.
91
+ */
92
+ const describable = (prop) => prop.origin === "derived" && !PLUMBING.has(prop.name);
93
+
75
94
  /**
76
95
  * Produce the effective passport written to passport.json.
77
96
  *
@@ -112,20 +131,21 @@ function mergePassport(derived, manual = {}, local = {}) {
112
131
  }
113
132
 
114
133
  // Coverage is recomputed after overlays, since manual descriptions count.
115
- const described = out.props.filter((p) => p.description).length;
134
+ const counted = out.props.filter(describable);
135
+ const described = counted.filter((p) => p.description).length;
116
136
  const authoredSections = [...AUTHORED].filter((k) => {
117
137
  const v = out[k];
118
138
  return Array.isArray(v) ? v.length > 0 : isObject(v) ? Object.keys(v).length > 0 : Boolean(v);
119
139
  }).sort();
120
140
 
121
141
  out.coverage = {
122
- propsTotal: out.props.length,
142
+ propsTotal: counted.length,
123
143
  propsDescribed: described,
124
- describedPct: out.props.length ? Math.round((described / out.props.length) * 100) : 0,
144
+ describedPct: counted.length ? Math.round((described / counted.length) * 100) : 0,
125
145
  authoredSections,
126
146
  };
127
147
 
128
148
  return out;
129
149
  }
130
150
 
131
- module.exports = { mergePassport, deepMerge, mergeProps, SOURCE_OWNED, AUTHORED, UNSAFE_KEYS };
151
+ module.exports = { mergePassport, deepMerge, mergeProps, describable, SOURCE_OWNED, AUTHORED, UNSAFE_KEYS };