dslinter 0.0.6

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 (63) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/LICENSE +201 -0
  3. package/README.md +104 -0
  4. package/bin/dslinter.mjs +29 -0
  5. package/components.json +20 -0
  6. package/package.json +90 -0
  7. package/src/components/InlineCode.tsx +5 -0
  8. package/src/components/icons.tsx +121 -0
  9. package/src/components/ui/badge.tsx +52 -0
  10. package/src/components/ui/button.tsx +57 -0
  11. package/src/components/ui/checkbox.tsx +25 -0
  12. package/src/components/ui/command.tsx +183 -0
  13. package/src/components/ui/dialog.tsx +156 -0
  14. package/src/components/ui/hover-card.tsx +42 -0
  15. package/src/components/ui/input.tsx +22 -0
  16. package/src/components/ui/label.tsx +19 -0
  17. package/src/components/ui/select.tsx +149 -0
  18. package/src/components/ui/table.tsx +118 -0
  19. package/src/components/ui/toggle-group.tsx +83 -0
  20. package/src/components/ui/toggle.tsx +45 -0
  21. package/src/dashboard/ComponentCatalog.tsx +210 -0
  22. package/src/dashboard/ComponentUsageDetails.tsx +109 -0
  23. package/src/dashboard/DashboardBody.tsx +71 -0
  24. package/src/dashboard/FindingsList.tsx +151 -0
  25. package/src/dashboard/ScoreStrip.tsx +28 -0
  26. package/src/dashboard/TokenWall.tsx +241 -0
  27. package/src/dashboard/aggregate.ts +73 -0
  28. package/src/dashboard/paths.ts +10 -0
  29. package/src/dashboard/useWorkspaceReport.ts +136 -0
  30. package/src/index.ts +67 -0
  31. package/src/lib/utils.ts +6 -0
  32. package/src/playground/definePlayground.tsx +99 -0
  33. package/src/playground/enumerateControlCombinations.test.ts +112 -0
  34. package/src/playground/enumerateControlCombinations.ts +74 -0
  35. package/src/report/a11yForModule.ts +35 -0
  36. package/src/report/codeScoreForModule.ts +41 -0
  37. package/src/report/modulePathMatch.ts +27 -0
  38. package/src/report/tokenStyleFindingsForModule.ts +24 -0
  39. package/src/shell/ComponentPlaygroundPane.tsx +438 -0
  40. package/src/shell/DashboardCommandPalette.tsx +134 -0
  41. package/src/shell/DashboardLayout.tsx +230 -0
  42. package/src/shell/EmptyCard.tsx +21 -0
  43. package/src/shell/GovernancePane.tsx +77 -0
  44. package/src/shell/PlaygroundA11yAndCode.tsx +387 -0
  45. package/src/shell/PlaygroundControlField.tsx +213 -0
  46. package/src/shell/PlaygroundControls.tsx +66 -0
  47. package/src/shell/PlaygroundUsageCode.tsx +51 -0
  48. package/src/shell/PlaygroundVariantMatrix.tsx +68 -0
  49. package/src/shell/Section.tsx +34 -0
  50. package/src/shell/Sidebar.tsx +203 -0
  51. package/src/shell/TokensPane.tsx +26 -0
  52. package/src/shell/controlApiTable.ts +53 -0
  53. package/src/shell/hashRoute.ts +49 -0
  54. package/src/shell/playgroundUsageHighlight.ts +53 -0
  55. package/src/shell/playgroundUsageTwoslash.ts +69 -0
  56. package/src/shell/useHashRoute.ts +29 -0
  57. package/src/styles/dashboard-theme.css +188 -0
  58. package/src/types/controls.ts +62 -0
  59. package/src/types/defaultTailwindTypography.ts +55 -0
  60. package/src/types/playground.ts +21 -0
  61. package/src/types/preview.ts +8 -0
  62. package/src/types/report.ts +116 -0
  63. package/src/types/tokenCatalog.ts +54 -0
@@ -0,0 +1,387 @@
1
+ import { useCallback } from "react";
2
+ import type { PlaygroundArgs, PlaygroundControl } from "../types/controls";
3
+ import type { PlaygroundEntry } from "../types/playground";
4
+ import type { A11yModuleSummary } from "../report/a11yForModule";
5
+ import type { CodeScoreModuleSummary } from "../report/codeScoreForModule";
6
+ import type { LintFinding, UsageSummary } from "../types/report";
7
+ import { Badge } from "@/components/ui/badge";
8
+ import { Button } from "@/components/ui/button";
9
+ import {
10
+ Table,
11
+ TableBody,
12
+ TableCell,
13
+ TableHead,
14
+ TableHeader,
15
+ TableRow,
16
+ } from "@/components/ui/table";
17
+ import { controlsToApiRows } from "./controlApiTable";
18
+ import { PlaygroundControlField } from "./PlaygroundControlField";
19
+ import { PlaygroundUsageCode } from "./PlaygroundUsageCode";
20
+ import { EmptyCard } from "./EmptyCard";
21
+ import { cn } from "../lib/utils";
22
+ import { Section } from "./Section";
23
+
24
+ type UsageProps = {
25
+ entry: PlaygroundEntry;
26
+ values: PlaygroundArgs;
27
+ };
28
+
29
+ export function PlaygroundUsageSection({ entry, values }: UsageProps) {
30
+ const usage =
31
+ entry.usageSnippet?.(values) ??
32
+ `// Pass usageSnippet on this PlaygroundEntry, or derive snippets from dslint controls.\n<${entry.id} />`;
33
+
34
+ return (
35
+ <Section
36
+ id="usage"
37
+ title="Usage"
38
+ description="Example usage for the current playground values."
39
+ >
40
+ <PlaygroundUsageCode source={usage} />
41
+ </Section>
42
+ );
43
+ }
44
+
45
+ type TokenStyleProps = {
46
+ findings: LintFinding[];
47
+ reportReady: boolean;
48
+ };
49
+
50
+ export function PlaygroundTokenStyleSection({
51
+ findings,
52
+ reportReady,
53
+ }: TokenStyleProps) {
54
+ return (
55
+ <>
56
+ {reportReady && findings.length > 0 ? (
57
+ <Table>
58
+ <TableHeader>
59
+ <TableRow>
60
+ <TableHead>Rule</TableHead>
61
+ <TableHead>Line</TableHead>
62
+ <TableHead>Severity</TableHead>
63
+ <TableHead>Message</TableHead>
64
+ </TableRow>
65
+ </TableHeader>
66
+ <TableBody>
67
+ {findings.map((f, i) => (
68
+ <TableRow key={`${f.rule_id}-${f.line ?? "x"}-${i}`}>
69
+ <TableCell className="font-mono text-xs text-muted-foreground">
70
+ {f.rule_id}
71
+ </TableCell>
72
+ <TableCell className="font-mono text-xs text-muted-foreground">
73
+ {f.line ?? "—"}
74
+ </TableCell>
75
+ <TableCell>
76
+ <Badge variant="secondary" size="sm">
77
+ {f.severity}
78
+ </Badge>
79
+ </TableCell>
80
+ <TableCell>{f.message}</TableCell>
81
+ </TableRow>
82
+ ))}
83
+ </TableBody>
84
+ </Table>
85
+ ) : reportReady && findings.length === 0 ? (
86
+ <EmptyCard>
87
+ No hardcoded or arbitrary token color findings on this file in the
88
+ current report.
89
+ </EmptyCard>
90
+ ) : (
91
+ <EmptyCard>
92
+ Token findings update when{" "}
93
+ <span className="font-mono">dslint-report.json</span> is available
94
+ (same fetch as Governance).
95
+ </EmptyCard>
96
+ )}
97
+ </>
98
+ );
99
+ }
100
+
101
+ type CodeScoreProps = {
102
+ codeScore: CodeScoreModuleSummary;
103
+ reportReady: boolean;
104
+ };
105
+
106
+ export function PlaygroundCodeScoreSection({
107
+ codeScore,
108
+ reportReady,
109
+ }: CodeScoreProps) {
110
+ const { findings } = codeScore;
111
+
112
+ const hasFindingRows = reportReady && findings.length > 0;
113
+
114
+ return (
115
+ <>
116
+ {hasFindingRows ? (
117
+ <>
118
+ <Table>
119
+ <TableHeader>
120
+ <TableRow>
121
+ <TableHead>Rule</TableHead>
122
+ <TableHead>Line</TableHead>
123
+ <TableHead>Severity</TableHead>
124
+ <TableHead>Message</TableHead>
125
+ </TableRow>
126
+ </TableHeader>
127
+ <TableBody>
128
+ {findings.map((f, i) => (
129
+ <TableRow key={`${f.rule_id}-${f.line ?? "x"}-${i}`}>
130
+ <TableCell>{f.rule_id}</TableCell>
131
+ <TableCell>{f.line ?? "—"}</TableCell>
132
+ <TableCell>{f.severity}</TableCell>
133
+ <TableCell>{f.message}</TableCell>
134
+ </TableRow>
135
+ ))}
136
+ </TableBody>
137
+ </Table>
138
+ </>
139
+ ) : reportReady && findings.length === 0 ? (
140
+ <EmptyCard>
141
+ No quality findings on this file in the current report.
142
+ </EmptyCard>
143
+ ) : (
144
+ <EmptyCard>
145
+ Code score updates when{" "}
146
+ <span className="font-mono">dslint-report.json</span> is available
147
+ (same fetch as Governance).
148
+ </EmptyCard>
149
+ )}
150
+ </>
151
+ );
152
+ }
153
+
154
+ type A11yProps = {
155
+ a11y: A11yModuleSummary;
156
+ reportReady: boolean;
157
+ };
158
+
159
+ export function PlaygroundA11ySection({ a11y, reportReady }: A11yProps) {
160
+ const hasFindingRows = reportReady && a11y.findings.length > 0;
161
+
162
+ return (
163
+ <>
164
+ {hasFindingRows ? (
165
+ <Table>
166
+ <TableHeader>
167
+ <TableRow>
168
+ <TableHead>Rule</TableHead>
169
+ <TableHead>Line</TableHead>
170
+ <TableHead>Severity</TableHead>
171
+ <TableHead>Message</TableHead>
172
+ </TableRow>
173
+ </TableHeader>
174
+ <TableBody>
175
+ {a11y.findings.map((f, i) => (
176
+ <TableRow key={`${f.rule_id}-${f.line ?? "x"}-${i}`}>
177
+ <TableCell>{f.rule_id}</TableCell>
178
+ <TableCell>{f.line ?? "—"}</TableCell>
179
+ <TableCell>{f.severity}</TableCell>
180
+ <TableCell>{f.message}</TableCell>
181
+ </TableRow>
182
+ ))}
183
+ </TableBody>
184
+ </Table>
185
+ ) : reportReady && a11y.issueCount === 0 ? (
186
+ <EmptyCard>
187
+ No accessibility findings on this file in the current report.
188
+ </EmptyCard>
189
+ ) : (
190
+ <EmptyCard>
191
+ A11y score updates when{" "}
192
+ <span className="font-mono">dslint-report.json</span> is available
193
+ (same fetch as Governance).
194
+ </EmptyCard>
195
+ )}
196
+ </>
197
+ );
198
+ }
199
+
200
+ type ApiProps = {
201
+ controls: PlaygroundControl[];
202
+ values: PlaygroundArgs;
203
+ onChange: (next: PlaygroundArgs) => void;
204
+ onReset: () => void;
205
+ /** When set, adds columns for how often each prop appears at scanned JSX call sites. */
206
+ reportUsage?: UsageSummary;
207
+ /** Declared prop names from the scan (definitions + playground specs), used for “never passed” hints. */
208
+ declaredPropsFromScan?: string[];
209
+ /** True when `dslint-report.json` is loaded (even if this component has no usage row). */
210
+ governanceReportLoaded?: boolean;
211
+ };
212
+
213
+ function formatRepoLiteralChips(
214
+ byVal: Record<string, number> | undefined,
215
+ max = 6,
216
+ ): string {
217
+ if (!byVal || Object.keys(byVal).length === 0) return "—";
218
+ const entries = Object.entries(byVal).sort((x, y) => y[1] - x[1]);
219
+ const shown = entries.slice(0, max);
220
+ const tail = Math.max(0, entries.length - max);
221
+ return (
222
+ shown.map(([val, n]) => `${JSON.stringify(val)} ×${n}`).join(" · ") +
223
+ (tail > 0 ? ` · +${tail}` : "")
224
+ );
225
+ }
226
+
227
+ export function PlaygroundApiReference({
228
+ controls,
229
+ values,
230
+ onChange,
231
+ onReset,
232
+ reportUsage,
233
+ declaredPropsFromScan: _declaredPropsFromScan = [],
234
+ governanceReportLoaded: _governanceReportLoaded = false,
235
+ }: ApiProps) {
236
+ if (controls.length === 0) return null;
237
+
238
+ const patch = useCallback(
239
+ (key: string, value: string | number | boolean) => {
240
+ onChange({ ...values, [key]: value });
241
+ },
242
+ [onChange, values],
243
+ );
244
+
245
+ const rows = controlsToApiRows(controls);
246
+ const showRepo = reportUsage != null;
247
+ const freqs = reportUsage?.prop_frequencies ?? {};
248
+ const valueFreqs = reportUsage?.prop_value_frequencies ?? {};
249
+ const controlKeys = new Set(rows.map((r) => r.prop));
250
+ const extraRepoProps = showRepo
251
+ ? Object.keys(freqs)
252
+ .filter((k) => !controlKeys.has(k))
253
+ .sort((a, b) => a.localeCompare(b))
254
+ : [];
255
+
256
+ return (
257
+ <Section
258
+ id="api-reference"
259
+ title="API reference"
260
+ description=""
261
+ actions={
262
+ <Button type="button" variant="outline" size="sm" onClick={onReset}>
263
+ Reset defaults
264
+ </Button>
265
+ }
266
+ >
267
+ <Table>
268
+ <TableHeader>
269
+ <TableRow>
270
+ <TableHead>Prop</TableHead>
271
+ <TableHead>Type</TableHead>
272
+ <TableHead>Value</TableHead>
273
+ {showRepo ? (
274
+ <>
275
+ <TableHead>Usage</TableHead>
276
+ <TableHead>Values</TableHead>
277
+ </>
278
+ ) : null}
279
+ </TableRow>
280
+ </TableHeader>
281
+ <TableBody>
282
+ {controls.map((c) => {
283
+ const r = rows.find((row) => row.prop === c.key);
284
+ if (!r) return null;
285
+ const n = showRepo ? (freqs[r.prop] ?? 0) : 0;
286
+ const valueChips = formatRepoLiteralChips(valueFreqs[r.prop]);
287
+ return (
288
+ <TableRow key={r.prop}>
289
+ <TableCell className="font-medium">{r.prop}</TableCell>
290
+ <TableCell>
291
+ {c.type === "select" ? (
292
+ <div className="flex flex-wrap items-center gap-1">
293
+ {c.options.map((o) => {
294
+ const current = String(
295
+ values[c.key] ?? c.default ?? "",
296
+ );
297
+ const selected = current === o.value;
298
+ return (
299
+ <Badge
300
+ key={o.value}
301
+ variant="outline"
302
+ size="sm"
303
+ asChild
304
+ className={cn(
305
+ selected &&
306
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/90",
307
+ )}
308
+ >
309
+ <button
310
+ type="button"
311
+ className="cursor-pointer"
312
+ onClick={() => patch(c.key, o.value)}
313
+ aria-pressed={selected}
314
+ aria-label={`Set ${c.label} to ${o.label}`}
315
+ >
316
+ {o.value}
317
+ </button>
318
+ </Badge>
319
+ );
320
+ })}
321
+ </div>
322
+ ) : (
323
+ <span className="font-mono text-xs flex items-center gap-1">
324
+ {r.type}
325
+ {r.default !== "—" ? (
326
+ <Badge variant="secondary" size="sm">
327
+ {r.default}
328
+ </Badge>
329
+ ) : null}
330
+ </span>
331
+ )}
332
+ </TableCell>
333
+ <TableCell>
334
+ <PlaygroundControlField
335
+ control={c}
336
+ values={values}
337
+ patch={patch}
338
+ idPrefix="api"
339
+ layout="table"
340
+ />
341
+ </TableCell>
342
+ {showRepo ? (
343
+ <>
344
+ <TableCell>{n}</TableCell>
345
+ <TableCell>{valueChips}</TableCell>
346
+ </>
347
+ ) : null}
348
+ </TableRow>
349
+ );
350
+ })}
351
+ </TableBody>
352
+ </Table>
353
+
354
+ {showRepo && extraRepoProps.length > 0 ? (
355
+ <div className="mt-4">
356
+ <h3 className="text-sm font-semibold text-foreground">
357
+ Also seen in repo (not in playground)
358
+ </h3>
359
+ <p className="mt-1 text-xs text-muted-foreground">
360
+ These prop names appear in scanned JSX but are not wired as
361
+ playground controls on this page.
362
+ </p>
363
+ <Table>
364
+ <TableHeader>
365
+ <TableRow>
366
+ <TableHead>Prop</TableHead>
367
+ <TableHead>Repo call sites</TableHead>
368
+ <TableHead>Repo literals</TableHead>
369
+ </TableRow>
370
+ </TableHeader>
371
+ <TableBody>
372
+ {extraRepoProps.map((prop) => (
373
+ <TableRow key={prop}>
374
+ <TableCell>{prop}</TableCell>
375
+ <TableCell>×{freqs[prop] ?? 0}</TableCell>
376
+ <TableCell>
377
+ {formatRepoLiteralChips(valueFreqs[prop])}
378
+ </TableCell>
379
+ </TableRow>
380
+ ))}
381
+ </TableBody>
382
+ </Table>
383
+ </div>
384
+ ) : null}
385
+ </Section>
386
+ );
387
+ }
@@ -0,0 +1,213 @@
1
+ import type { PlaygroundArgs, PlaygroundControl } from "../types/controls";
2
+ import { Checkbox } from "@/components/ui/checkbox";
3
+ import { Input } from "@/components/ui/input";
4
+ import { Label } from "@/components/ui/label";
5
+ import {
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from "@/components/ui/select";
12
+
13
+ const labelClass = "text-xs font-medium text-muted-foreground";
14
+
15
+ export type PlaygroundControlFieldProps = {
16
+ control: PlaygroundControl;
17
+ values: PlaygroundArgs;
18
+ patch: (key: string, value: string | number | boolean) => void;
19
+ /** Prefix for element ids (e.g. `ctrl` vs `api`). */
20
+ idPrefix: string;
21
+ layout: "grid" | "table";
22
+ };
23
+
24
+ export function PlaygroundControlField({
25
+ control: c,
26
+ values,
27
+ patch,
28
+ idPrefix,
29
+ layout,
30
+ }: PlaygroundControlFieldProps) {
31
+ const id = `${idPrefix}-${c.key}`;
32
+
33
+ if (layout === "grid") {
34
+ switch (c.type) {
35
+ case "boolean": {
36
+ const checked = Boolean(values[c.key]);
37
+ return (
38
+ <div className="flex flex-col gap-2">
39
+ <div className="flex items-center gap-2">
40
+ <Checkbox
41
+ id={id}
42
+ checked={checked}
43
+ onCheckedChange={(v: boolean | "indeterminate") =>
44
+ patch(c.key, v === true)
45
+ }
46
+ />
47
+ <Label
48
+ htmlFor={id}
49
+ className={`${labelClass} cursor-pointer font-normal`}
50
+ >
51
+ {c.label}
52
+ </Label>
53
+ </div>
54
+ {c.hint ? (
55
+ <p className="text-xs text-muted-foreground">{c.hint}</p>
56
+ ) : null}
57
+ </div>
58
+ );
59
+ }
60
+ case "string":
61
+ return (
62
+ <div className="flex min-w-0 flex-col gap-1.5">
63
+ <Label htmlFor={id} className={labelClass}>
64
+ {c.label}
65
+ </Label>
66
+ <Input
67
+ id={id}
68
+ type="text"
69
+ value={String(values[c.key] ?? "")}
70
+ placeholder={c.placeholder}
71
+ onChange={(e) => patch(c.key, e.target.value)}
72
+ className="h-8 text-xs"
73
+ />
74
+ </div>
75
+ );
76
+ case "number": {
77
+ const raw = values[c.key];
78
+ const parsed =
79
+ typeof raw === "number" && Number.isFinite(raw) ? raw : Number(raw);
80
+ const safe = Number.isFinite(parsed) ? parsed : c.default;
81
+ return (
82
+ <div className="flex min-w-0 flex-col gap-1.5">
83
+ <Label htmlFor={id} className={labelClass}>
84
+ {c.label}
85
+ </Label>
86
+ <Input
87
+ id={id}
88
+ type="number"
89
+ value={safe}
90
+ min={c.min}
91
+ max={c.max}
92
+ step={c.step ?? 1}
93
+ onChange={(e) => {
94
+ const v = e.target.valueAsNumber;
95
+ patch(c.key, Number.isFinite(v) ? v : c.default);
96
+ }}
97
+ className="h-8 text-xs"
98
+ />
99
+ </div>
100
+ );
101
+ }
102
+ case "select": {
103
+ const v = String(values[c.key] ?? c.default ?? "");
104
+ return (
105
+ <div className="flex min-w-0 flex-col gap-1.5">
106
+ <Label htmlFor={id} className={labelClass}>
107
+ {c.label}
108
+ </Label>
109
+ <Select
110
+ value={v}
111
+ onValueChange={(next: string) => patch(c.key, next)}
112
+ >
113
+ <SelectTrigger id={id} className="h-8 text-xs">
114
+ <SelectValue placeholder={c.label} />
115
+ </SelectTrigger>
116
+ <SelectContent>
117
+ {c.options.map((o) => (
118
+ <SelectItem key={o.value} value={o.value} className="text-xs">
119
+ {o.label}
120
+ </SelectItem>
121
+ ))}
122
+ </SelectContent>
123
+ </Select>
124
+ </div>
125
+ );
126
+ }
127
+ default:
128
+ return null;
129
+ }
130
+ }
131
+
132
+ // table: prop name is in its own column; compact editors only
133
+ switch (c.type) {
134
+ case "boolean": {
135
+ const checked = Boolean(values[c.key]);
136
+ return (
137
+ <div className="flex min-w-[8rem] flex-col gap-1">
138
+ <div className="flex items-center gap-2">
139
+ <Checkbox
140
+ id={id}
141
+ checked={checked}
142
+ aria-label={c.label}
143
+ onCheckedChange={(v: boolean | "indeterminate") =>
144
+ patch(c.key, v === true)
145
+ }
146
+ />
147
+ <span className="text-xs text-muted-foreground">{c.label}</span>
148
+ </div>
149
+ {c.hint ? (
150
+ <p className="text-xs text-muted-foreground">{c.hint}</p>
151
+ ) : null}
152
+ </div>
153
+ );
154
+ }
155
+ case "string":
156
+ return (
157
+ <Input
158
+ id={id}
159
+ type="text"
160
+ value={String(values[c.key] ?? "")}
161
+ placeholder={c.placeholder}
162
+ onChange={(e) => patch(c.key, e.target.value)}
163
+ className="h-8 min-w-[10rem] max-w-xs text-xs"
164
+ aria-label={c.label}
165
+ />
166
+ );
167
+ case "number": {
168
+ const raw = values[c.key];
169
+ const parsed =
170
+ typeof raw === "number" && Number.isFinite(raw) ? raw : Number(raw);
171
+ const safe = Number.isFinite(parsed) ? parsed : c.default;
172
+ return (
173
+ <Input
174
+ id={id}
175
+ type="number"
176
+ value={safe}
177
+ min={c.min}
178
+ max={c.max}
179
+ step={c.step ?? 1}
180
+ onChange={(e) => {
181
+ const v = e.target.valueAsNumber;
182
+ patch(c.key, Number.isFinite(v) ? v : c.default);
183
+ }}
184
+ className="h-8 w-24 text-xs"
185
+ aria-label={c.label}
186
+ />
187
+ );
188
+ }
189
+ case "select": {
190
+ const v = String(values[c.key] ?? c.default ?? "");
191
+ return (
192
+ <Select value={v} onValueChange={(next: string) => patch(c.key, next)}>
193
+ <SelectTrigger
194
+ id={id}
195
+ className="h-8 min-w-[10rem] max-w-xs text-xs"
196
+ aria-label={c.label}
197
+ >
198
+ <SelectValue placeholder={c.label} />
199
+ </SelectTrigger>
200
+ <SelectContent>
201
+ {c.options.map((o) => (
202
+ <SelectItem key={o.value} value={o.value} className="text-xs">
203
+ {o.label}
204
+ </SelectItem>
205
+ ))}
206
+ </SelectContent>
207
+ </Select>
208
+ );
209
+ }
210
+ default:
211
+ return null;
212
+ }
213
+ }
@@ -0,0 +1,66 @@
1
+ import { useCallback } from "react";
2
+ import type { PlaygroundArgs, PlaygroundControl } from "../types/controls";
3
+ import { Button } from "@/components/ui/button";
4
+ import { PlaygroundControlField } from "./PlaygroundControlField";
5
+
6
+ type Props = {
7
+ controls: PlaygroundControl[];
8
+ values: PlaygroundArgs;
9
+ onChange: (next: PlaygroundArgs) => void;
10
+ onReset: () => void;
11
+ /** Omit the outer card wrapper (doc-style pages provide their own section chrome). */
12
+ bare?: boolean;
13
+ };
14
+
15
+ export function PlaygroundControls({
16
+ controls,
17
+ values,
18
+ onChange,
19
+ onReset,
20
+ bare,
21
+ }: Props) {
22
+ const patch = useCallback(
23
+ (key: string, value: string | number | boolean) => {
24
+ onChange({ ...values, [key]: value });
25
+ },
26
+ [onChange, values],
27
+ );
28
+
29
+ if (controls.length === 0) return null;
30
+
31
+ const inner = (
32
+ <>
33
+ <div className="flex p-3 pl-5 flex-wrap items-center justify-between gap-3 border-b border-border pb-3">
34
+ <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
35
+ Controls
36
+ </p>
37
+ <Button type="button" variant="outline" size="sm" onClick={onReset}>
38
+ Reset defaults
39
+ </Button>
40
+ </div>
41
+ <div className=" p-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
42
+ {controls.map((c) => (
43
+ <div key={c.key}>
44
+ <PlaygroundControlField
45
+ control={c}
46
+ values={values}
47
+ patch={patch}
48
+ idPrefix="ctrl"
49
+ layout="grid"
50
+ />
51
+ </div>
52
+ ))}
53
+ </div>
54
+ </>
55
+ );
56
+
57
+ if (bare) {
58
+ return <div className="w-full">{inner}</div>;
59
+ }
60
+
61
+ return (
62
+ <div className="mb-6 w-full rounded-ds-lg border border-border bg-card p-4 text-card-foreground shadow-xs">
63
+ {inner}
64
+ </div>
65
+ );
66
+ }