@cosmicdrift/kumiko-renderer-web 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.
Files changed (58) hide show
  1. package/package.json +63 -0
  2. package/src/__tests__/avatar.test.tsx +34 -0
  3. package/src/__tests__/combobox.test.tsx +240 -0
  4. package/src/__tests__/config-edit.test.tsx +172 -0
  5. package/src/__tests__/create-app.test.tsx +261 -0
  6. package/src/__tests__/date-input.test.tsx +91 -0
  7. package/src/__tests__/default-app-shell.test.tsx +60 -0
  8. package/src/__tests__/dispatcher-context.test.tsx +101 -0
  9. package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
  10. package/src/__tests__/kumiko-screen.test.tsx +1014 -0
  11. package/src/__tests__/language-switcher.test.tsx +100 -0
  12. package/src/__tests__/money-input.test.tsx +232 -0
  13. package/src/__tests__/nav-base-path.test.tsx +388 -0
  14. package/src/__tests__/nav-search-params.test.tsx +88 -0
  15. package/src/__tests__/nav-tree.test.tsx +183 -0
  16. package/src/__tests__/nav.test.tsx +253 -0
  17. package/src/__tests__/primitives.test.tsx +936 -0
  18. package/src/__tests__/render-edit.test.tsx +178 -0
  19. package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
  20. package/src/__tests__/render-list-debounce.test.tsx +128 -0
  21. package/src/__tests__/render-list.test.tsx +151 -0
  22. package/src/__tests__/sidebar.test.tsx +59 -0
  23. package/src/__tests__/test-utils.tsx +144 -0
  24. package/src/__tests__/theme-toggle.test.tsx +101 -0
  25. package/src/__tests__/toast.test.tsx +162 -0
  26. package/src/__tests__/use-form.test.tsx +112 -0
  27. package/src/__tests__/use-query-live.test.tsx +152 -0
  28. package/src/__tests__/use-query.test.tsx +88 -0
  29. package/src/__tests__/use-store.test.tsx +139 -0
  30. package/src/__tests__/workspace-shell.test.tsx +772 -0
  31. package/src/app/browser-locale.ts +85 -0
  32. package/src/app/client-plugin.tsx +63 -0
  33. package/src/app/create-app.tsx +380 -0
  34. package/src/app/nav.tsx +226 -0
  35. package/src/index.ts +137 -0
  36. package/src/layout/app-layout.tsx +35 -0
  37. package/src/layout/avatar.tsx +93 -0
  38. package/src/layout/default-app-shell.tsx +74 -0
  39. package/src/layout/language-switcher.tsx +101 -0
  40. package/src/layout/nav-tree.tsx +281 -0
  41. package/src/layout/profile-menu.tsx +40 -0
  42. package/src/layout/sidebar.tsx +65 -0
  43. package/src/layout/theme-toggle.tsx +44 -0
  44. package/src/layout/topbar.tsx +22 -0
  45. package/src/layout/workspace-shell.tsx +282 -0
  46. package/src/layout/workspace-switcher.tsx +62 -0
  47. package/src/lib/cn.ts +10 -0
  48. package/src/primitives/action-menu.tsx +111 -0
  49. package/src/primitives/combobox.tsx +261 -0
  50. package/src/primitives/date-input.tsx +165 -0
  51. package/src/primitives/dialog.tsx +119 -0
  52. package/src/primitives/dropdown-menu.tsx +103 -0
  53. package/src/primitives/index.tsx +1271 -0
  54. package/src/primitives/money-input.tsx +192 -0
  55. package/src/primitives/toast.tsx +166 -0
  56. package/src/sse/live-events.ts +90 -0
  57. package/src/styles.css +113 -0
  58. package/src/tokens.ts +63 -0
@@ -0,0 +1,192 @@
1
+ // MoneyInput — type=text mit focus-aware Locale-Format. Canonical-Wert
2
+ // bleiben Minor-Units (Cents) wie auf der Wire; während Focus zeigt das
3
+ // Input einen rohen, editierbaren Decimal-String. Bei Blur formatiert
4
+ // Intl.NumberFormat mit `style: "currency"` — das liefert Currency-
5
+ // Symbol (€/$/¥) UND Tausender-Trenner UND korrekte Decimals in einem
6
+ // Aufruf.
7
+ //
8
+ // Warum nicht type=number: number-Inputs lehnen formatierte Strings
9
+ // ("1.234,56 €") ab — kein Browser akzeptiert Locale-Decimals (Komma)
10
+ // in number-Inputs. inputMode="decimal" gibt mobiles Numpad-Keyboard
11
+ // trotzdem.
12
+ //
13
+ // +/- Buttons mutieren den Canonical-Wert direkt (1 Major-Unit pro
14
+ // Klick — also 100 cents bei EUR/USD, 1 yen bei JPY). User der nur
15
+ // Cent-genaue Steps will tippt halt im Focus-Modus.
16
+
17
+ import { Minus, Plus } from "lucide-react";
18
+ import { type FocusEvent, type ReactNode, useState } from "react";
19
+ import { cn } from "../lib/cn";
20
+
21
+ export type MoneyInputProps = {
22
+ readonly id: string;
23
+ readonly name: string;
24
+ readonly value: number | "";
25
+ readonly onChange: (v: number | undefined) => void;
26
+ readonly currency: string;
27
+ readonly locale?: string;
28
+ readonly disabled?: boolean;
29
+ readonly required?: boolean;
30
+ readonly hasError?: boolean;
31
+ };
32
+
33
+ const inputClass =
34
+ "flex h-9 w-full rounded-md border border-input bg-transparent pl-3 pr-1 text-sm shadow-sm " +
35
+ "transition-colors placeholder:text-muted-foreground focus-visible:outline-none " +
36
+ "focus-visible:ring-1 focus-visible:ring-ring " +
37
+ "disabled:cursor-not-allowed disabled:opacity-50 " +
38
+ // Numerische Inputs rechtsbündig — wie native type=number — damit
39
+ // Beträge unter Listen-Spalten an den Tausender-Stellen alignen.
40
+ "text-right tabular-nums";
41
+
42
+ const stepBtnClass =
43
+ "inline-flex h-7 w-7 items-center justify-center rounded-sm text-muted-foreground " +
44
+ "hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 " +
45
+ "focus-visible:ring-ring disabled:opacity-40 disabled:pointer-events-none";
46
+
47
+ export function MoneyInput({
48
+ id,
49
+ name,
50
+ value,
51
+ onChange,
52
+ currency,
53
+ locale,
54
+ disabled,
55
+ required,
56
+ hasError,
57
+ }: MoneyInputProps): ReactNode {
58
+ const decimals = currencyDecimals(currency);
59
+ const factor = 10 ** decimals;
60
+ const resolvedLocale = locale ?? guessLocale();
61
+ const [focused, setFocused] = useState(false);
62
+ // Raw-Edit-Buffer während Focus. Sonst würde jeder Tipp-Step durch
63
+ // Math.round → format-Roundtrip jagen und der Cursor würde springen.
64
+ const [draft, setDraft] = useState<string>("");
65
+
66
+ const major = value === "" ? null : value / factor;
67
+
68
+ const formatted =
69
+ major === null
70
+ ? ""
71
+ : new Intl.NumberFormat(resolvedLocale, {
72
+ style: "currency",
73
+ currency,
74
+ minimumFractionDigits: decimals,
75
+ maximumFractionDigits: decimals,
76
+ }).format(major);
77
+
78
+ // Edit-Mode: Decimal-String ohne Tausender-Trenner. Konsistente Helper-
79
+ // Funktion damit Focus-Init und Render-Fallback nicht auseinanderdriften.
80
+ const toEditable = (m: number | null): string =>
81
+ m === null
82
+ ? ""
83
+ : m.toLocaleString(resolvedLocale, {
84
+ minimumFractionDigits: decimals,
85
+ maximumFractionDigits: decimals,
86
+ useGrouping: false,
87
+ });
88
+
89
+ const editable = focused ? draft : toEditable(major);
90
+
91
+ const handleFocus = (_e: FocusEvent<HTMLInputElement>): void => {
92
+ setDraft(toEditable(major));
93
+ setFocused(true);
94
+ };
95
+
96
+ const handleBlur = (): void => {
97
+ setFocused(false);
98
+ if (draft.trim() === "") {
99
+ onChange(undefined);
100
+ return;
101
+ }
102
+ const parsed = parseLocaleNumber(draft, resolvedLocale);
103
+ if (Number.isNaN(parsed)) return;
104
+ onChange(Math.round(parsed * factor));
105
+ };
106
+
107
+ const bump = (delta: number): void => {
108
+ const current = value === "" ? 0 : value;
109
+ onChange(current + delta * factor);
110
+ };
111
+
112
+ return (
113
+ <div className="relative w-full">
114
+ <input
115
+ id={id}
116
+ name={name}
117
+ type="text"
118
+ inputMode="decimal"
119
+ autoComplete="off"
120
+ disabled={disabled}
121
+ aria-required={required}
122
+ aria-invalid={hasError === true ? true : undefined}
123
+ value={focused ? editable : formatted}
124
+ onFocus={handleFocus}
125
+ onBlur={handleBlur}
126
+ onChange={(e) => setDraft(e.target.value)}
127
+ className={cn(
128
+ inputClass,
129
+ "pr-20",
130
+ hasError === true && "border-destructive focus-visible:ring-destructive",
131
+ )}
132
+ />
133
+ <div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
134
+ <button
135
+ type="button"
136
+ aria-label="−"
137
+ tabIndex={-1}
138
+ disabled={disabled}
139
+ onClick={() => bump(-1)}
140
+ className={stepBtnClass}
141
+ >
142
+ <Minus className="size-3.5" aria-hidden="true" />
143
+ </button>
144
+ <button
145
+ type="button"
146
+ aria-label="+"
147
+ tabIndex={-1}
148
+ disabled={disabled}
149
+ onClick={() => bump(1)}
150
+ className={stepBtnClass}
151
+ >
152
+ <Plus className="size-3.5" aria-hidden="true" />
153
+ </button>
154
+ </div>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ // Currency-Decimal-Stellen — überdeckt die wichtigsten Welt-Währungen.
160
+ // Default 2 wenn Code unbekannt.
161
+ export function currencyDecimals(code: string): number {
162
+ if (code === "JPY" || code === "KRW" || code === "VND" || code === "ISK") return 0;
163
+ if (code === "BHD" || code === "JOD" || code === "KWD" || code === "OMR" || code === "TND")
164
+ return 3;
165
+ return 2;
166
+ }
167
+
168
+ function guessLocale(): string {
169
+ if (typeof navigator !== "undefined" && navigator.language) return navigator.language;
170
+ return "en-US";
171
+ }
172
+
173
+ // Locale-Decimal-Parse: erkennt automatisch ob Komma oder Punkt der
174
+ // Decimal-Separator ist. Intl.NumberFormat liefert die Trenner für
175
+ // das Locale, daraus bauen wir den Reverse-Parser. Strict beim
176
+ // Vorzeichen: ein `-` darf NUR ganz vorne stehen — `1-23` ist invalid,
177
+ // nicht `-123` (sonst würden vertippte Inputs zu falschen Beträgen).
178
+ export function parseLocaleNumber(raw: string, locale: string): number {
179
+ const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
180
+ const groupSep = parts.find((p) => p.type === "group")?.value ?? ",";
181
+ const decimalSep = parts.find((p) => p.type === "decimal")?.value ?? ".";
182
+ const trimmed = raw.trim();
183
+ const negative = trimmed.startsWith("-");
184
+ const body = negative ? trimmed.slice(1) : trimmed;
185
+ // Body darf nur noch Ziffern, Group- und Decimal-Separator enthalten.
186
+ // Alles andere (zweites Minus, Buchstaben, etc.) → NaN, damit Caller
187
+ // (handleBlur) den Wert verwirft statt eine korrupte Zahl zu setzen.
188
+ const cleaned = body.split(groupSep).join("").split(decimalSep).join(".");
189
+ if (!/^[0-9]*\.?[0-9]*$/.test(cleaned) || cleaned === "" || cleaned === ".") return Number.NaN;
190
+ const n = Number(cleaned);
191
+ return negative ? -n : n;
192
+ }
@@ -0,0 +1,166 @@
1
+ // Toast-Primitive auf @radix-ui/react-toast. Ein globaler Provider
2
+ // + ein useToast()-Hook der von überall in der App aus Toasts auslösen
3
+ // kann. Kein Context-Boilerplate für Konsumenten — der Hook returned
4
+ // einen `toast()`-Trigger, mehr Public-API gibt's nicht.
5
+ //
6
+ // Variants: "default" (neutral) und "destructive" (Fehler-Akzent).
7
+ // Auto-dismiss nach 5s, manuell schließbar via X-Button. Der ARIA-
8
+ // Live-Region-Setup kommt komplett aus Radix.
9
+
10
+ import * as Primitive from "@radix-ui/react-toast";
11
+ import { X } from "lucide-react";
12
+ import {
13
+ createContext,
14
+ type ReactNode,
15
+ useCallback,
16
+ useContext,
17
+ useId,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
+ } from "react";
22
+ import { cn } from "../lib/cn";
23
+
24
+ export type ToastVariant = "default" | "destructive";
25
+
26
+ export type ToastOptions = {
27
+ readonly title: string;
28
+ readonly description?: string;
29
+ readonly variant?: ToastVariant;
30
+ // Self-service deep-link (z.B. KumikoError.docsUrl). Wenn gesetzt
31
+ // rendert der Toast einen "Mehr erfahren →" Link der in neuem Tab
32
+ // öffnet. Label override via `docsLinkLabel` (Default: deutsch).
33
+ readonly docsUrl?: string;
34
+ readonly docsLinkLabel?: string;
35
+ };
36
+
37
+ type ToastEntry = ToastOptions & {
38
+ readonly id: string;
39
+ };
40
+
41
+ type ToastApi = {
42
+ readonly toast: (opts: ToastOptions) => void;
43
+ };
44
+
45
+ const ToastContext = createContext<ToastApi | null>(null);
46
+
47
+ export function useToast(): ToastApi {
48
+ const ctx = useContext(ToastContext);
49
+ if (ctx === null) {
50
+ // Nicht throwen — Tests die einen Component ohne Provider rendern
51
+ // sollen nicht crashen. No-op statt fehlerhaftes Mount.
52
+ return { toast: () => undefined };
53
+ }
54
+ return ctx;
55
+ }
56
+
57
+ export type ToastProviderProps = {
58
+ readonly children: ReactNode;
59
+ };
60
+
61
+ export function ToastProvider({ children }: ToastProviderProps): ReactNode {
62
+ const [entries, setEntries] = useState<readonly ToastEntry[]>([]);
63
+ // Monoton wachsender Counter via useRef — useState hätte einen
64
+ // closure-Bug: zwei toast()-Calls im selben Tick lesen denselben
65
+ // alten Wert aus dem Closure und kollidieren bei der ID. Ref ist
66
+ // synchron-mutabel, jeder Aufruf bekommt eine garantiert neue ID.
67
+ const idPrefix = useId();
68
+ const counterRef = useRef(0);
69
+
70
+ const toast = useCallback(
71
+ (opts: ToastOptions) => {
72
+ counterRef.current += 1;
73
+ const id = `${idPrefix}-${counterRef.current}`;
74
+ setEntries((current) => [...current, { ...opts, id }]);
75
+ },
76
+ [idPrefix],
77
+ );
78
+
79
+ const api = useMemo<ToastApi>(() => ({ toast }), [toast]);
80
+
81
+ return (
82
+ <ToastContext.Provider value={api}>
83
+ <Primitive.Provider swipeDirection="right" duration={5000}>
84
+ {children}
85
+ {entries.map((entry) => (
86
+ <ToastItem
87
+ key={entry.id}
88
+ entry={entry}
89
+ onClose={() => setEntries((current) => current.filter((e) => e.id !== entry.id))}
90
+ />
91
+ ))}
92
+ <Primitive.Viewport
93
+ className={cn(
94
+ "fixed top-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4",
95
+ "sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
96
+ )}
97
+ />
98
+ </Primitive.Provider>
99
+ </ToastContext.Provider>
100
+ );
101
+ }
102
+
103
+ const rootClass =
104
+ "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 " +
105
+ "overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all " +
106
+ "data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] " +
107
+ "data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none " +
108
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out " +
109
+ "data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full " +
110
+ "data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full";
111
+
112
+ function ToastItem({
113
+ entry,
114
+ onClose,
115
+ }: {
116
+ readonly entry: ToastEntry;
117
+ readonly onClose: () => void;
118
+ }): ReactNode {
119
+ const variantClass =
120
+ entry.variant === "destructive"
121
+ ? "destructive group border-destructive bg-destructive text-destructive-foreground"
122
+ : "border bg-background text-foreground";
123
+ return (
124
+ <Primitive.Root
125
+ className={cn(rootClass, variantClass)}
126
+ onOpenChange={(open) => {
127
+ if (!open) onClose();
128
+ }}
129
+ >
130
+ <div className="grid gap-1">
131
+ <Primitive.Title className="text-sm font-semibold">{entry.title}</Primitive.Title>
132
+ {entry.description !== undefined && (
133
+ <Primitive.Description className="text-sm opacity-90">
134
+ {entry.description}
135
+ </Primitive.Description>
136
+ )}
137
+ {entry.docsUrl !== undefined && (
138
+ <Primitive.Action altText={entry.docsLinkLabel ?? "Mehr erfahren"} asChild>
139
+ <a
140
+ href={entry.docsUrl}
141
+ target="_blank"
142
+ rel="noopener noreferrer"
143
+ className={cn(
144
+ "text-xs underline opacity-90 hover:opacity-100",
145
+ "focus:outline-none focus:ring-1 focus:ring-current rounded",
146
+ )}
147
+ >
148
+ {entry.docsLinkLabel ?? "Mehr erfahren"} →
149
+ </a>
150
+ </Primitive.Action>
151
+ )}
152
+ </div>
153
+ <Primitive.Close
154
+ className={cn(
155
+ "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity",
156
+ "hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1",
157
+ "group-hover:opacity-100",
158
+ "group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50",
159
+ )}
160
+ aria-label="Close"
161
+ >
162
+ <X className="h-4 w-4" />
163
+ </Primitive.Close>
164
+ </Primitive.Root>
165
+ );
166
+ }
@@ -0,0 +1,90 @@
1
+ import type { LiveEvent, LiveEventSubscriber } from "@cosmicdrift/kumiko-renderer";
2
+
3
+ // EventSource-backed Live-Events für den Web-Renderer. Der shared
4
+ // Layer konsumiert nur das `LiveEventSubscriber`-Interface; diese Datei
5
+ // liefert eine Factory die intern eine EventSource auf /api/sse aufbaut,
6
+ // pro (entity, verb)-Kombi einen addEventListener verdrahtet und
7
+ // subscriptions routet.
8
+ //
9
+ // Verbindungs-Lifecycle: lazy beim ersten subscribe, close wenn der
10
+ // letzte unsubscribe feuert. Mehrere Consumer teilen sich dieselbe
11
+ // EventSource, sparen CPU + Server-Load.
12
+
13
+ const VERBS = ["created", "updated", "deleted", "restored"] as const;
14
+
15
+ type EntitySubscriber = {
16
+ readonly entityName: string;
17
+ readonly listener: (event: LiveEvent) => void;
18
+ };
19
+
20
+ export type CreateEventSourceLiveEventsOptions = {
21
+ /** URL des SSE-Endpoints. Default: /api/sse (das ist wo
22
+ * createSseRoute im Kumiko-Server mountet). Override wenn der
23
+ * Mountpath divergiert. */
24
+ readonly url?: string;
25
+ };
26
+
27
+ /** Liefert einen `LiveEventSubscriber` der EventSource-backed ist.
28
+ * Normalerweise einmal im App-Bootstrap gerufen und als value an
29
+ * `<LiveEventsProvider>` durchgereicht — createKumikoApp tut das. */
30
+ export function createEventSourceLiveEvents(
31
+ options: CreateEventSourceLiveEventsOptions = {},
32
+ ): LiveEventSubscriber {
33
+ const url = options.url ?? "/api/sse";
34
+
35
+ const subscribers = new Set<EntitySubscriber>();
36
+ let source: EventSource | undefined;
37
+ const wiredTypes = new Set<string>();
38
+
39
+ const handleEvent = (type: string, raw: string): void => {
40
+ let parsed: LiveEvent["data"];
41
+ try {
42
+ parsed = JSON.parse(raw) as LiveEvent["data"];
43
+ } catch {
44
+ // Malformed payload — drop it. Besser als einen fangenden Listener
45
+ // zu crashen und alle anderen subscribers mitzureißen.
46
+ return;
47
+ }
48
+ const event: LiveEvent = { type, data: parsed };
49
+ for (const sub of subscribers) {
50
+ if (sub.entityName === parsed.aggregateType) sub.listener(event);
51
+ }
52
+ };
53
+
54
+ const ensureConnected = (): void => {
55
+ if (source !== undefined) return;
56
+ if (typeof window === "undefined" || typeof EventSource === "undefined") return;
57
+ source = new EventSource(url);
58
+ };
59
+
60
+ const ensureListenersForEntity = (entityName: string): void => {
61
+ if (source === undefined) return;
62
+ for (const verb of VERBS) {
63
+ const type = `${entityName}.${verb}`;
64
+ if (wiredTypes.has(type)) continue;
65
+ source.addEventListener(type, (e) => {
66
+ handleEvent(type, (e as MessageEvent).data);
67
+ });
68
+ wiredTypes.add(type);
69
+ }
70
+ };
71
+
72
+ const closeIfEmpty = (): void => {
73
+ if (subscribers.size > 0) return;
74
+ if (source === undefined) return;
75
+ source.close();
76
+ source = undefined;
77
+ wiredTypes.clear();
78
+ };
79
+
80
+ return (entityName, listener) => {
81
+ ensureConnected();
82
+ ensureListenersForEntity(entityName);
83
+ const sub: EntitySubscriber = { entityName, listener };
84
+ subscribers.add(sub);
85
+ return () => {
86
+ subscribers.delete(sub);
87
+ closeIfEmpty();
88
+ };
89
+ };
90
+ }
package/src/styles.css ADDED
@@ -0,0 +1,113 @@
1
+ /* Tailwind v4 + shadcn-Token-Schema. Die :root + .dark Blocks
2
+ halten die Design-Tokens als CSS-Variablen. Theme-Toggle =
3
+ `document.documentElement.classList.toggle("dark")` — keine
4
+ JS-State-Management-Pipeline nötig.
5
+
6
+ Tokens-Namen folgen der shadcn-Konvention (background/foreground/
7
+ primary/secondary/muted/accent/destructive/border/input/ring).
8
+ Werte sind HSL-triplets ohne hsl() wrapper, damit Tailwind-
9
+ Klassen wie `bg-primary` direkt funktionieren.
10
+ */
11
+
12
+ @import "tailwindcss";
13
+
14
+ /* react-day-picker v9 liefert seine Struktur-CSS separat (kein bundled
15
+ Default mehr seit v9). Ohne diesen Import bricht das Calendar-Layout
16
+ — Wochen rutschen ineinander, weil rdp den table-Reset (border-
17
+ collapse, cell-Geometrie) extern hält. Unsere classNames-Map in
18
+ `date-input.tsx` überschreibt nur Slot-Tokens, die strukturellen
19
+ Defaults bleiben rdp's eigenes Stylesheet. */
20
+ @import "react-day-picker/style.css";
21
+
22
+ /* Tailwind v4 scannt per default vom Import-Ort aus; da das CSS
23
+ aus einem temp-dir kompiliert wird, findet es sonst keine
24
+ Source-Dateien. Explizite @source-Direktiven decken alle
25
+ Workspace-Sources ab — der CLI scannt dann unsere Primitives,
26
+ Layout-Components UND die Sample-App. */
27
+ @source "../../renderer-web/src/**/*.{ts,tsx}";
28
+ @source "../../renderer/src/**/*.{ts,tsx}";
29
+ @source "../../../samples/**/src/**/*.{ts,tsx}";
30
+
31
+ /* Dark-Mode via class-selector (nicht media). Erlaubt JS-Toggle
32
+ und überschreibt System-Preference wenn die App will. */
33
+ @custom-variant dark (&:where(.dark, .dark *));
34
+
35
+ @theme {
36
+ /* Dark-Mode (Default) — Linear-Style: blau-graues Background statt
37
+ pures Schwarz. background <-> card haben minimal-Distinction
38
+ (1-2% lightness shift) damit Cards subtle "schweben" ohne harte
39
+ Borders. Primary ist das Linear-Brand-Purple (~#5e6ad2). */
40
+ --color-background: hsl(220 13% 9%);
41
+ --color-foreground: hsl(220 14% 96%);
42
+ --color-card: hsl(220 13% 11%);
43
+ --color-card-foreground: hsl(220 14% 96%);
44
+ --color-popover: hsl(220 13% 11%);
45
+ --color-popover-foreground: hsl(220 14% 96%);
46
+ --color-primary: hsl(232 60% 64%);
47
+ --color-primary-foreground: hsl(0 0% 100%);
48
+ --color-secondary: hsl(220 13% 16%);
49
+ --color-secondary-foreground: hsl(220 14% 96%);
50
+ --color-muted: hsl(220 13% 14%);
51
+ --color-muted-foreground: hsl(220 8% 56%);
52
+ --color-accent: hsl(220 13% 16%);
53
+ --color-accent-foreground: hsl(220 14% 96%);
54
+ --color-destructive: hsl(0 75% 58%);
55
+ --color-destructive-foreground: hsl(0 0% 100%);
56
+ --color-border: hsl(220 13% 18%);
57
+ --color-input: hsl(220 13% 16%);
58
+ --color-ring: hsl(232 60% 64%);
59
+
60
+ /* Radius-Skala: Linear hat größere Radii — Cards/Modals 10-12px,
61
+ Buttons/Inputs 6-8px, Pills voll. */
62
+ --radius-sm: 0.25rem;
63
+ --radius-md: 0.375rem;
64
+ --radius-lg: 0.5rem;
65
+ --radius-xl: 0.75rem;
66
+ }
67
+
68
+ /* Light-Mode-Overrides — aktiv wenn `<html>` KEINE `.dark` Klasse
69
+ hat. Dark ist der Default (siehe @theme oben); toggled der User
70
+ auf light, greifen diese Werte. */
71
+ @layer base {
72
+ :root:not(.dark) {
73
+ /* Light-Mode — Linear-Style: warmes weiß-grau Background, Cards
74
+ leicht heller. Primary bleibt Brand-Purple (gleicher hue wie
75
+ dark, höhere saturation für Kontrast auf weiß). Borders sehr
76
+ dezent (rgba-style alpha statt full grey-200). */
77
+ --color-background: hsl(0 0% 100%);
78
+ --color-foreground: hsl(220 14% 14%);
79
+ --color-card: hsl(0 0% 100%);
80
+ --color-card-foreground: hsl(220 14% 14%);
81
+ --color-popover: hsl(0 0% 100%);
82
+ --color-popover-foreground: hsl(220 14% 14%);
83
+ --color-primary: hsl(232 60% 58%);
84
+ --color-primary-foreground: hsl(0 0% 100%);
85
+ --color-secondary: hsl(220 14% 96%);
86
+ --color-secondary-foreground: hsl(220 14% 14%);
87
+ --color-muted: hsl(220 14% 97%);
88
+ --color-muted-foreground: hsl(220 8% 46%);
89
+ --color-accent: hsl(220 14% 96%);
90
+ --color-accent-foreground: hsl(220 14% 14%);
91
+ --color-destructive: hsl(0 72% 51%);
92
+ --color-destructive-foreground: hsl(0 0% 100%);
93
+ --color-border: hsl(220 14% 92%);
94
+ --color-input: hsl(220 14% 92%);
95
+ --color-ring: hsl(232 60% 58%);
96
+ }
97
+
98
+ * {
99
+ border-color: var(--color-border);
100
+ }
101
+
102
+ body {
103
+ background-color: var(--color-background);
104
+ color: var(--color-foreground);
105
+ font-family:
106
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
107
+ font-size: 14px;
108
+ line-height: 1.5;
109
+ margin: 0;
110
+ -webkit-font-smoothing: antialiased;
111
+ -moz-osx-font-smoothing: grayscale;
112
+ }
113
+ }
package/src/tokens.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { createStore } from "@cosmicdrift/kumiko-headless";
2
+ import {
3
+ cssVarTokens,
4
+ type ThemeMode,
5
+ type Tokens,
6
+ type TokensApi,
7
+ } from "@cosmicdrift/kumiko-renderer";
8
+ import { useSyncExternalStore } from "react";
9
+
10
+ // Web-spezifische TokensApi-Impl. Theme-Toggle via `.dark`-Class auf
11
+ // <html>. Die echten Farben leben in styles.css; hier ist nur die
12
+ // JS-Seite die den class-switch triggert und React-Consumer mit
13
+ // useSyncExternalStore darüber informiert.
14
+ //
15
+ // Source-of-truth ist der DOM (`<html class="dark">`); der Store ist
16
+ // reiner Notification-Bus (Tick-Counter), den setMode/toggleMode bei
17
+ // jedem Class-Wechsel hochzählen. So bleibt die DOM-Klasse die einzige
18
+ // Wahrheit — readCurrentMode liest sie frisch bei jedem getSnapshot.
19
+
20
+ const themeTick = createStore(0);
21
+
22
+ function readCurrentMode(): ThemeMode {
23
+ if (typeof document === "undefined") return "dark";
24
+ return document.documentElement.classList.contains("dark") ? "dark" : "light";
25
+ }
26
+
27
+ function notifyThemeChange(): void {
28
+ themeTick.setState((t) => t + 1);
29
+ }
30
+
31
+ /** Hook der eine TokensApi für den Browser baut. Wird von
32
+ * createKumikoApp genutzt; App-Code der einen eigenen Token-State
33
+ * braucht (z.B. User-Präferenz aus localStorage) kann selber
34
+ * `<TokensProvider value={...}>` mounten. */
35
+ export function useBrowserTokensApi(): TokensApi {
36
+ const mode = useSyncExternalStore(themeTick.subscribe, readCurrentMode, () => "dark" as const);
37
+ return {
38
+ tokens: cssVarTokens,
39
+ mode,
40
+ setMode: (next) => {
41
+ if (typeof document === "undefined") return;
42
+ document.documentElement.classList.toggle("dark", next === "dark");
43
+ notifyThemeChange();
44
+ },
45
+ toggleMode: () => {
46
+ if (typeof document === "undefined") return;
47
+ document.documentElement.classList.toggle("dark");
48
+ notifyThemeChange();
49
+ },
50
+ };
51
+ }
52
+
53
+ /** Default-Tokens — identisch zu `cssVarTokens` (var-string-refs).
54
+ * Light- und Dark-Werte switchen via `.dark`-class auf <html>. */
55
+ export const defaultTokens: Tokens = cssVarTokens;
56
+ export const lightTokens: Tokens = cssVarTokens;
57
+
58
+ /** Historisch: schrieb Tokens auf :root als CSS-vars. Jetzt no-op —
59
+ * die CSS-vars leben in styles.css, nicht in JS. Bleibt als Export
60
+ * damit alter App-Code nicht bricht. */
61
+ export function applyTokensToCssVars(_tokens: Tokens): void {
62
+ // Absichtlich leer — siehe Kommentar oben.
63
+ }