specproof 0.1.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/.github/workflows/release.yml +50 -0
- package/.github/workflows/test-unit.yml +36 -0
- package/CHANGELOG.md +33 -0
- package/CLAUDE.md +72 -0
- package/README.md +51 -0
- package/app/globals.css +31 -0
- package/app/layout.tsx +29 -0
- package/app/page.tsx +20 -0
- package/app/proof-contract.test.ts +95 -0
- package/app/proof.css +217 -0
- package/app/proof.generated.json +7 -0
- package/bun.lock +991 -0
- package/components/CoverageProof.tsx +387 -0
- package/components/ui/accordion.tsx +57 -0
- package/components/ui/sheet.tsx +140 -0
- package/eslint.config.mjs +43 -0
- package/lib/api-test-coverage.ts +387 -0
- package/lib/utils.ts +6 -0
- package/next.config.js +6 -0
- package/package.json +57 -0
- package/postcss.config.js +7 -0
- package/scripts/generate-proof.ts +49 -0
- package/tailwind.config.ts +61 -0
- package/tsconfig.json +43 -0
- package/vitest.config.ts +18 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Accordion,
|
|
7
|
+
AccordionContent,
|
|
8
|
+
AccordionItem,
|
|
9
|
+
AccordionTrigger
|
|
10
|
+
} from '@/components/ui/accordion';
|
|
11
|
+
import {
|
|
12
|
+
Sheet,
|
|
13
|
+
SheetContent,
|
|
14
|
+
SheetDescription,
|
|
15
|
+
SheetHeader,
|
|
16
|
+
SheetTitle
|
|
17
|
+
} from '@/components/ui/sheet';
|
|
18
|
+
import type {
|
|
19
|
+
CoverageReport,
|
|
20
|
+
OperationCoverage,
|
|
21
|
+
StatusCoverage,
|
|
22
|
+
TagCoverage,
|
|
23
|
+
TestSnippet
|
|
24
|
+
} from '@/lib/api-test-coverage';
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Helpers
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
function verdictOf(status: StatusCoverage): 'ok' | 'gap' | 'undoc' {
|
|
31
|
+
if (!status.documented) return 'undoc';
|
|
32
|
+
return status.assertions > 0 ? 'ok' : 'gap';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Render {slug} path segments in a muted tone so parameters read apart */
|
|
36
|
+
function PathInk({ specPath }: { specPath: string }) {
|
|
37
|
+
const parts = specPath.split(/(\{[^}]+\})/g).filter(Boolean);
|
|
38
|
+
return (
|
|
39
|
+
<span className="text-sm tracking-tight">
|
|
40
|
+
{parts.map((part, i) =>
|
|
41
|
+
part.startsWith('{') ? (
|
|
42
|
+
<em key={i} className="not-italic text-muted-foreground">
|
|
43
|
+
{part}
|
|
44
|
+
</em>
|
|
45
|
+
) : (
|
|
46
|
+
<span key={i}>{part}</span>
|
|
47
|
+
)
|
|
48
|
+
)}
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What the code panel is showing: one status of one operation */
|
|
54
|
+
interface Evidence {
|
|
55
|
+
operation: OperationCoverage;
|
|
56
|
+
status: StatusCoverage;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Test-code panel
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
function SnippetBlock({ snippet, code }: { snippet: TestSnippet; code: string }) {
|
|
64
|
+
const hitRe = new RegExp(`\\.status\\)\\.(?:toBe|toEqual)\\(\\s*${code}\\s*\\)`);
|
|
65
|
+
const lines = snippet.source.split('\n');
|
|
66
|
+
return (
|
|
67
|
+
<figure className="flex flex-col gap-2">
|
|
68
|
+
<figcaption className="flex items-baseline gap-3">
|
|
69
|
+
<span className="text-xs font-medium">{snippet.title}</span>
|
|
70
|
+
<span className="sp-leader" aria-hidden />
|
|
71
|
+
<span className="shrink-0 text-[0.65rem] text-muted-foreground">L{snippet.startLine}</span>
|
|
72
|
+
</figcaption>
|
|
73
|
+
<pre className="sp-codeblock py-2">
|
|
74
|
+
{lines.map((line, i) => (
|
|
75
|
+
<div key={i} className="sp-codeline" data-hit={hitRe.test(line) ? '' : undefined}>
|
|
76
|
+
<span className="sp-lineno">{snippet.startLine + i}</span>
|
|
77
|
+
<code>{line || ' '}</code>
|
|
78
|
+
</div>
|
|
79
|
+
))}
|
|
80
|
+
</pre>
|
|
81
|
+
</figure>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function EvidencePanel({
|
|
86
|
+
evidence,
|
|
87
|
+
onClose
|
|
88
|
+
}: {
|
|
89
|
+
evidence: Evidence | null;
|
|
90
|
+
onClose: () => void;
|
|
91
|
+
}) {
|
|
92
|
+
return (
|
|
93
|
+
<Sheet open={evidence !== null} onOpenChange={(open) => !open && onClose()}>
|
|
94
|
+
<SheetContent className="proof-root w-full overflow-y-auto border-l border-[var(--sp-hair-strong)] sm:max-w-2xl">
|
|
95
|
+
{evidence && (
|
|
96
|
+
<>
|
|
97
|
+
<SheetHeader className="text-left">
|
|
98
|
+
<SheetTitle className="flex items-baseline gap-3 font-normal">
|
|
99
|
+
<span
|
|
100
|
+
className="sp-method shrink-0 uppercase"
|
|
101
|
+
data-method={evidence.operation.method}
|
|
102
|
+
>
|
|
103
|
+
{evidence.operation.method}
|
|
104
|
+
</span>
|
|
105
|
+
<span className="font-mono text-sm">{evidence.operation.specPath}</span>
|
|
106
|
+
<span className="sp-code text-lg font-semibold" data-class={evidence.status.code[0]}>
|
|
107
|
+
{evidence.status.code}
|
|
108
|
+
</span>
|
|
109
|
+
</SheetTitle>
|
|
110
|
+
<SheetDescription className="font-mono text-xs">
|
|
111
|
+
{evidence.status.documented ? (
|
|
112
|
+
evidence.status.description
|
|
113
|
+
) : (
|
|
114
|
+
<span className="text-[var(--sp-undoc)]">
|
|
115
|
+
Asserted in tests but absent from the OpenAPI spec — document it or drop it.
|
|
116
|
+
</span>
|
|
117
|
+
)}
|
|
118
|
+
</SheetDescription>
|
|
119
|
+
</SheetHeader>
|
|
120
|
+
|
|
121
|
+
<div className="mt-6 flex flex-col gap-8">
|
|
122
|
+
{evidence.status.snippets.map((snippet) => (
|
|
123
|
+
<SnippetBlock key={snippet.startLine} snippet={snippet} code={evidence.status.code} />
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{evidence.operation.testFile && (
|
|
128
|
+
<p className="mt-8 border-t border-dashed pt-3 text-[0.65rem] tracking-[0.14em] text-muted-foreground">
|
|
129
|
+
SOURCE · {evidence.operation.testFile.toUpperCase()}
|
|
130
|
+
</p>
|
|
131
|
+
)}
|
|
132
|
+
</>
|
|
133
|
+
)}
|
|
134
|
+
</SheetContent>
|
|
135
|
+
</Sheet>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ============================================================================
|
|
140
|
+
// Status list (accordion body)
|
|
141
|
+
// ============================================================================
|
|
142
|
+
|
|
143
|
+
function StatusList({
|
|
144
|
+
operation,
|
|
145
|
+
onShowEvidence
|
|
146
|
+
}: {
|
|
147
|
+
operation: OperationCoverage;
|
|
148
|
+
onShowEvidence: (evidence: Evidence) => void;
|
|
149
|
+
}) {
|
|
150
|
+
return (
|
|
151
|
+
<div className="flex flex-col gap-0 pl-[calc(0.6rem+3px)]">
|
|
152
|
+
{operation.statuses.map((status) => {
|
|
153
|
+
const verdict = verdictOf(status);
|
|
154
|
+
const hasEvidence = status.snippets.length > 0;
|
|
155
|
+
return (
|
|
156
|
+
<div key={status.code} className="flex items-baseline gap-3 py-1.5">
|
|
157
|
+
<span
|
|
158
|
+
className="sp-code w-8 shrink-0 text-sm font-semibold"
|
|
159
|
+
data-class={status.code[0]}
|
|
160
|
+
>
|
|
161
|
+
{status.code}
|
|
162
|
+
</span>
|
|
163
|
+
<span className="text-xs text-muted-foreground">
|
|
164
|
+
{status.documented
|
|
165
|
+
? status.description
|
|
166
|
+
: 'asserted in tests, but absent from the OpenAPI spec'}
|
|
167
|
+
</span>
|
|
168
|
+
<span className="sp-leader" aria-hidden />
|
|
169
|
+
{verdict === 'ok' && (
|
|
170
|
+
<span className="shrink-0 text-[0.68rem] text-muted-foreground">
|
|
171
|
+
{status.assertions} assertion{status.assertions === 1 ? '' : 's'}
|
|
172
|
+
</span>
|
|
173
|
+
)}
|
|
174
|
+
{hasEvidence ? (
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
className="sp-stamp shrink-0"
|
|
178
|
+
data-verdict={verdict}
|
|
179
|
+
title="Show the test code"
|
|
180
|
+
onClick={() => onShowEvidence({ operation, status })}
|
|
181
|
+
>
|
|
182
|
+
{verdict === 'ok' ? 'VERIFIED' : 'UNDOCUMENTED'} ⌕
|
|
183
|
+
</button>
|
|
184
|
+
) : (
|
|
185
|
+
<span className="sp-stamp shrink-0" data-verdict={verdict}>
|
|
186
|
+
{verdict === 'ok' ? 'VERIFIED' : verdict === 'gap' ? 'NO TEST' : 'UNDOCUMENTED'}
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
})}
|
|
192
|
+
|
|
193
|
+
<div className="mt-3 flex items-center gap-2 border-t border-dashed pt-2.5 text-[0.68rem] text-muted-foreground">
|
|
194
|
+
{operation.testFile && operation.testCount > 0 ? (
|
|
195
|
+
<span>
|
|
196
|
+
{operation.testCount} test{operation.testCount === 1 ? '' : 's'} in{' '}
|
|
197
|
+
<span className="text-foreground/70">{operation.testFile}</span>
|
|
198
|
+
</span>
|
|
199
|
+
) : (
|
|
200
|
+
<span className="sp-stamp" data-verdict="gap">
|
|
201
|
+
NO TEST FILE — THIS OPERATION SHIPS UNTESTED
|
|
202
|
+
</span>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================================================
|
|
210
|
+
// Operation row
|
|
211
|
+
// ============================================================================
|
|
212
|
+
|
|
213
|
+
function OperationRow({
|
|
214
|
+
operation,
|
|
215
|
+
onShowEvidence
|
|
216
|
+
}: {
|
|
217
|
+
operation: OperationCoverage;
|
|
218
|
+
onShowEvidence: (evidence: Evidence) => void;
|
|
219
|
+
}) {
|
|
220
|
+
const documented = operation.statuses.filter((s) => s.documented);
|
|
221
|
+
return (
|
|
222
|
+
<AccordionItem
|
|
223
|
+
value={`${operation.method} ${operation.specPath}`}
|
|
224
|
+
className="sp-oprow border-b-0"
|
|
225
|
+
>
|
|
226
|
+
<AccordionTrigger className="gap-3 px-3 py-3.5 hover:no-underline">
|
|
227
|
+
<span className="sp-method w-16 shrink-0 uppercase" data-method={operation.method}>
|
|
228
|
+
{operation.method}
|
|
229
|
+
</span>
|
|
230
|
+
<PathInk specPath={operation.specPath} />
|
|
231
|
+
<span className="sp-leader hidden sm:block" aria-hidden />
|
|
232
|
+
<span className="sp-marks shrink-0" aria-hidden>
|
|
233
|
+
{operation.statuses.map((status) => (
|
|
234
|
+
<span key={status.code} className="sp-mark" data-state={verdictOf(status)} />
|
|
235
|
+
))}
|
|
236
|
+
</span>
|
|
237
|
+
<span className="w-12 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
|
238
|
+
{operation.coveredCount}/{documented.length}
|
|
239
|
+
</span>
|
|
240
|
+
</AccordionTrigger>
|
|
241
|
+
<AccordionContent className="px-3 pb-5">
|
|
242
|
+
{operation.summary && (
|
|
243
|
+
<p className="mb-3 pl-[calc(0.6rem+3px)] text-xs text-muted-foreground">
|
|
244
|
+
{operation.summary}
|
|
245
|
+
</p>
|
|
246
|
+
)}
|
|
247
|
+
<StatusList operation={operation} onShowEvidence={onShowEvidence} />
|
|
248
|
+
</AccordionContent>
|
|
249
|
+
</AccordionItem>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ============================================================================
|
|
254
|
+
// Tag section
|
|
255
|
+
// ============================================================================
|
|
256
|
+
|
|
257
|
+
function TagSection({
|
|
258
|
+
tag,
|
|
259
|
+
index,
|
|
260
|
+
onShowEvidence
|
|
261
|
+
}: {
|
|
262
|
+
tag: TagCoverage;
|
|
263
|
+
index: number;
|
|
264
|
+
onShowEvidence: (evidence: Evidence) => void;
|
|
265
|
+
}) {
|
|
266
|
+
return (
|
|
267
|
+
<section className="sp-rise" style={{ '--sp-stagger': index + 3 } as React.CSSProperties}>
|
|
268
|
+
<header className="mb-1 flex items-baseline gap-4 border-b pb-2">
|
|
269
|
+
<span className="text-[0.65rem] tracking-[0.2em] text-muted-foreground">
|
|
270
|
+
{String(index + 1).padStart(2, '0')}
|
|
271
|
+
</span>
|
|
272
|
+
<h2 className="text-sm font-semibold uppercase tracking-[0.08em]">{tag.tag}</h2>
|
|
273
|
+
<span className="hidden text-xs text-muted-foreground sm:block">{tag.description}</span>
|
|
274
|
+
<span className="sp-leader" aria-hidden />
|
|
275
|
+
<span className="text-sm tabular-nums text-muted-foreground">
|
|
276
|
+
<span className="text-foreground">{tag.coveredCount}</span>/{tag.totalCount} verified
|
|
277
|
+
</span>
|
|
278
|
+
</header>
|
|
279
|
+
<Accordion type="multiple" className="divide-y divide-[var(--sp-hair)]">
|
|
280
|
+
{tag.operations.map((operation) => (
|
|
281
|
+
<OperationRow
|
|
282
|
+
key={`${operation.method} ${operation.specPath}`}
|
|
283
|
+
operation={operation}
|
|
284
|
+
onShowEvidence={onShowEvidence}
|
|
285
|
+
/>
|
|
286
|
+
))}
|
|
287
|
+
</Accordion>
|
|
288
|
+
</section>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ============================================================================
|
|
293
|
+
// Proof
|
|
294
|
+
// ============================================================================
|
|
295
|
+
|
|
296
|
+
export function CoverageProof({
|
|
297
|
+
report,
|
|
298
|
+
compiledAt
|
|
299
|
+
}: {
|
|
300
|
+
report: CoverageReport;
|
|
301
|
+
compiledAt: string;
|
|
302
|
+
}) {
|
|
303
|
+
const [evidence, setEvidence] = useState<Evidence | null>(null);
|
|
304
|
+
const gapCount = report.totalCount - report.coveredCount;
|
|
305
|
+
const facts: Array<[string, string]> = [
|
|
306
|
+
['operations', String(report.operationCount)],
|
|
307
|
+
['status pairs verified', `${report.coveredCount}/${report.totalCount}`],
|
|
308
|
+
['gaps', String(gapCount)],
|
|
309
|
+
['untested routes', String(report.untestedOperations)]
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<div className="proof-root proof-page min-h-screen">
|
|
314
|
+
<div className="mx-auto flex max-w-4xl flex-col gap-14 px-6 py-16">
|
|
315
|
+
{/* masthead */}
|
|
316
|
+
<header className="sp-rise" style={{ '--sp-stagger': 0 } as React.CSSProperties}>
|
|
317
|
+
<div className="flex flex-wrap items-end justify-between gap-6">
|
|
318
|
+
<h1 className="text-2xl font-semibold tracking-tight">SpecProof</h1>
|
|
319
|
+
<div className="text-right">
|
|
320
|
+
<div className="text-5xl font-semibold tabular-nums leading-none">
|
|
321
|
+
{Math.round((report.coveredCount / Math.max(report.totalCount, 1)) * 100)}
|
|
322
|
+
<span className="text-2xl font-normal text-muted-foreground">%</span>
|
|
323
|
+
</div>
|
|
324
|
+
<div className="mt-2 text-[0.65rem] tracking-[0.14em] text-muted-foreground">
|
|
325
|
+
DOCUMENTED RESPONSES VERIFIED
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
</header>
|
|
330
|
+
|
|
331
|
+
{/* summary strip */}
|
|
332
|
+
<dl
|
|
333
|
+
className="sp-rise sp-rule-double flex flex-wrap items-baseline gap-x-10 gap-y-3 border-b py-3"
|
|
334
|
+
style={{ '--sp-stagger': 1 } as React.CSSProperties}
|
|
335
|
+
>
|
|
336
|
+
{facts.map(([label, value]) => (
|
|
337
|
+
<div key={label} className="flex items-baseline gap-2.5">
|
|
338
|
+
<dd className="text-xl tabular-nums">{value}</dd>
|
|
339
|
+
<dt className="text-[0.65rem] tracking-[0.18em] text-muted-foreground">
|
|
340
|
+
{label.toUpperCase()}
|
|
341
|
+
</dt>
|
|
342
|
+
</div>
|
|
343
|
+
))}
|
|
344
|
+
<div className="ml-auto text-[0.65rem] tracking-[0.18em] text-muted-foreground">
|
|
345
|
+
COMPILED {new Date(compiledAt).toISOString().slice(0, 10)}
|
|
346
|
+
</div>
|
|
347
|
+
</dl>
|
|
348
|
+
|
|
349
|
+
{/* legend */}
|
|
350
|
+
<div
|
|
351
|
+
className="sp-rise -mt-8 flex flex-wrap items-center gap-x-6 gap-y-2 text-[0.65rem] tracking-[0.14em] text-muted-foreground"
|
|
352
|
+
style={{ '--sp-stagger': 2 } as React.CSSProperties}
|
|
353
|
+
>
|
|
354
|
+
<span className="flex items-center gap-2">
|
|
355
|
+
<span className="sp-mark" data-state="ok" /> VERIFIED
|
|
356
|
+
</span>
|
|
357
|
+
<span className="flex items-center gap-2">
|
|
358
|
+
<span className="sp-mark" data-state="gap" /> NO TEST
|
|
359
|
+
</span>
|
|
360
|
+
<span className="flex items-center gap-2">
|
|
361
|
+
<span className="sp-mark" data-state="undoc" /> UNDOCUMENTED
|
|
362
|
+
</span>
|
|
363
|
+
</div>
|
|
364
|
+
|
|
365
|
+
{/* tag sections */}
|
|
366
|
+
{report.operationCount === 0 ? (
|
|
367
|
+
<section
|
|
368
|
+
className="sp-rise border border-dashed px-6 py-10 text-center"
|
|
369
|
+
style={{ '--sp-stagger': 3 } as React.CSSProperties}
|
|
370
|
+
>
|
|
371
|
+
<p className="text-sm font-semibold tracking-[0.14em]">NO API DEFINITION PROVIDED</p>
|
|
372
|
+
<div className="mt-4 flex flex-col gap-1 text-xs text-muted-foreground">
|
|
373
|
+
<code className="text-foreground/70">SPECPROOF_REPO=/path/to/repo</code>
|
|
374
|
+
<code className="text-foreground/70">bun run generate:proof</code>
|
|
375
|
+
</div>
|
|
376
|
+
</section>
|
|
377
|
+
) : (
|
|
378
|
+
report.tags.map((tag, i) => (
|
|
379
|
+
<TagSection key={tag.tag} tag={tag} index={i} onShowEvidence={setEvidence} />
|
|
380
|
+
))
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<EvidencePanel evidence={evidence} onClose={() => setEvidence(null)} />
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
|
5
|
+
import { ChevronDown } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
import { cn } from "@/lib/utils"
|
|
8
|
+
|
|
9
|
+
const Accordion = AccordionPrimitive.Root
|
|
10
|
+
|
|
11
|
+
const AccordionItem = React.forwardRef<
|
|
12
|
+
React.ElementRef<typeof AccordionPrimitive.Item>,
|
|
13
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
|
14
|
+
>(({ className, ...props }, ref) => (
|
|
15
|
+
<AccordionPrimitive.Item
|
|
16
|
+
ref={ref}
|
|
17
|
+
className={cn("border-b", className)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
))
|
|
21
|
+
AccordionItem.displayName = "AccordionItem"
|
|
22
|
+
|
|
23
|
+
const AccordionTrigger = React.forwardRef<
|
|
24
|
+
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
|
25
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
|
26
|
+
>(({ className, children, ...props }, ref) => (
|
|
27
|
+
<AccordionPrimitive.Header className="flex">
|
|
28
|
+
<AccordionPrimitive.Trigger
|
|
29
|
+
ref={ref}
|
|
30
|
+
className={cn(
|
|
31
|
+
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
|
|
32
|
+
className
|
|
33
|
+
)}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
{children}
|
|
37
|
+
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
|
38
|
+
</AccordionPrimitive.Trigger>
|
|
39
|
+
</AccordionPrimitive.Header>
|
|
40
|
+
))
|
|
41
|
+
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
|
42
|
+
|
|
43
|
+
const AccordionContent = React.forwardRef<
|
|
44
|
+
React.ElementRef<typeof AccordionPrimitive.Content>,
|
|
45
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
|
46
|
+
>(({ className, children, ...props }, ref) => (
|
|
47
|
+
<AccordionPrimitive.Content
|
|
48
|
+
ref={ref}
|
|
49
|
+
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
|
53
|
+
</AccordionPrimitive.Content>
|
|
54
|
+
))
|
|
55
|
+
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
|
56
|
+
|
|
57
|
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
|
5
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
6
|
+
import { X } from "lucide-react"
|
|
7
|
+
|
|
8
|
+
import { cn } from "@/lib/utils"
|
|
9
|
+
|
|
10
|
+
const Sheet = SheetPrimitive.Root
|
|
11
|
+
|
|
12
|
+
const SheetTrigger = SheetPrimitive.Trigger
|
|
13
|
+
|
|
14
|
+
const SheetClose = SheetPrimitive.Close
|
|
15
|
+
|
|
16
|
+
const SheetPortal = SheetPrimitive.Portal
|
|
17
|
+
|
|
18
|
+
const SheetOverlay = React.forwardRef<
|
|
19
|
+
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
|
20
|
+
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
|
21
|
+
>(({ className, ...props }, ref) => (
|
|
22
|
+
<SheetPrimitive.Overlay
|
|
23
|
+
className={cn(
|
|
24
|
+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
25
|
+
className
|
|
26
|
+
)}
|
|
27
|
+
{...props}
|
|
28
|
+
ref={ref}
|
|
29
|
+
/>
|
|
30
|
+
))
|
|
31
|
+
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
|
32
|
+
|
|
33
|
+
const sheetVariants = cva(
|
|
34
|
+
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
35
|
+
{
|
|
36
|
+
variants: {
|
|
37
|
+
side: {
|
|
38
|
+
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
|
39
|
+
bottom:
|
|
40
|
+
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
|
41
|
+
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
|
42
|
+
right:
|
|
43
|
+
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
defaultVariants: {
|
|
47
|
+
side: "right",
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
interface SheetContentProps
|
|
53
|
+
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
|
54
|
+
VariantProps<typeof sheetVariants> {}
|
|
55
|
+
|
|
56
|
+
const SheetContent = React.forwardRef<
|
|
57
|
+
React.ElementRef<typeof SheetPrimitive.Content>,
|
|
58
|
+
SheetContentProps
|
|
59
|
+
>(({ side = "right", className, children, ...props }, ref) => (
|
|
60
|
+
<SheetPortal>
|
|
61
|
+
<SheetOverlay />
|
|
62
|
+
<SheetPrimitive.Content
|
|
63
|
+
ref={ref}
|
|
64
|
+
className={cn(sheetVariants({ side }), className)}
|
|
65
|
+
{...props}
|
|
66
|
+
>
|
|
67
|
+
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
|
68
|
+
<X className="h-4 w-4" />
|
|
69
|
+
<span className="sr-only">Close</span>
|
|
70
|
+
</SheetPrimitive.Close>
|
|
71
|
+
{children}
|
|
72
|
+
</SheetPrimitive.Content>
|
|
73
|
+
</SheetPortal>
|
|
74
|
+
))
|
|
75
|
+
SheetContent.displayName = SheetPrimitive.Content.displayName
|
|
76
|
+
|
|
77
|
+
const SheetHeader = ({
|
|
78
|
+
className,
|
|
79
|
+
...props
|
|
80
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
81
|
+
<div
|
|
82
|
+
className={cn(
|
|
83
|
+
"flex flex-col space-y-2 text-center sm:text-left",
|
|
84
|
+
className
|
|
85
|
+
)}
|
|
86
|
+
{...props}
|
|
87
|
+
/>
|
|
88
|
+
)
|
|
89
|
+
SheetHeader.displayName = "SheetHeader"
|
|
90
|
+
|
|
91
|
+
const SheetFooter = ({
|
|
92
|
+
className,
|
|
93
|
+
...props
|
|
94
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
95
|
+
<div
|
|
96
|
+
className={cn(
|
|
97
|
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
98
|
+
className
|
|
99
|
+
)}
|
|
100
|
+
{...props}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
SheetFooter.displayName = "SheetFooter"
|
|
104
|
+
|
|
105
|
+
const SheetTitle = React.forwardRef<
|
|
106
|
+
React.ElementRef<typeof SheetPrimitive.Title>,
|
|
107
|
+
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
|
108
|
+
>(({ className, ...props }, ref) => (
|
|
109
|
+
<SheetPrimitive.Title
|
|
110
|
+
ref={ref}
|
|
111
|
+
className={cn("text-lg font-semibold text-foreground", className)}
|
|
112
|
+
{...props}
|
|
113
|
+
/>
|
|
114
|
+
))
|
|
115
|
+
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
|
116
|
+
|
|
117
|
+
const SheetDescription = React.forwardRef<
|
|
118
|
+
React.ElementRef<typeof SheetPrimitive.Description>,
|
|
119
|
+
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
|
120
|
+
>(({ className, ...props }, ref) => (
|
|
121
|
+
<SheetPrimitive.Description
|
|
122
|
+
ref={ref}
|
|
123
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
124
|
+
{...props}
|
|
125
|
+
/>
|
|
126
|
+
))
|
|
127
|
+
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
|
128
|
+
|
|
129
|
+
export {
|
|
130
|
+
Sheet,
|
|
131
|
+
SheetPortal,
|
|
132
|
+
SheetOverlay,
|
|
133
|
+
SheetTrigger,
|
|
134
|
+
SheetClose,
|
|
135
|
+
SheetContent,
|
|
136
|
+
SheetHeader,
|
|
137
|
+
SheetFooter,
|
|
138
|
+
SheetTitle,
|
|
139
|
+
SheetDescription,
|
|
140
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import js from "@eslint/js";
|
|
2
|
+
import globals from "globals";
|
|
3
|
+
import tseslint from "typescript-eslint";
|
|
4
|
+
import pluginReact from "eslint-plugin-react";
|
|
5
|
+
import pluginReactHooks from "eslint-plugin-react-hooks";
|
|
6
|
+
|
|
7
|
+
export default [
|
|
8
|
+
{
|
|
9
|
+
ignores: ["**/.next/**", "**/node_modules/**", "**/dist/**", "**/build/**", "**/coverage/**"],
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
|
13
|
+
languageOptions: {
|
|
14
|
+
globals: {
|
|
15
|
+
...globals.browser,
|
|
16
|
+
...globals.node,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
js.configs.recommended,
|
|
21
|
+
...tseslint.configs.recommended,
|
|
22
|
+
{
|
|
23
|
+
plugins: {
|
|
24
|
+
react: pluginReact,
|
|
25
|
+
"react-hooks": pluginReactHooks,
|
|
26
|
+
},
|
|
27
|
+
rules: {
|
|
28
|
+
...pluginReact.configs.recommended.rules,
|
|
29
|
+
...pluginReactHooks.configs.recommended.rules,
|
|
30
|
+
// Disable React JSX scope requirement for Next.js
|
|
31
|
+
"react/react-in-jsx-scope": "off",
|
|
32
|
+
// Disable prop-types for TypeScript projects
|
|
33
|
+
"react/prop-types": "off",
|
|
34
|
+
// Allow unused variables that start with underscore
|
|
35
|
+
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
|
36
|
+
},
|
|
37
|
+
settings: {
|
|
38
|
+
react: {
|
|
39
|
+
version: "detect",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
];
|