@panaversity/ksor 0.0.27 → 0.0.28

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 (28) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/cli.mjs +67 -4
  3. package/dist/{gateway-api-BF06IsJ--D-eI--yB.mjs → gateway-api-8lNruq9e-CuohjtoK.mjs} +1 -1
  4. package/dist/gateway.mjs +1 -1
  5. package/package.json +3 -3
  6. package/templates/scaffold/.agents/skills/format-checker/check.mjs +83 -2
  7. package/templates/scaffold/.claude/skills/format-checker/check.mjs +83 -2
  8. package/templates/scaffold/AGENTS.md +49 -0
  9. package/templates/scaffold/README.md +3 -2
  10. package/templates/scaffold/knowledge/what-is-a-ksor.flashcards.yaml +25 -0
  11. package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +15 -0
  12. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +45 -7
  13. package/templates/scaffold/system/site/components/document-actions.tsx +106 -0
  14. package/templates/scaffold/system/site/components/flashcards.tsx +743 -0
  15. package/templates/scaffold/system/site/components/governance.tsx +17 -25
  16. package/templates/scaffold/system/site/components/record-views.tsx +241 -0
  17. package/templates/scaffold/system/site/components/study-aids.tsx +61 -0
  18. package/templates/scaffold/system/site/components/ui/card.tsx +76 -0
  19. package/templates/scaffold/system/site/components/ui/dropdown-menu.tsx +229 -0
  20. package/templates/scaffold/system/site/components/ui/progress.tsx +29 -0
  21. package/templates/scaffold/system/site/lib/attachment-rule.ts +124 -0
  22. package/templates/scaffold/system/site/lib/attachments.ts +105 -0
  23. package/templates/scaffold/system/site/lib/deck.ts +59 -0
  24. package/templates/scaffold/system/site/lib/reading-time.ts +45 -0
  25. package/templates/scaffold/system/site/lib/srs.ts +219 -0
  26. package/templates/scaffold/system/site/lib/stage-knowledge.ts +68 -0
  27. package/templates/scaffold/system/site/source.config.ts +54 -1
  28. package/templates/scaffold/system/site/components/copy-markdown.tsx +0 -70
@@ -1,7 +1,6 @@
1
- import { FileText } from "lucide-react";
2
1
  import Link from "next/link";
3
2
 
4
- import { CopyMarkdown } from "@/components/copy-markdown";
3
+ import { DocumentActions } from "@/components/document-actions";
5
4
  import type { ReactElement } from "react";
6
5
 
7
6
  import {
@@ -182,29 +181,22 @@ export function GovernanceMeta({
182
181
  // On the governance row, not as a footnote below the sources: it is
183
182
  // how a reader hands this document to an agent, and it was previously
184
183
  // the smallest text on the page, last (research/site-design.md F2).
185
- // These are the ACTIONS on a row of FACTS, so they wear neither the
186
- // bordered badge (that means "a status the record declares", and
187
- // dressing a link in it made the only clickable thing look like another
188
- // read-only field) nor the accent at rest. An icon and a hover surface
189
- // say "control" without spending the brand colour on something that is
190
- // merely sitting there — the page had gone blue enough that the accent
191
- // had stopped meaning anything (owner, 2026-08-22).
192
- // Inline after the facts, NOT right-aligned: `ms-auto` parked it at the
193
- // far edge of a 900px row, 498px from the nearest thing on a document
194
- // with two facts, where it read as belonging to nothing (measured in
195
- // Chromium, 2026-08-21).
196
- <span className="flex items-center gap-0.5">
197
- <a
198
- href={markdownUrl}
199
- className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 font-mono text-[0.6875rem] tracking-[0.14em] text-fd-muted-foreground uppercase transition-colors hover:bg-fd-muted hover:text-fd-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-ring"
200
- >
201
- <FileText aria-hidden className="size-3.5" />
202
- Markdown
203
- </a>
204
- {/* Beside the link, not instead of it: opening the markdown and
205
- handing it to an agent are different acts, and a reader who wants
206
- the second should not have to perform the first. */}
207
- <CopyMarkdown href={markdownUrl} />
184
+ // ONE control rather than two: opening the markdown and copying it are
185
+ // different acts, but two bare controls on a row of read-only facts
186
+ // made the row look half clickable. It wears neither the bordered badge
187
+ // (that means "a status the record declares") nor the accent at rest
188
+ // the page had gone blue enough that the accent had stopped meaning
189
+ // anything (owner, 2026-08-22).
190
+ // RIGHT-ALIGNED (owner, and a reversal). The 2026-08-21 finding stands
191
+ // on its own terms: `ms-auto` once parked this 498px from the nearest
192
+ // thing on a two-fact document, where it read as belonging to nothing.
193
+ // What changed is the column beside it the reading time now sits at
194
+ // the right end of the strip directly below, so the far edge is no
195
+ // longer empty space but a line the eye already follows. The two form
196
+ // a right-hand column of things you DO, against a left-hand row of
197
+ // things the record DECLARES.
198
+ <span className="ms-auto">
199
+ <DocumentActions href={markdownUrl} />
208
200
  </span>
209
201
  )}
210
202
  </dl>
@@ -0,0 +1,241 @@
1
+ "use client";
2
+
3
+ import { Clock, FileText, ListTree } from "lucide-react";
4
+ import {
5
+ useCallback,
6
+ useEffect,
7
+ useId,
8
+ useRef,
9
+ useState,
10
+ type ReactElement,
11
+ type ReactNode,
12
+ } from "react";
13
+
14
+ /**
15
+ * The two READINGS of one document: the record's own words, and its summary.
16
+ *
17
+ * Study aids are NOT here. A deck — and the quiz that will sit beside it — is
18
+ * something you do AFTER reading, so it belongs at the end of the page rather
19
+ * than behind a tab that hides the document while you use it. See
20
+ * components/study-aids.tsx.
21
+ *
22
+ * PRESENCE-DRIVEN. With neither attachment there is no tab strip at all — not a
23
+ * disabled tab, not a greyed affordance, not an empty panel. A control that
24
+ * cannot do anything is worse than no control, and a governed record whose
25
+ * documents mostly have no study attachments would otherwise grow a row of dead
26
+ * chrome on every page.
27
+ *
28
+ * EVERY PANEL IS IN THE SERVER-RENDERED HTML, hidden rather than unmounted.
29
+ * The panels arrive as props — React elements built on the server — so an agent
30
+ * parsing the page, a crawler, and a reader whose bundle failed all get the
31
+ * text. A summary that only exists once JavaScript runs is a summary missing
32
+ * from the printout, which is the same argument governance.tsx makes for
33
+ * rendering governance as plain markup.
34
+ */
35
+
36
+ type ViewId = "document" | "summary";
37
+
38
+ interface View {
39
+ readonly id: ViewId;
40
+ readonly label: string;
41
+ readonly icon: ReactElement;
42
+ readonly panel: ReactNode;
43
+ /** About how long this reading takes, in minutes. */
44
+ readonly minutes?: number | undefined;
45
+ }
46
+
47
+ export interface RecordViewsProps {
48
+ /** The document's own body — always present. */
49
+ readonly children: ReactNode;
50
+ /** Rendered summary, or null when the document has none. */
51
+ readonly summary: ReactNode | null;
52
+ /** Minutes to read the document, and the summary if there is one. */
53
+ readonly documentMinutes?: number | undefined;
54
+ readonly summaryMinutes?: number | undefined;
55
+ }
56
+
57
+ const ICON = "size-3.5 shrink-0";
58
+
59
+ export function RecordViews({
60
+ children,
61
+ summary,
62
+ documentMinutes,
63
+ summaryMinutes,
64
+ }: RecordViewsProps): ReactElement {
65
+ const views: View[] = [
66
+ {
67
+ id: "document",
68
+ label: "Document",
69
+ icon: <FileText className={ICON} />,
70
+ panel: children,
71
+ minutes: documentMinutes,
72
+ },
73
+ ];
74
+ if (summary !== null) {
75
+ views.push({
76
+ id: "summary",
77
+ label: "Summary",
78
+ icon: <ListTree className={ICON} />,
79
+ panel: summary,
80
+ minutes: summaryMinutes,
81
+ });
82
+ }
83
+
84
+ // No attachment, no chrome. Returned before any hook state matters — the
85
+ // hooks below still run, because a conditional hook is a different bug.
86
+ const only = views.length === 1;
87
+
88
+ const [active, setActive] = useState<ViewId>("document");
89
+ const activeMinutes = views.find((view) => view.id === active)?.minutes;
90
+ const tabRefs = useRef(new Map<ViewId, HTMLButtonElement>());
91
+ const base = useId();
92
+ const tabId = (id: ViewId): string => `${base}-tab-${id}`;
93
+ const panelId = (id: ViewId): string => `${base}-panel-${id}`;
94
+
95
+ /**
96
+ * The view is in the URL fragment, so a summary is linkable and survives a
97
+ * reload. Read on mount rather than during render: the server has no
98
+ * fragment, and choosing a tab from it during render would hydrate a tree the
99
+ * server never produced.
100
+ */
101
+ useEffect(() => {
102
+ const fromHash = window.location.hash.replace("#", "");
103
+ if (fromHash === "summary") {
104
+ setActive((current) =>
105
+ views.some((v) => v.id === fromHash) ? (fromHash as ViewId) : current,
106
+ );
107
+ }
108
+ // Views are derived from props that do not change for a given page.
109
+ // eslint-disable-next-line react-hooks/exhaustive-deps
110
+ }, []);
111
+
112
+ const select = useCallback((id: ViewId, focus = false) => {
113
+ setActive(id);
114
+ // replaceState, not a hash assignment: assigning location.hash scrolls the
115
+ // matching element to the top of the viewport, which on a tab strip means
116
+ // the page jumps every time a reader changes view.
117
+ // pathname + search, not pathname alone: dropping back to the document
118
+ // view must not silently discard a query string the reader arrived with.
119
+ const base = `${window.location.pathname}${window.location.search}`;
120
+ window.history.replaceState(null, "", id === "document" ? base : `${base}#${id}`);
121
+ if (focus) tabRefs.current.get(id)?.focus();
122
+ }, []);
123
+
124
+ const onKeyDown = useCallback(
125
+ (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
126
+ const last = views.length - 1;
127
+ const to =
128
+ event.key === "ArrowRight"
129
+ ? index === last
130
+ ? 0
131
+ : index + 1
132
+ : event.key === "ArrowLeft"
133
+ ? index === 0
134
+ ? last
135
+ : index - 1
136
+ : event.key === "Home"
137
+ ? 0
138
+ : event.key === "End"
139
+ ? last
140
+ : null;
141
+ if (to === null) return;
142
+ event.preventDefault();
143
+ const target = views[to];
144
+ if (target !== undefined) select(target.id, true);
145
+ },
146
+ [select, views],
147
+ );
148
+
149
+ // No summary, so no tabs — but the strip row still carries the reading time.
150
+ // It sits here rather than up with the title because this row is about the
151
+ // reading you are ABOUT to do, and a lone line above the facts row read as
152
+ // floating between two things it belonged to neither of.
153
+ if (only) {
154
+ return (
155
+ <div className="ksor-views">
156
+ {documentMinutes === undefined ? null : (
157
+ <p className="mb-6 flex items-center justify-end gap-2 border-b border-fd-border pb-2.5 text-sm text-fd-muted-foreground">
158
+ <Clock aria-hidden className="size-3.5 shrink-0" />
159
+ <span>{documentMinutes} min read</span>
160
+ </p>
161
+ )}
162
+ {children}
163
+ </div>
164
+ );
165
+ }
166
+
167
+ return (
168
+ <div className="ksor-views">
169
+ {/* The rule spans the whole strip, and the clock sits at its far end.
170
+ ONE clock rather than a number on every tab: the reader is on one view
171
+ at a time, and the time they care about is the time for the view they
172
+ are looking at. The rule is on this wrapper rather than the tablist so
173
+ it runs the full width beneath both. */}
174
+ <div className="mb-6 flex items-center justify-between gap-4 border-b border-fd-border">
175
+ <div role="tablist" aria-label="Views of this document" className="flex gap-1">
176
+ {views.map((view, index) => {
177
+ const selected = view.id === active;
178
+ return (
179
+ <button
180
+ key={view.id}
181
+ ref={(node) => {
182
+ if (node) tabRefs.current.set(view.id, node);
183
+ }}
184
+ type="button"
185
+ role="tab"
186
+ id={tabId(view.id)}
187
+ aria-selected={selected}
188
+ aria-controls={panelId(view.id)}
189
+ // The visible minutes belong to the SELECTED view only, so each
190
+ // tab carries its own in its accessible name — otherwise a
191
+ // screen-reader user could not compare them without switching.
192
+ {...(view.minutes === undefined
193
+ ? {}
194
+ : {
195
+ "aria-label": `${view.label}, ${view.minutes} minute${view.minutes === 1 ? "" : "s"} to read`,
196
+ })}
197
+ // Roving tabIndex: one stop for the whole strip, arrows move
198
+ // within it. Every tab being a tab stop is the common mistake and
199
+ // makes a keyboard reader walk the chrome before the prose.
200
+ tabIndex={selected ? 0 : -1}
201
+ onClick={() => select(view.id)}
202
+ onKeyDown={(event) => onKeyDown(event, index)}
203
+ className={[
204
+ "-mb-px flex items-center gap-2 border-b-2 px-3 py-2 font-mono text-xs tracking-wide transition-colors",
205
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fd-primary",
206
+ selected
207
+ ? "border-fd-primary text-fd-foreground"
208
+ : "border-transparent text-fd-muted-foreground hover:text-fd-foreground",
209
+ ].join(" ")}
210
+ >
211
+ {view.icon}
212
+ {view.label}
213
+ </button>
214
+ );
215
+ })}
216
+ </div>
217
+
218
+ {activeMinutes === undefined ? null : (
219
+ <span className="flex shrink-0 items-center gap-2 pb-2 text-sm text-fd-muted-foreground">
220
+ <Clock aria-hidden className="size-3.5 shrink-0" />
221
+ <span>{activeMinutes} min read</span>
222
+ </span>
223
+ )}
224
+ </div>
225
+
226
+ {views.map((view) => (
227
+ <div
228
+ key={view.id}
229
+ role="tabpanel"
230
+ id={panelId(view.id)}
231
+ aria-labelledby={tabId(view.id)}
232
+ hidden={view.id !== active}
233
+ // Not tabbable itself; its contents are. A tabpanel of prose with
234
+ // tabIndex={0} adds a stop that announces the whole document.
235
+ >
236
+ {view.panel}
237
+ </div>
238
+ ))}
239
+ </div>
240
+ );
241
+ }
@@ -0,0 +1,61 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+
3
+ /**
4
+ * The end-of-document region: what a reader DOES with a document once they
5
+ * have read it.
6
+ *
7
+ * The deck lives here, and the quiz will sit beside it. Deliberately not a tab:
8
+ * a study aid is used AFTER the document, and a tab would hide the document
9
+ * while you used it. Deliberately one region rather than each aid finding its
10
+ * own spot on the page, so a second aid is a child here and not a new layout
11
+ * argument.
12
+ *
13
+ * Renders NOTHING when the document has no aids — no heading, no rule, no
14
+ * empty region (specs/ksor/study-attachments C3).
15
+ */
16
+ export function StudyAids({ children }: { readonly children: ReactNode }): ReactElement | null {
17
+ const present = Array.isArray(children) ? children.some((c) => c) : Boolean(children);
18
+ if (!present) return null;
19
+
20
+ return (
21
+ <section
22
+ aria-label="Study aids"
23
+ // `not-prose`: this is site chrome around the record, not the record's
24
+ // prose, so the document type ramp must not reach into it.
25
+ className="not-prose mt-14 flex flex-col gap-14"
26
+ >
27
+ {children}
28
+ </section>
29
+ );
30
+ }
31
+
32
+ /**
33
+ * One aid's header: its own name in the record's serif, an accent bar, and the
34
+ * hairline that separates it from the document above.
35
+ *
36
+ * Shared so the deck and the quiz cannot drift into two different headings.
37
+ */
38
+ export function StudyAidHeader({
39
+ title,
40
+ description,
41
+ }: {
42
+ readonly title: string;
43
+ readonly description?: string | undefined;
44
+ }): ReactElement {
45
+ return (
46
+ <header className="mb-8">
47
+ <h2 className="font-(family-name:--font-display) text-2xl font-semibold tracking-tight text-fd-foreground">
48
+ {title}
49
+ </h2>
50
+ {/* A short accent bar over a full-width hairline: the rule says "a new
51
+ region starts here" and the bar says which one, without a second
52
+ colour or a boxed container. */}
53
+ <div className="mt-3 h-px w-full bg-fd-border">
54
+ <div className="h-[3px] w-24 -translate-y-px bg-fd-primary" />
55
+ </div>
56
+ {description === undefined ? null : (
57
+ <p className="mt-4 text-sm text-fd-muted-foreground">{description}</p>
58
+ )}
59
+ </header>
60
+ );
61
+ }
@@ -0,0 +1,76 @@
1
+ // Generated by `pnpm dlx shadcn@latest add card`. Yours to edit.
2
+ import * as React from "react";
3
+
4
+ import { cn } from "@/lib/utils";
5
+
6
+ function Card({ className, ...props }: React.ComponentProps<"div">) {
7
+ return (
8
+ <div
9
+ data-slot="card"
10
+ className={cn(
11
+ "flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
12
+ className,
13
+ )}
14
+ {...props}
15
+ />
16
+ );
17
+ }
18
+
19
+ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
20
+ return (
21
+ <div
22
+ data-slot="card-header"
23
+ className={cn(
24
+ "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
25
+ className,
26
+ )}
27
+ {...props}
28
+ />
29
+ );
30
+ }
31
+
32
+ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
33
+ return (
34
+ <div
35
+ data-slot="card-title"
36
+ className={cn("leading-none font-semibold", className)}
37
+ {...props}
38
+ />
39
+ );
40
+ }
41
+
42
+ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
43
+ return (
44
+ <div
45
+ data-slot="card-description"
46
+ className={cn("text-sm text-muted-foreground", className)}
47
+ {...props}
48
+ />
49
+ );
50
+ }
51
+
52
+ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
53
+ return (
54
+ <div
55
+ data-slot="card-action"
56
+ className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
57
+ {...props}
58
+ />
59
+ );
60
+ }
61
+
62
+ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
63
+ return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
64
+ }
65
+
66
+ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
67
+ return (
68
+ <div
69
+ data-slot="card-footer"
70
+ className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
71
+ {...props}
72
+ />
73
+ );
74
+ }
75
+
76
+ export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
@@ -0,0 +1,229 @@
1
+ // Generated by `pnpm dlx shadcn@latest add dropdown-menu`. Yours to edit.
2
+ "use client";
3
+
4
+ import * as React from "react";
5
+ import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
6
+ import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
7
+
8
+ import { cn } from "@/lib/utils";
9
+
10
+ function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
11
+ return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
12
+ }
13
+
14
+ function DropdownMenuPortal({
15
+ ...props
16
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
17
+ return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
18
+ }
19
+
20
+ function DropdownMenuTrigger({
21
+ ...props
22
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
23
+ return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
24
+ }
25
+
26
+ function DropdownMenuContent({
27
+ className,
28
+ sideOffset = 4,
29
+ ...props
30
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
31
+ return (
32
+ <DropdownMenuPrimitive.Portal>
33
+ <DropdownMenuPrimitive.Content
34
+ data-slot="dropdown-menu-content"
35
+ sideOffset={sideOffset}
36
+ className={cn(
37
+ "z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
38
+ className,
39
+ )}
40
+ {...props}
41
+ />
42
+ </DropdownMenuPrimitive.Portal>
43
+ );
44
+ }
45
+
46
+ function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
47
+ return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
48
+ }
49
+
50
+ function DropdownMenuItem({
51
+ className,
52
+ inset,
53
+ variant = "default",
54
+ ...props
55
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
56
+ inset?: boolean;
57
+ variant?: "default" | "destructive";
58
+ }) {
59
+ return (
60
+ <DropdownMenuPrimitive.Item
61
+ data-slot="dropdown-menu-item"
62
+ data-inset={inset}
63
+ data-variant={variant}
64
+ className={cn(
65
+ "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
66
+ className,
67
+ )}
68
+ {...props}
69
+ />
70
+ );
71
+ }
72
+
73
+ function DropdownMenuCheckboxItem({
74
+ className,
75
+ children,
76
+ checked,
77
+ ...props
78
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
79
+ return (
80
+ <DropdownMenuPrimitive.CheckboxItem
81
+ data-slot="dropdown-menu-checkbox-item"
82
+ className={cn(
83
+ "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
84
+ className,
85
+ )}
86
+ checked={checked}
87
+ {...props}
88
+ >
89
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
90
+ <DropdownMenuPrimitive.ItemIndicator>
91
+ <CheckIcon className="size-4" />
92
+ </DropdownMenuPrimitive.ItemIndicator>
93
+ </span>
94
+ {children}
95
+ </DropdownMenuPrimitive.CheckboxItem>
96
+ );
97
+ }
98
+
99
+ function DropdownMenuRadioGroup({
100
+ ...props
101
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
102
+ return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
103
+ }
104
+
105
+ function DropdownMenuRadioItem({
106
+ className,
107
+ children,
108
+ ...props
109
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
110
+ return (
111
+ <DropdownMenuPrimitive.RadioItem
112
+ data-slot="dropdown-menu-radio-item"
113
+ className={cn(
114
+ "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
115
+ className,
116
+ )}
117
+ {...props}
118
+ >
119
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
120
+ <DropdownMenuPrimitive.ItemIndicator>
121
+ <CircleIcon className="size-2 fill-current" />
122
+ </DropdownMenuPrimitive.ItemIndicator>
123
+ </span>
124
+ {children}
125
+ </DropdownMenuPrimitive.RadioItem>
126
+ );
127
+ }
128
+
129
+ function DropdownMenuLabel({
130
+ className,
131
+ inset,
132
+ ...props
133
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
134
+ inset?: boolean;
135
+ }) {
136
+ return (
137
+ <DropdownMenuPrimitive.Label
138
+ data-slot="dropdown-menu-label"
139
+ data-inset={inset}
140
+ className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
141
+ {...props}
142
+ />
143
+ );
144
+ }
145
+
146
+ function DropdownMenuSeparator({
147
+ className,
148
+ ...props
149
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
150
+ return (
151
+ <DropdownMenuPrimitive.Separator
152
+ data-slot="dropdown-menu-separator"
153
+ className={cn("-mx-1 my-1 h-px bg-border", className)}
154
+ {...props}
155
+ />
156
+ );
157
+ }
158
+
159
+ function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
160
+ return (
161
+ <span
162
+ data-slot="dropdown-menu-shortcut"
163
+ className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
164
+ {...props}
165
+ />
166
+ );
167
+ }
168
+
169
+ function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
170
+ return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
171
+ }
172
+
173
+ function DropdownMenuSubTrigger({
174
+ className,
175
+ inset,
176
+ children,
177
+ ...props
178
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
179
+ inset?: boolean;
180
+ }) {
181
+ return (
182
+ <DropdownMenuPrimitive.SubTrigger
183
+ data-slot="dropdown-menu-sub-trigger"
184
+ data-inset={inset}
185
+ className={cn(
186
+ "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
187
+ className,
188
+ )}
189
+ {...props}
190
+ >
191
+ {children}
192
+ <ChevronRightIcon className="ml-auto size-4" />
193
+ </DropdownMenuPrimitive.SubTrigger>
194
+ );
195
+ }
196
+
197
+ function DropdownMenuSubContent({
198
+ className,
199
+ ...props
200
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
201
+ return (
202
+ <DropdownMenuPrimitive.SubContent
203
+ data-slot="dropdown-menu-sub-content"
204
+ className={cn(
205
+ "z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
206
+ className,
207
+ )}
208
+ {...props}
209
+ />
210
+ );
211
+ }
212
+
213
+ export {
214
+ DropdownMenu,
215
+ DropdownMenuPortal,
216
+ DropdownMenuTrigger,
217
+ DropdownMenuContent,
218
+ DropdownMenuGroup,
219
+ DropdownMenuLabel,
220
+ DropdownMenuItem,
221
+ DropdownMenuCheckboxItem,
222
+ DropdownMenuRadioGroup,
223
+ DropdownMenuRadioItem,
224
+ DropdownMenuSeparator,
225
+ DropdownMenuShortcut,
226
+ DropdownMenuSub,
227
+ DropdownMenuSubTrigger,
228
+ DropdownMenuSubContent,
229
+ };