@cosmicdrift/kumiko-renderer-web 0.233.0 → 0.234.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.
@@ -8,7 +8,7 @@
8
8
  // basierte Stile. Radix-UI-Unterbau für interaktive Elemente (Modal,
9
9
  // Dropdown etc. kommen später).
10
10
 
11
- import type { FieldIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
11
+ import type { FieldIconKey, IconKey } from "@cosmicdrift/kumiko-framework/ui-types";
12
12
  import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
13
13
  import { applyFormatSpec, isSafeHref } from "@cosmicdrift/kumiko-headless";
14
14
  import type {
@@ -35,6 +35,8 @@ import {
35
35
  type ProgressProps,
36
36
  type SectionProps,
37
37
  type StepBarProps,
38
+ shouldRenderActionsIconOnly,
39
+ statusToneForValue,
38
40
  type TextProps,
39
41
  useColumnRenderer,
40
42
  useOptionalLocale,
@@ -81,16 +83,18 @@ import {
81
83
  useRef,
82
84
  useState,
83
85
  } from "react";
84
- import { NAV_ICONS } from "../icons";
86
+ import { Icon, NAV_ICONS } from "../icons";
85
87
  import { cn } from "../lib/cn";
86
88
  import { Badge } from "../ui/badge";
87
89
  import { buttonVariants, Button as UiButton } from "../ui/button";
88
90
  import { Checkbox } from "../ui/checkbox";
89
91
  import { Input as UiInput } from "../ui/input";
90
92
  import { Label as UiLabel } from "../ui/label";
93
+ import { Switch } from "../ui/switch";
91
94
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
92
95
  import { Textarea } from "../ui/textarea";
93
96
  import { ProgressBar } from "../widgets/progress-bar";
97
+ import { StatusBadge } from "../widgets/status-badge";
94
98
  import { StepBar } from "../widgets/step-bar";
95
99
  import { ComboboxInput } from "./combobox";
96
100
  import { DateInput } from "./date-input";
@@ -132,7 +136,10 @@ const cardSurface = cva(
132
136
  );
133
137
  // Wraps instead of running off-screen when the row outgrows its container (fw#2528).
134
138
  const cardFooter = "flex flex-wrap items-center justify-end gap-2 px-[var(--card-padding)] py-4";
135
- const cardFooterBorder = "border-t bg-muted/30";
139
+ // /30 read as nearly invisible against the light-theme card (white card,
140
+ // muted at 94% lightness) — /50 keeps the same token, just a stronger step.
141
+ const cardFooterBorder = "border-t bg-muted/50";
142
+ const cardHeaderBorder = "border-b bg-muted/50";
136
143
 
137
144
  // ---- Button (vendored shadcn ui/button) ----
138
145
 
@@ -153,6 +160,14 @@ const BUTTON_SIZE = {
153
160
  icon: "icon",
154
161
  } as const;
155
162
 
163
+ // Closed IconKey vocabulary, checked against the shared NAV_ICONS registry
164
+ // (icons.tsx) — an unknown key (schema data isn't statically typed against
165
+ // IconKey the way this prop is) renders no icon instead of crashing, same
166
+ // fallback shape as fieldIconFor above.
167
+ function actionIconFor(icon: IconKey | undefined): IconKey | undefined {
168
+ return icon !== undefined && Object.hasOwn(NAV_ICONS, icon) ? icon : undefined;
169
+ }
170
+
156
171
  function DefaultButton({
157
172
  type = "button",
158
173
  onClick,
@@ -257,6 +272,13 @@ function fieldLabelId(id: string): string {
257
272
  return `${id}-label`;
258
273
  }
259
274
 
275
+ // Boolean optics depend on the surrounding Field layout: layout="inline"
276
+ // (checkbox lists like MultiSelectCheckboxes, the BooleanField widget) keeps
277
+ // the checkbox, the standard form field (layout="stacked"/default) gets the
278
+ // new switch. Pure web-renderer rendering decision, no contract field needed
279
+ // — Field hands its resolved layout down to the nested Input via context.
280
+ const FieldLayoutContext = createContext<FieldProps["layout"]>("stacked");
281
+
260
282
  function DefaultField({
261
283
  id,
262
284
  label,
@@ -305,7 +327,7 @@ function DefaultField({
305
327
  return (
306
328
  <div data-testid={testId} className="flex flex-col gap-1.5">
307
329
  <div className="flex items-center gap-2">
308
- {children}
330
+ <FieldLayoutContext.Provider value="inline">{children}</FieldLayoutContext.Provider>
309
331
  {labelEl}
310
332
  {labelAppendix !== undefined && labelAppendix}
311
333
  </div>
@@ -398,9 +420,116 @@ function withUnitSuffix(unit: string | undefined, input: ReactNode): ReactNode {
398
420
  );
399
421
  }
400
422
 
423
+ // Segmented control for `kind: "select"` with a small closed option set —
424
+ // a 4-value Status field looked wrong stretched into a full-width dropdown
425
+ // (edit-existing screenshot feedback). Purely a rendering choice inside the
426
+ // "select" branch below; the primitives contract is untouched (still
427
+ // `options` + string value/onChange).
428
+ const SEGMENTED_SELECT_MAX_OPTIONS = 4;
429
+ const SEGMENTED_SELECT_MAX_LABEL_LENGTH = 14;
430
+
431
+ function isSegmentedSelectEligible(options: readonly { readonly label: string }[]): boolean {
432
+ return (
433
+ options.length > 0 &&
434
+ options.length <= SEGMENTED_SELECT_MAX_OPTIONS &&
435
+ options.every((o) => o.label.length <= SEGMENTED_SELECT_MAX_LABEL_LENGTH)
436
+ );
437
+ }
438
+
439
+ // WAI-ARIA radiogroup pattern (role="radiogroup" + role="radio" children):
440
+ // arrow keys move focus AND selection in the same step, only the checked
441
+ // segment (or the first when none is checked) sits in the tab order.
442
+ function SegmentedSelect({
443
+ id,
444
+ name,
445
+ value,
446
+ onChange,
447
+ options,
448
+ disabled,
449
+ required,
450
+ hasError,
451
+ }: {
452
+ readonly id: string;
453
+ readonly name: string;
454
+ readonly value: string;
455
+ readonly onChange: (v: string) => void;
456
+ readonly options: readonly { readonly value: string; readonly label: string }[];
457
+ readonly disabled?: boolean;
458
+ readonly required?: boolean;
459
+ readonly hasError?: boolean;
460
+ }): ReactNode {
461
+ const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
462
+
463
+ const selectAt = (index: number): void => {
464
+ const target = options[index];
465
+ if (target === undefined) return;
466
+ onChange(target.value);
467
+ buttonRefs.current[index]?.focus();
468
+ };
469
+
470
+ const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number): void => {
471
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
472
+ e.preventDefault();
473
+ selectAt((index + 1) % options.length);
474
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
475
+ e.preventDefault();
476
+ selectAt((index - 1 + options.length) % options.length);
477
+ }
478
+ };
479
+
480
+ return (
481
+ <div
482
+ role="radiogroup"
483
+ aria-labelledby={fieldLabelId(id)}
484
+ aria-required={required}
485
+ aria-invalid={hasError === true ? true : undefined}
486
+ data-testid={`segmented-${id}`}
487
+ className={cn(
488
+ "inline-flex w-fit flex-wrap divide-x overflow-hidden rounded-md border",
489
+ hasError === true
490
+ ? "divide-destructive/50 border-destructive"
491
+ : "divide-border border-input",
492
+ )}
493
+ >
494
+ <input type="hidden" name={name} value={value} />
495
+ {options.map((opt, index) => {
496
+ const checked = opt.value === value;
497
+ return (
498
+ // biome-ignore lint/a11y/useSemanticElements: a native <input type="radio"> can't render the segment's label as content — button+role="radio" is the standard WAI-ARIA composite-widget substitute.
499
+ <button
500
+ key={opt.value}
501
+ ref={(node) => {
502
+ buttonRefs.current[index] = node;
503
+ }}
504
+ type="button"
505
+ role="radio"
506
+ aria-checked={checked}
507
+ tabIndex={checked || (value === "" && index === 0) ? 0 : -1}
508
+ disabled={disabled}
509
+ data-testid={`segmented-${id}-${opt.value}`}
510
+ onClick={() => onChange(opt.value)}
511
+ onKeyDown={(e) => handleKeyDown(e, index)}
512
+ className={cn(
513
+ "px-3 py-1.5 text-sm font-medium transition-colors",
514
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
515
+ "disabled:pointer-events-none disabled:opacity-50",
516
+ checked
517
+ ? "bg-primary text-primary-foreground"
518
+ : "bg-transparent text-foreground hover:bg-accent",
519
+ )}
520
+ >
521
+ {opt.label}
522
+ </button>
523
+ );
524
+ })}
525
+ </div>
526
+ );
527
+ }
528
+
401
529
  function DefaultInput(props: InputProps): ReactNode {
402
530
  // Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
403
531
  // selbst — kein manuelles border-destructive mehr nötig.
532
+ const booleanLayout = useContext(FieldLayoutContext);
404
533
  const common = {
405
534
  id: props.id,
406
535
  name: props.name,
@@ -484,7 +613,10 @@ function DefaultInput(props: InputProps): ReactNode {
484
613
  />
485
614
  );
486
615
  case "boolean":
487
- return (
616
+ // layout="inline" (checkbox lists like MultiSelectCheckboxes, the
617
+ // BooleanField widget) keeps the checkbox optics; the standard form
618
+ // field (render-field.tsx, layout="stacked") gets the switch.
619
+ return booleanLayout === "inline" ? (
488
620
  <Checkbox
489
621
  id={props.id}
490
622
  name={props.name}
@@ -494,6 +626,17 @@ function DefaultInput(props: InputProps): ReactNode {
494
626
  checked={props.value}
495
627
  onCheckedChange={(checked) => props.onChange(checked === true)}
496
628
  />
629
+ ) : (
630
+ <Switch
631
+ id={props.id}
632
+ name={props.name}
633
+ disabled={props.disabled}
634
+ aria-required={props.required}
635
+ aria-invalid={props.hasError === true ? true : undefined}
636
+ checked={props.value}
637
+ onCheckedChange={(checked) => props.onChange(checked === true)}
638
+ className="aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
639
+ />
497
640
  );
498
641
  case "file":
499
642
  case "image":
@@ -538,6 +681,20 @@ function DefaultInput(props: InputProps): ReactNode {
538
681
  const comboOptions = props.options.map((o) =>
539
682
  typeof o === "string" ? { value: o, label: o } : o,
540
683
  );
684
+ if (isSegmentedSelectEligible(comboOptions)) {
685
+ return (
686
+ <SegmentedSelect
687
+ id={props.id}
688
+ name={props.name}
689
+ value={props.value}
690
+ onChange={props.onChange}
691
+ options={comboOptions}
692
+ {...(props.disabled !== undefined && { disabled: props.disabled })}
693
+ {...(props.required !== undefined && { required: props.required })}
694
+ {...(props.hasError !== undefined && { hasError: props.hasError })}
695
+ />
696
+ );
697
+ }
541
698
  return (
542
699
  <ComboboxInput
543
700
  id={props.id}
@@ -1096,10 +1253,14 @@ function RowActionsCell({
1096
1253
  const visible = actions.filter((a) => a.isVisible === undefined || a.isVisible(row));
1097
1254
  if (visible.length === 0) return null;
1098
1255
  if (mode === "inline") {
1256
+ // Teil C: >2 actions all carrying an icon collapse to icon-only —
1257
+ // otherwise "inline" is exactly the wall-to-wall text-button problem
1258
+ // this feature exists to fix.
1259
+ const iconOnly = shouldRenderActionsIconOnly(visible);
1099
1260
  return (
1100
1261
  <div className="flex w-full items-center gap-1 justify-start">
1101
1262
  {visible.map((a) => (
1102
- <RowActionButton key={a.id} row={row} action={a} />
1263
+ <RowActionButton key={a.id} row={row} action={a} iconOnly={iconOnly} />
1103
1264
  ))}
1104
1265
  </div>
1105
1266
  );
@@ -1155,9 +1316,13 @@ function useRowActionTrigger(row: ListRowViewModel) {
1155
1316
  function RowActionButton({
1156
1317
  row,
1157
1318
  action,
1319
+ iconOnly = false,
1158
1320
  }: {
1159
1321
  readonly row: ListRowViewModel;
1160
1322
  readonly action: DataTableRowAction;
1323
+ /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
1324
+ * effect when this action actually resolved an icon. */
1325
+ readonly iconOnly?: boolean;
1161
1326
  }): ReactNode {
1162
1327
  const { busy, triggerNow } = useRowActionTrigger(row);
1163
1328
  const [confirmOpen, setConfirmOpen] = useState(false);
@@ -1169,12 +1334,16 @@ function RowActionButton({
1169
1334
  ? "text-primary hover:bg-primary/10"
1170
1335
  : "text-foreground hover:bg-accent";
1171
1336
 
1337
+ const resolvedIcon = actionIconFor(action.icon);
1338
+ const showIconOnly = iconOnly && resolvedIcon !== undefined;
1339
+
1172
1340
  return (
1173
1341
  <>
1174
1342
  <button
1175
1343
  type="button"
1176
1344
  data-testid={`row-${row.id}-action-${action.id}`}
1177
1345
  disabled={busy}
1346
+ {...(showIconOnly && { "aria-label": action.label, title: action.label })}
1178
1347
  onClick={(e) => {
1179
1348
  e.stopPropagation();
1180
1349
  if (needsConfirm(action)) {
@@ -1184,13 +1353,25 @@ function RowActionButton({
1184
1353
  }
1185
1354
  }}
1186
1355
  className={cn(
1187
- "inline-flex h-8 items-center justify-center rounded-sm px-2 text-sm",
1356
+ "inline-flex h-8 items-center justify-center gap-1.5 rounded-sm text-sm",
1357
+ showIconOnly ? "w-8" : "px-2",
1188
1358
  "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
1189
1359
  "disabled:opacity-50 disabled:pointer-events-none",
1190
1360
  variantClass,
1191
1361
  )}
1192
1362
  >
1193
- {busy ? <Loader2 className="size-3.5 animate-spin" aria-hidden="true" /> : action.label}
1363
+ {busy ? (
1364
+ <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
1365
+ ) : resolvedIcon === undefined ? (
1366
+ action.label
1367
+ ) : showIconOnly ? (
1368
+ <Icon name={resolvedIcon} className="size-4" />
1369
+ ) : (
1370
+ <>
1371
+ <Icon name={resolvedIcon} className="size-4" />
1372
+ {action.label}
1373
+ </>
1374
+ )}
1194
1375
  </button>
1195
1376
  <DefaultDialog
1196
1377
  open={confirmOpen}
@@ -1807,13 +1988,21 @@ function DataTableCell({
1807
1988
  // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
1808
1989
  console.warn(`[kumiko] columnRenderer "${componentRef.name}" not registered`);
1809
1990
  }
1810
- // select-Werte als neutrale Badge-Pill (shadcn secondary) statt Plain-Text.
1811
- // Farbige Status-Semantik (grün/amber) bleibt App-Sache via columnRenderer.
1991
+ // A select value renders as a pill instead of plain text. When the raw
1992
+ // value is a known status word, it carries the same tone as the
1993
+ // projectionDetail header badge — a status column stayed grey while the
1994
+ // detail view of the same value was coloured (fw#2579). Unknown values
1995
+ // keep the neutral outline pill.
1812
1996
  if (type === "select" && value !== null && value !== undefined && value !== "") {
1813
- // dashboard-01-Muster: outline-Badge + muted statt gefülltem secondary.
1997
+ const label = defaultCellRender(value, type, optionLabels, locale);
1998
+ const tone = typeof value === "string" ? statusToneForValue(value) : undefined;
1999
+ if (tone !== undefined) {
2000
+ return <StatusBadge tone={tone}>{label}</StatusBadge>;
2001
+ }
2002
+ // dashboard-01 pattern: outline badge + muted instead of a filled secondary.
1814
2003
  return (
1815
2004
  <Badge variant="outline" className="px-1.5 text-muted-foreground">
1816
- {defaultCellRender(value, type, optionLabels, locale)}
2005
+ {label}
1817
2006
  </Badge>
1818
2007
  );
1819
2008
  }
@@ -1896,7 +2085,7 @@ function DefaultForm({
1896
2085
  )}
1897
2086
  <div className={cn(cardSurface(), "overflow-hidden")}>
1898
2087
  {(title !== undefined || subtitle !== undefined) && (
1899
- <div className="px-6 pb-2 pt-5">
2088
+ <div className={cn(cardHeaderBorder, "px-6 pb-4 pt-5")}>
1900
2089
  {title !== undefined && (
1901
2090
  <h2
1902
2091
  data-testid={testId !== undefined ? `${testId}-title` : undefined}
@@ -1986,7 +2175,7 @@ export function FormScreenShell({
1986
2175
  children,
1987
2176
  className,
1988
2177
  testId,
1989
- maxWidth = "full",
2178
+ maxWidth = "4xl",
1990
2179
  }: {
1991
2180
  readonly children: ReactNode;
1992
2181
  readonly className?: string;
@@ -2010,20 +2199,24 @@ function DefaultSection({
2010
2199
  actions,
2011
2200
  variant = "default",
2012
2201
  testId,
2202
+ icon,
2013
2203
  }: SectionProps): ReactNode {
2014
2204
  const insideForm = useContext(InsideFormContext);
2015
2205
 
2016
2206
  // h3 statt CardTitle (= div): erhält die Heading-Semantik für
2017
2207
  // Screenreader-Navigation. Subtitle fließt darunter (kein Divider —
2018
2208
  // shadcn CardTitle+CardDescription-Muster).
2209
+ // icon only renders alongside a title — a title-less section has nothing
2210
+ // for a lone icon to sit next to, so it stays as-is (no heading grows).
2019
2211
  const header =
2020
2212
  title !== undefined || subtitle !== undefined ? (
2021
2213
  <div className="flex flex-col gap-1">
2022
2214
  {title !== undefined && (
2023
2215
  <h3
2024
2216
  data-testid={testId !== undefined ? `${testId}-title` : undefined}
2025
- className="text-base font-semibold leading-none tracking-tight"
2217
+ className="flex items-center gap-2 text-base font-semibold leading-none tracking-tight"
2026
2218
  >
2219
+ {icon !== undefined && <Icon name={icon} className="size-4 text-muted-foreground" />}
2027
2220
  {title}
2028
2221
  </h3>
2029
2222
  )}
@@ -2068,8 +2261,8 @@ function DefaultSection({
2068
2261
  );
2069
2262
  }
2070
2263
 
2071
- // Standalone: eigene Card, Header fließt in den Body (kein Divider).
2072
- // actions = abgehobene Footer-Row (border-t bg-muted/30, wie DefaultForm).
2264
+ // Standalone: own card, header flows into the body (no divider).
2265
+ // actions = raised footer row (cardFooterBorder, same as DefaultForm).
2073
2266
  // overflow-hidden clips the footer-corner radius correctly for portaled
2074
2267
  // overlays (Combobox/Select/Tooltip escape to document.body, unaffected).
2075
2268
  // A non-portaled overlay (e.g. a custom dropdown built directly into
@@ -1,17 +1,25 @@
1
1
  import type { MetricProps } from "@cosmicdrift/kumiko-renderer";
2
2
  import type { ReactNode } from "react";
3
- import { MiniStat } from "../widgets/stat";
4
3
 
4
+ // Borderless cell in the metrics-band Grid — `first:border-l-0` drops the
5
+ // divider on the first cell purely from DOM position, so the tiles read as
6
+ // one row of vertical dividers instead of individual cards (fw record-
7
+ // screen-type polish). Typo matches StatCard's label/value rhythm.
5
8
  export function DefaultMetric({ label, value, testId }: MetricProps): ReactNode {
6
9
  return (
7
- <MiniStat
8
- label={label}
9
- value={value}
10
- testId={testId}
11
- {...(testId !== undefined && {
12
- labelTestId: `${testId}-label`,
13
- valueTestId: `${testId}-value`,
14
- })}
15
- />
10
+ <div data-testid={testId} className="border-l first:border-l-0 px-4 py-3">
11
+ <div
12
+ className="text-xs text-muted-foreground"
13
+ data-testid={testId !== undefined ? `${testId}-label` : undefined}
14
+ >
15
+ {label}
16
+ </div>
17
+ <div
18
+ className="mt-0.5 text-xl font-semibold tabular-nums text-foreground"
19
+ data-testid={testId !== undefined ? `${testId}-value` : undefined}
20
+ >
21
+ {value}
22
+ </div>
23
+ </div>
16
24
  );
17
25
  }
package/src/styles.css CHANGED
@@ -38,14 +38,15 @@
38
38
  @custom-variant dark (&:where(.dark, .dark *));
39
39
 
40
40
  @theme {
41
- /* Dark-Mode (Default) — shadcn-Preset Rhea"/Neutral (hue 0, reines Grau —
42
- kein zinc-Blaustich). background == card; Cards trennen über Border +
43
- Shadow. Primary near-white, Apps überschreiben mit ihrer Brand. */
41
+ /* Dark-Mode (Default) — shadcn preset "Rhea"/Neutral (hue 0, pure gray,
42
+ no zinc blue tint). Card is raised above background (8% vs. 3.9%) so it
43
+ separates by surface color, not just border + shadow. Primary
44
+ near-white, apps override with their own brand. */
44
45
  --color-background: hsl(0 0% 3.9%);
45
46
  --color-foreground: hsl(0 0% 98%);
46
- --color-card: hsl(0 0% 3.9%);
47
+ --color-card: hsl(0 0% 8%);
47
48
  --color-card-foreground: hsl(0 0% 98%);
48
- --color-popover: hsl(0 0% 3.9%);
49
+ --color-popover: hsl(0 0% 9%);
49
50
  --color-popover-foreground: hsl(0 0% 98%);
50
51
  --color-primary: hsl(0 0% 98%);
51
52
  --color-primary-foreground: hsl(0 0% 9%);
@@ -107,10 +108,12 @@
107
108
  auf light, greifen diese Werte. */
108
109
  @layer base {
109
110
  :root:not(.dark) {
110
- /* Light-Mode — Neutral (hue 0, reines Grau). Weißes Background, Cards ==
111
- weiß, trennen über Border + Shadow. Primary near-black; Apps überschreiben
112
- mit Brand. Werte = shadcn-Preset (aus dem Live-Preview verifiziert). */
113
- --color-background: hsl(0 0% 100%);
111
+ /* Light-Mode — Neutral (hue 0, pure gray). Background is tinted slightly
112
+ (96.5%), card stays pure white card lifts off the page instead of
113
+ blending into it. Border + shadow remain additional cues. Primary
114
+ near-black; apps override with brand. Values = shadcn preset, adapted
115
+ to this hue-0 elevation scheme. */
116
+ --color-background: hsl(0 0% 96.5%);
114
117
  --color-foreground: hsl(0 0% 3.9%);
115
118
  --color-card: hsl(0 0% 100%);
116
119
  --color-card-foreground: hsl(0 0% 3.9%);
@@ -118,11 +121,11 @@
118
121
  --color-popover-foreground: hsl(0 0% 3.9%);
119
122
  --color-primary: hsl(0 0% 9%);
120
123
  --color-primary-foreground: hsl(0 0% 98%);
121
- --color-secondary: hsl(0 0% 96.1%);
124
+ --color-secondary: hsl(0 0% 94%);
122
125
  --color-secondary-foreground: hsl(0 0% 9%);
123
- --color-muted: hsl(0 0% 96.1%);
124
- --color-muted-foreground: hsl(0 0% 45.1%);
125
- --color-accent: hsl(0 0% 96.1%);
126
+ --color-muted: hsl(0 0% 94%);
127
+ --color-muted-foreground: hsl(0 0% 42%);
128
+ --color-accent: hsl(0 0% 94%);
126
129
  --color-accent-foreground: hsl(0 0% 9%);
127
130
  --color-destructive: hsl(0 84.2% 60.2%);
128
131
  --color-destructive-foreground: hsl(0 0% 98%);
@@ -130,10 +133,11 @@
130
133
  --color-input: hsl(0 0% 89.8%);
131
134
  --color-ring: hsl(0 0% 63%);
132
135
 
133
- /* Sidebar-Familie Light (Neutral) = shadcn-Werte: Rail 98%, aktiv 96.1%,
134
- Border 89.8%. Inset-Panel (100%) schwebt via Shadow + Rounded auf dem
135
- Rail; der Kontrast ist bewusst subtil (wie im Original). */
136
- --color-sidebar: hsl(0 0% 98%);
136
+ /* Sidebar family light (neutral): rail 98.5% (clearly between background
137
+ 96.5% and card 100%, otherwise the tinted page would swallow the
138
+ rail), active 96.1%, border 89.8%. Inset panel (100%) floats via
139
+ shadow + rounded on the rail. */
140
+ --color-sidebar: hsl(0 0% 98.5%);
137
141
  --color-sidebar-foreground: hsl(0 0% 26.1%);
138
142
  --color-sidebar-primary: hsl(0 0% 9%);
139
143
  --color-sidebar-primary-foreground: hsl(0 0% 98%);
@@ -0,0 +1,35 @@
1
+ // @ts-nocheck — vendored shadcn, regenerate via scripts/sync-shadcn.ts
2
+ "use client"
3
+
4
+ import * as React from "react"
5
+ import { cn } from "../lib/cn"
6
+ import { Switch as SwitchPrimitive } from "radix-ui"
7
+
8
+ function Switch({
9
+ className,
10
+ size = "default",
11
+ ...props
12
+ }: React.ComponentProps<typeof SwitchPrimitive.Root> & {
13
+ size?: "sm" | "default"
14
+ }) {
15
+ return (
16
+ <SwitchPrimitive.Root
17
+ data-slot="switch"
18
+ data-size={size}
19
+ className={cn(
20
+ "peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
21
+ className
22
+ )}
23
+ {...props}
24
+ >
25
+ <SwitchPrimitive.Thumb
26
+ data-slot="switch-thumb"
27
+ className={cn(
28
+ "pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
29
+ )}
30
+ />
31
+ </SwitchPrimitive.Root>
32
+ )
33
+ }
34
+
35
+ export { Switch }