@teamofsilicons/silicon-ui 0.1.0 → 0.2.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 +18 -0
- package/check.mjs +236 -0
- package/eslint.js +53 -0
- package/package.json +12 -2
package/README.md
CHANGED
|
@@ -59,6 +59,24 @@ import { SiliconProvider } from "@teamofsilicons/silicon-ui/provider";
|
|
|
59
59
|
|
|
60
60
|
`@teamofsilicons/silicon-ui/components.json` lists every component, its import path and its exports.
|
|
61
61
|
|
|
62
|
+
|
|
63
|
+
## Guardrails
|
|
64
|
+
|
|
65
|
+
Two checks keep an app on the system, so screens stay consistent however they are built (including by AI):
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
// eslint.config.mjs: raw <button>, <input>, <select>, <textarea>, <dialog> and borrowed library CSS are errors
|
|
69
|
+
import siliconUi from "@teamofsilicons/silicon-ui/eslint";
|
|
70
|
+
export default [...yourConfig, ...siliconUi()];
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
npx silicon-ui-check # raw spacing, radius, colour or font size in CSS modules fails
|
|
75
|
+
npx silicon-ui-check --update-baseline # tolerate today's legacy values; only new drift fails
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Full guide, including the Claude Code hook: [docs/guardrails.md](https://github.com/teamofsilicons/silicon-ui/blob/main/docs/guardrails.md).
|
|
79
|
+
|
|
62
80
|
## Licence
|
|
63
81
|
|
|
64
82
|
MIT. Based on [Arc](https://github.com/kuratlielia/arc-library) by Elia Kuratli.
|
package/check.mjs
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* silicon-ui-check: keeps an app's CSS on Silicon UI's tokens.
|
|
4
|
+
*
|
|
5
|
+
* Fails a CSS file that sets spacing, a corner radius, a colour or a font size with a raw value instead of a token:
|
|
6
|
+
*
|
|
7
|
+
* spacing padding, margin, gap, inset, top/right/bottom/left, scroll-margin/padding → var(--space-*)
|
|
8
|
+
* radius border-radius and its corners → var(--radius-*)
|
|
9
|
+
* color hex, rgb(), hsl(), oklch(), oklab(), lab(), lch(), color() → var(--…)
|
|
10
|
+
* font-size px or rem → var(--text-*)
|
|
11
|
+
*
|
|
12
|
+
* Allowed everywhere: 0, auto, inherit and other keywords, anything inside var() (including fallbacks), and
|
|
13
|
+
* font-relative or layout-relative units (em, %, ch, vw, vh, cq*). Custom property definitions (--x: …) are not
|
|
14
|
+
* checked: tokens are defined that way, in the files you list under `ignore`. The visually hidden block (.srOnly,
|
|
15
|
+
* .visuallyHidden, .sr-only) and @keyframes are skipped.
|
|
16
|
+
*
|
|
17
|
+
* Configure it in package.json:
|
|
18
|
+
*
|
|
19
|
+
* "siliconUi": {
|
|
20
|
+
* "css": ["components/**\/*.module.css", "app/**\/*.module.css"],
|
|
21
|
+
* "ignore": ["styles/tokens.css"],
|
|
22
|
+
* "baseline": "silicon-ui-baseline.json"
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* silicon-ui-check check every configured file (fails only above the baseline)
|
|
27
|
+
* silicon-ui-check a.css b.css check these files
|
|
28
|
+
* silicon-ui-check --update-baseline record today's counts, so only new drift fails from now on
|
|
29
|
+
* silicon-ui-check --hook Claude Code PostToolUse hook: checks the edited file (CSS, and ESLint for
|
|
30
|
+
* .ts/.tsx/.js/.jsx), exit 2 with the problems so the agent fixes them
|
|
31
|
+
*/
|
|
32
|
+
import { spawnSync } from "node:child_process";
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
36
|
+
|
|
37
|
+
const RULES = {
|
|
38
|
+
spacing: /^(?:padding|margin|gap|row-gap|column-gap|inset|top|right|bottom|left)(?:-[a-z-]+)?$|^scroll-(?:margin|padding)(?:-[a-z-]+)?$/,
|
|
39
|
+
radius: /^border(?:-[a-z]+(?:-[a-z]+)?)?-radius$/,
|
|
40
|
+
"font-size": /^font-size$/,
|
|
41
|
+
};
|
|
42
|
+
const LENGTH = /(?<![\w.#-])(-?(?:\d*\.)?\d+)(px|rem)\b/g;
|
|
43
|
+
const COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\(/i;
|
|
44
|
+
const HIDDEN = /\.(?:srOnly|visuallyHidden|sr-only|visually-hidden)\b/;
|
|
45
|
+
const FIX = {
|
|
46
|
+
spacing: "use a spacing token: var(--space-tight|related|group|inset|comfort|section|block|page|stage|screen)",
|
|
47
|
+
radius: "use a radius token: var(--radius-detail|control|container|overlay|full)",
|
|
48
|
+
color: "use a colour token: var(--foreground), var(--text-secondary), var(--border), var(--accent) …",
|
|
49
|
+
"font-size": "use a type token: var(--text-xs|sm|base|lg|xl|2xl|3xl|4xl|5xl)",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Removes every var(...) (with nested parentheses), so tokens and their fallbacks are never flagged. */
|
|
53
|
+
function withoutVars(value) {
|
|
54
|
+
let out = "";
|
|
55
|
+
for (let i = 0; i < value.length; i++) {
|
|
56
|
+
if (value.startsWith("var(", i)) {
|
|
57
|
+
let depth = 0;
|
|
58
|
+
for (; i < value.length; i++) {
|
|
59
|
+
if (value[i] === "(") depth++;
|
|
60
|
+
else if (value[i] === ")" && --depth === 0) break;
|
|
61
|
+
}
|
|
62
|
+
out += "var()";
|
|
63
|
+
} else out += value[i];
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Declarations with their line, selector and whether they sit inside @keyframes. */
|
|
69
|
+
function declarations(css) {
|
|
70
|
+
const source = css.replace(/\/\*[\s\S]*?\*\//g, match => match.replace(/[^\n]/g, " "));
|
|
71
|
+
const found = [];
|
|
72
|
+
const stack = [];
|
|
73
|
+
let start = 0;
|
|
74
|
+
const lineAt = index => source.slice(0, index).split("\n").length;
|
|
75
|
+
for (let i = 0; i < source.length; i++) {
|
|
76
|
+
const char = source[i];
|
|
77
|
+
if (char === "{") {
|
|
78
|
+
stack.push(source.slice(start, i).trim());
|
|
79
|
+
start = i + 1;
|
|
80
|
+
} else if (char === "}" || char === ";") {
|
|
81
|
+
const text = source.slice(start, i);
|
|
82
|
+
const colon = text.indexOf(":");
|
|
83
|
+
if (colon > 0 && stack.length && !/[{}]/.test(text)) {
|
|
84
|
+
const property = text.slice(0, colon).trim().toLowerCase();
|
|
85
|
+
const value = text.slice(colon + 1).trim();
|
|
86
|
+
const offset = start + text.length - text.trimStart().length;
|
|
87
|
+
if (/^[a-z-]+$/.test(property)) {
|
|
88
|
+
found.push({ property, value, line: lineAt(offset), selector: stack[stack.length - 1], keyframes: stack.some(p => /^@(-\w+-)?keyframes\b/.test(p)) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (char === "}") stack.pop();
|
|
92
|
+
start = i + 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function checkCss(css) {
|
|
99
|
+
const problems = [];
|
|
100
|
+
for (const { property, value, line, selector, keyframes } of declarations(css)) {
|
|
101
|
+
if (keyframes || property.startsWith("--") || HIDDEN.test(selector)) continue;
|
|
102
|
+
const bare = withoutVars(value);
|
|
103
|
+
for (const [rule, pattern] of Object.entries(RULES)) {
|
|
104
|
+
if (!pattern.test(property)) continue;
|
|
105
|
+
const raw = [...bare.matchAll(LENGTH)].filter(match => Number(match[1]) !== 0).map(match => match[0]);
|
|
106
|
+
if (raw.length) problems.push({ rule, line, property, value, raw: raw.join(", ") });
|
|
107
|
+
}
|
|
108
|
+
const color = bare.match(COLOR);
|
|
109
|
+
if (color) problems.push({ rule: "color", line, property, value, raw: color[0] });
|
|
110
|
+
}
|
|
111
|
+
return problems;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ---------------------------------------------------------------- files and config ------------------------------------------------------------- */
|
|
115
|
+
|
|
116
|
+
function globToRegExp(glob) {
|
|
117
|
+
let re = "";
|
|
118
|
+
for (let i = 0; i < glob.length; i++) {
|
|
119
|
+
const c = glob[i];
|
|
120
|
+
if (c === "*") {
|
|
121
|
+
if (glob[i + 1] === "*") { re += glob[i + 2] === "/" ? "(?:.*/)?" : ".*"; i += glob[i + 2] === "/" ? 2 : 1; }
|
|
122
|
+
else re += "[^/]*";
|
|
123
|
+
} else if (c === "?") re += "[^/]";
|
|
124
|
+
else if (c === "{") { const end = glob.indexOf("}", i); re += `(?:${glob.slice(i + 1, end).split(",").map(s => s.replace(/[.+^$()|[\]\\]/g, "\\$&")).join("|")})`; i = end; }
|
|
125
|
+
else re += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
126
|
+
}
|
|
127
|
+
return new RegExp(`^${re}$`);
|
|
128
|
+
}
|
|
129
|
+
const matches = (file, globs) => globs.some(glob => globToRegExp(glob).test(file));
|
|
130
|
+
|
|
131
|
+
function walk(dir, root) {
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
134
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
|
135
|
+
const full = path.join(dir, entry.name);
|
|
136
|
+
if (entry.isDirectory()) out.push(...walk(full, root));
|
|
137
|
+
else out.push(path.relative(root, full).split(path.sep).join("/"));
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function findProject(from) {
|
|
143
|
+
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
|
|
144
|
+
const manifest = path.join(dir, "package.json");
|
|
145
|
+
if (fs.existsSync(manifest)) {
|
|
146
|
+
const config = JSON.parse(fs.readFileSync(manifest, "utf8")).siliconUi;
|
|
147
|
+
if (config) return { root: dir, config: { css: ["**/*.module.css"], ignore: [], baseline: "silicon-ui-baseline.json", ...config } };
|
|
148
|
+
}
|
|
149
|
+
if (path.dirname(dir) === dir) return { root: path.resolve(from), config: { css: ["**/*.module.css"], ignore: [], baseline: "silicon-ui-baseline.json" } };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const count = problems => problems.reduce((totals, { rule }) => ({ ...totals, [rule]: (totals[rule] ?? 0) + 1 }), {});
|
|
154
|
+
const readBaseline = file => (fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")).files ?? {} : {});
|
|
155
|
+
|
|
156
|
+
/** Problems in a file beyond what the baseline allows for it (all of them for a file the baseline does not list). */
|
|
157
|
+
function overBaseline(problems, allowed = {}) {
|
|
158
|
+
const totals = count(problems);
|
|
159
|
+
return Object.keys(totals).filter(rule => totals[rule] > (allowed[rule] ?? 0)).flatMap(rule => problems.filter(problem => problem.rule === rule));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function report(file, problems, allowed = {}) {
|
|
163
|
+
const lines = [`${file}`];
|
|
164
|
+
for (const { rule, line, property, raw } of [...problems].sort((x, y) => x.line - y.line)) lines.push(` ${line} ${rule}: ${property} uses ${raw}; ${FIX[rule]}`);
|
|
165
|
+
const counted = count(problems);
|
|
166
|
+
const listed = Object.keys(counted).filter(rule => allowed[rule] && counted[rule] > allowed[rule]).map(rule => `${rule} ${counted[rule]}, baseline ${allowed[rule]}`);
|
|
167
|
+
if (listed.length) lines.push(` (over the baseline: ${listed.join("; ")}. Older raw values are listed too; fix at least the new ones)`);
|
|
168
|
+
return lines.join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ---------------------------------------------------------------- modes ------------------------------------------------------------------------ */
|
|
172
|
+
|
|
173
|
+
function runCli(args) {
|
|
174
|
+
const { root, config } = findProject(process.cwd());
|
|
175
|
+
const update = args.includes("--update-baseline");
|
|
176
|
+
const named = args.filter(arg => !arg.startsWith("--"));
|
|
177
|
+
const files = (named.length ? named.map(file => path.relative(root, path.resolve(file)).split(path.sep).join("/")) : walk(root, root).filter(file => matches(file, config.css)))
|
|
178
|
+
.filter(file => !matches(file, config.ignore));
|
|
179
|
+
const baselinePath = path.join(root, config.baseline);
|
|
180
|
+
const baseline = readBaseline(baselinePath);
|
|
181
|
+
|
|
182
|
+
if (update) {
|
|
183
|
+
const next = {};
|
|
184
|
+
for (const file of files) { const totals = count(checkCss(fs.readFileSync(path.join(root, file), "utf8"))); if (Object.keys(totals).length) next[file] = totals; }
|
|
185
|
+
fs.writeFileSync(baselinePath, `${JSON.stringify({ note: "Raw values that predate the Silicon UI checks. Counts may only go down: fix a file, then run silicon-ui-check --update-baseline.", files: next }, null, 2)}\n`);
|
|
186
|
+
const total = Object.values(next).reduce((sum, totals) => sum + Object.values(totals).reduce((a, b) => a + b, 0), 0);
|
|
187
|
+
console.log(`silicon-ui-check: baseline written to ${config.baseline}: ${total} raw values in ${Object.keys(next).length} files.`);
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let failed = 0, legacy = 0;
|
|
192
|
+
for (const file of files) {
|
|
193
|
+
const problems = checkCss(fs.readFileSync(path.join(root, file), "utf8"));
|
|
194
|
+
const over = overBaseline(problems, baseline[file]);
|
|
195
|
+
legacy += problems.length - over.length;
|
|
196
|
+
if (over.length) { failed++; console.error(report(file, problems.length > over.length ? problems : over, baseline[file])); }
|
|
197
|
+
}
|
|
198
|
+
if (failed) {
|
|
199
|
+
console.error(`\nsilicon-ui-check: ${failed} file${failed === 1 ? " uses" : "s use"} raw values where Silicon UI has tokens.`);
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
console.log(`silicon-ui-check: ${files.length} files on tokens${legacy ? ` (${legacy} older raw values tolerated by the baseline; it only goes down)` : ""}.`);
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function runHook() {
|
|
207
|
+
let input = {};
|
|
208
|
+
try { input = JSON.parse(fs.readFileSync(0, "utf8") || "{}"); } catch { return 0; }
|
|
209
|
+
const target = input.tool_input?.file_path ?? input.tool_input?.path;
|
|
210
|
+
if (!target || !fs.existsSync(target)) return 0;
|
|
211
|
+
const { root, config } = findProject(path.dirname(target));
|
|
212
|
+
const file = path.relative(root, path.resolve(target)).split(path.sep).join("/");
|
|
213
|
+
const messages = [];
|
|
214
|
+
|
|
215
|
+
if (/\.css$/.test(file) && matches(file, config.css) && !matches(file, config.ignore)) {
|
|
216
|
+
const problems = checkCss(fs.readFileSync(target, "utf8"));
|
|
217
|
+
const allowed = readBaseline(path.join(root, config.baseline))[file];
|
|
218
|
+
if (overBaseline(problems, allowed).length) messages.push(report(file, problems, allowed));
|
|
219
|
+
}
|
|
220
|
+
if (/\.(?:[cm]?[jt]sx?)$/.test(file)) {
|
|
221
|
+
const eslint = path.join(root, "node_modules", ".bin", "eslint");
|
|
222
|
+
if (fs.existsSync(eslint)) {
|
|
223
|
+
const result = spawnSync(eslint, ["--no-warn-ignored", "--max-warnings=0", file], { cwd: root, encoding: "utf8" });
|
|
224
|
+
if (result.status) messages.push((result.stdout || result.stderr).trim());
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!messages.length) return 0;
|
|
228
|
+
console.error(`Silicon UI check failed for ${file}. Fix these before moving on (docs: https://github.com/teamofsilicons/silicon-ui/tree/main/docs):\n\n${messages.join("\n\n")}`);
|
|
229
|
+
return 2;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Run as a command (directly or through node_modules/.bin), not when imported.
|
|
233
|
+
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
234
|
+
const args = process.argv.slice(2);
|
|
235
|
+
process.exitCode = args.includes("--hook") ? runHook() : runCli(args);
|
|
236
|
+
}
|
package/eslint.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rules that keep an app on Silicon UI: build with its components, never re-create or restyle them.
|
|
3
|
+
*
|
|
4
|
+
* // eslint.config.mjs
|
|
5
|
+
* import siliconUi from "@teamofsilicons/silicon-ui/eslint";
|
|
6
|
+
* export default [...yourConfig, ...siliconUi()];
|
|
7
|
+
*
|
|
8
|
+
* Options:
|
|
9
|
+
* files globs the rules apply to (default: every .tsx and .jsx file)
|
|
10
|
+
* ignores globs they skip (for example a vendored exception)
|
|
11
|
+
* elements override the element → component table below
|
|
12
|
+
*
|
|
13
|
+
* This config sets `no-restricted-syntax` and `no-restricted-imports`. ESLint replaces, not merges, a rule's options, so
|
|
14
|
+
* if your own config also sets either rule, put this one last and move your entries into `extraSyntax` / `extraImports`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Raw elements Silicon UI already has a component for. */
|
|
18
|
+
export const ELEMENTS = {
|
|
19
|
+
button: "Use Button (actions) or ButtonLink (navigation) from @teamofsilicons/silicon-ui.",
|
|
20
|
+
input: "Use Input, Checkbox, Switch, RadioGroup, Slider or another field from @teamofsilicons/silicon-ui.",
|
|
21
|
+
select: "Use Select, Combobox or MorphSelect from @teamofsilicons/silicon-ui.",
|
|
22
|
+
textarea: "Use Textarea from @teamofsilicons/silicon-ui.",
|
|
23
|
+
dialog: "Use Dialog, Drawer or BottomSheet from @teamofsilicons/silicon-ui.",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const IMPORTS = [
|
|
27
|
+
{
|
|
28
|
+
regex: "^@teamofsilicons/silicon-ui/(?!styles\\.css$).*\\.css$",
|
|
29
|
+
message: "Do not borrow a Silicon UI component's CSS. Render the component; if it lacks a variant, add it to the library.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
regex: "(^|/)components/silicon-ui/(?!blocks/)",
|
|
33
|
+
message: "Import components from @teamofsilicons/silicon-ui, not from a local copy. Never copy library code into the app.",
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export default function siliconUi({ files = ["**/*.{tsx,jsx}"], ignores = [], elements = ELEMENTS, extraSyntax = [], extraImports = [] } = {}) {
|
|
38
|
+
const syntax = Object.entries(elements).map(([name, message]) => ({
|
|
39
|
+
selector: `JSXOpeningElement[name.type="JSXIdentifier"][name.name="${name}"]`,
|
|
40
|
+
message: `<${name}> is not allowed here. ${message}`,
|
|
41
|
+
}));
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
name: "silicon-ui/components",
|
|
45
|
+
files,
|
|
46
|
+
ignores,
|
|
47
|
+
rules: {
|
|
48
|
+
"no-restricted-syntax": ["error", ...syntax, ...extraSyntax],
|
|
49
|
+
"no-restricted-imports": ["error", { patterns: [...IMPORTS, ...extraImports] }],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamofsilicons/silicon-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "React components with calm motion, on the Silicon design tokens. By Team of Silicons.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Team of Silicons",
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
"sideEffects": [
|
|
25
25
|
"**/*.css"
|
|
26
26
|
],
|
|
27
|
+
"bin": {
|
|
28
|
+
"silicon-ui-check": "./check.mjs"
|
|
29
|
+
},
|
|
27
30
|
"exports": {
|
|
28
31
|
"./styles.css": {
|
|
29
32
|
"types": "./styles.css.d.ts",
|
|
@@ -41,6 +44,7 @@
|
|
|
41
44
|
"types": "./lib/*.d.ts",
|
|
42
45
|
"default": "./lib/*.js"
|
|
43
46
|
},
|
|
47
|
+
"./eslint": "./eslint.js",
|
|
44
48
|
"./components.json": "./components.json",
|
|
45
49
|
"./package.json": "./package.json",
|
|
46
50
|
"./*": {
|
|
@@ -50,7 +54,13 @@
|
|
|
50
54
|
},
|
|
51
55
|
"peerDependencies": {
|
|
52
56
|
"react": ">=19",
|
|
53
|
-
"react-dom": ">=19"
|
|
57
|
+
"react-dom": ">=19",
|
|
58
|
+
"eslint": ">=9"
|
|
59
|
+
},
|
|
60
|
+
"peerDependenciesMeta": {
|
|
61
|
+
"eslint": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
54
64
|
},
|
|
55
65
|
"dependencies": {
|
|
56
66
|
"@radix-ui/react-accordion": "^1.2.20",
|