@sukramj/fabric-ui 0.0.0-stage → 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 (43) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +91 -2
  3. package/package.json +92 -4
  4. package/src/lib/api/client.ts +154 -0
  5. package/src/lib/api/index.ts +3 -0
  6. package/src/lib/api/schema.gen.ts +1159 -0
  7. package/src/lib/api/stream.ts +54 -0
  8. package/src/lib/context.ts +96 -0
  9. package/src/lib/i18n/de.json +157 -0
  10. package/src/lib/i18n/en.json +157 -0
  11. package/src/lib/i18n/index.ts +50 -0
  12. package/src/lib/index.ts +10 -0
  13. package/src/lib/kit/Badge.svelte +26 -0
  14. package/src/lib/kit/Button.svelte +49 -0
  15. package/src/lib/kit/Card.svelte +16 -0
  16. package/src/lib/kit/DataTable.svelte +239 -0
  17. package/src/lib/kit/EmptyState.svelte +24 -0
  18. package/src/lib/kit/ErrorState.svelte +22 -0
  19. package/src/lib/kit/Icon.svelte +12 -0
  20. package/src/lib/kit/Input.svelte +22 -0
  21. package/src/lib/kit/LoadingState.svelte +15 -0
  22. package/src/lib/kit/PageHeader.svelte +32 -0
  23. package/src/lib/kit/PageShell.svelte +16 -0
  24. package/src/lib/kit/Select.svelte +49 -0
  25. package/src/lib/kit/Spinner.svelte +59 -0
  26. package/src/lib/kit/Tabs.svelte +63 -0
  27. package/src/lib/kit/data-table.ts +24 -0
  28. package/src/lib/kit/icons.ts +41 -0
  29. package/src/lib/kit/index.ts +21 -0
  30. package/src/lib/kit/tabs.ts +13 -0
  31. package/src/lib/kit/utils.ts +49 -0
  32. package/src/lib/pages/index.ts +8 -0
  33. package/src/lib/pages/node/Diagnostics.svelte +205 -0
  34. package/src/lib/pages/node/Endpoints.svelte +82 -0
  35. package/src/lib/pages/node/Fabrics.svelte +169 -0
  36. package/src/lib/pages/node/Pair.svelte +186 -0
  37. package/src/lib/pages/node/Selection.svelte +388 -0
  38. package/src/lib/pages/node/Status.svelte +63 -0
  39. package/src/lib/qr.ts +58 -0
  40. package/src/lib/store.svelte.ts +293 -0
  41. package/src/lib/theme/ha-bridge.ts +156 -0
  42. package/src/lib/theme/index.ts +1 -0
  43. package/src/lib/theme/tokens.css +292 -0
@@ -0,0 +1,54 @@
1
+ // The event stream helper: GET /node/stream as typed callbacks. A host with
2
+ // a hub of its own feeds it from here (see ui/README.md).
3
+ import type { ExposureEvent, FabricEvent, WindowEvent } from "./client";
4
+
5
+ /** The event types of the stream, as the contract names them. */
6
+ export const STREAM_EVENTS = [
7
+ "fabric_added",
8
+ "fabric_removed",
9
+ "window_opened",
10
+ "window_closed",
11
+ "endpoints_changed",
12
+ "exposure_changed",
13
+ ] as const;
14
+
15
+ export type StreamEventType = (typeof STREAM_EVENTS)[number];
16
+
17
+ export type StreamEvent =
18
+ | { type: "fabric_added" | "fabric_removed"; data: FabricEvent }
19
+ | { type: "window_opened" | "window_closed"; data: WindowEvent }
20
+ | { type: "endpoints_changed"; data: Record<string, never> }
21
+ | { type: "exposure_changed"; data: ExposureEvent };
22
+
23
+ /** The part of EventSource the helper uses, so a test can hand in a fake. */
24
+ export interface EventSourceLike {
25
+ addEventListener(type: string, fn: (ev: MessageEvent) => void): void;
26
+ close(): void;
27
+ }
28
+
29
+ export type EventSourceFactory = (url: string) => EventSourceLike;
30
+
31
+ /**
32
+ * Subscribes to the stream at `url` and calls `onEvent` for every event;
33
+ * an event whose data is not JSON is skipped. The browser's EventSource
34
+ * reconnects on its own. Returns the close function.
35
+ */
36
+ export function subscribeStream(
37
+ url: string,
38
+ onEvent: (ev: StreamEvent) => void,
39
+ open: EventSourceFactory = (u) => new EventSource(u, { withCredentials: true }),
40
+ ): () => void {
41
+ const es = open(url);
42
+ for (const type of STREAM_EVENTS) {
43
+ es.addEventListener(type, (msg: MessageEvent) => {
44
+ let data: unknown;
45
+ try {
46
+ data = JSON.parse(String(msg.data));
47
+ } catch {
48
+ return;
49
+ }
50
+ onEvent({ type, data } as StreamEvent);
51
+ });
52
+ }
53
+ return () => es.close();
54
+ }
@@ -0,0 +1,96 @@
1
+ // The fabric-ui context: what every page reads — the client, the host's
2
+ // translator, toast and confirm, the node store and the links between the
3
+ // pages. A host provides it once above the pages (provideFabricUI); the
4
+ // standalone shell provides its own.
5
+ import { getContext, hasContext, setContext } from "svelte";
6
+ import type { FabricClient } from "./api/client";
7
+ import { defaultT, type Translate } from "./i18n";
8
+ import { createNodeStore, type NodeStore, type NodeStoreDeps, type Toast } from "./store.svelte";
9
+
10
+ export type { Toast } from "./store.svelte";
11
+
12
+ export interface ConfirmOptions {
13
+ title: string;
14
+ body?: string;
15
+ destructive?: boolean;
16
+ confirmLabel?: string;
17
+ }
18
+
19
+ export type Confirm = (opts: ConfirmOptions) => Promise<boolean>;
20
+
21
+ /** The hrefs the pages link each other with; a host maps them onto its router. */
22
+ export interface Routes {
23
+ status: string;
24
+ pair: string;
25
+ fabrics: string;
26
+ selection: string;
27
+ endpoints: string;
28
+ diagnostics: string;
29
+ }
30
+
31
+ export const defaultRoutes: Routes = {
32
+ status: "#/node/status",
33
+ pair: "#/node/pair",
34
+ fabrics: "#/node/fabrics",
35
+ selection: "#/node/selection",
36
+ endpoints: "#/node/endpoints",
37
+ diagnostics: "#/node/diagnostics",
38
+ };
39
+
40
+ export interface FabricUI {
41
+ client: FabricClient;
42
+ t: Translate;
43
+ toast: Toast;
44
+ confirm: Confirm;
45
+ node: NodeStore;
46
+ routes: Routes;
47
+ }
48
+
49
+ export interface FabricUIOptions {
50
+ client: FabricClient;
51
+ t?: Translate;
52
+ toast?: Toast;
53
+ confirm?: Confirm;
54
+ routes?: Partial<Routes>;
55
+ subscribe?: NodeStoreDeps["subscribe"];
56
+ }
57
+
58
+ const KEY = Symbol.for("@sukramj/fabric-ui");
59
+
60
+ const consoleToast: Toast = {
61
+ info: (m) => console.info(m),
62
+ success: (m) => console.info(m),
63
+ error: (m) => console.error(m),
64
+ };
65
+
66
+ /** Builds a context; the node store is created on the client. */
67
+ export function createFabricUI(opts: FabricUIOptions): FabricUI {
68
+ const t = opts.t ?? defaultT;
69
+ const toast = opts.toast ?? consoleToast;
70
+ return {
71
+ client: opts.client,
72
+ t,
73
+ toast,
74
+ confirm: opts.confirm ?? (async (o) => window.confirm(o.body ? `${o.title}\n\n${o.body}` : o.title)),
75
+ node: createNodeStore(opts.client, { t, toast, subscribe: opts.subscribe }),
76
+ routes: { ...defaultRoutes, ...opts.routes },
77
+ };
78
+ }
79
+
80
+ /** Puts ui into the component context; call it in the component above the pages. */
81
+ export function provideFabricUI(ui: FabricUI): FabricUI {
82
+ setContext(KEY, ui);
83
+ return ui;
84
+ }
85
+
86
+ /** The context the pages read; it throws outside provideFabricUI. */
87
+ export function useFabricUI(): FabricUI {
88
+ const ui = getContext<FabricUI | undefined>(KEY);
89
+ if (!ui) throw new Error("fabric-ui: no context — call provideFabricUI above the pages");
90
+ return ui;
91
+ }
92
+
93
+ /** The translator of the context, or English outside one (the kit uses it). */
94
+ export function useT(): Translate {
95
+ return hasContext(KEY) ? getContext<FabricUI>(KEY).t : defaultT;
96
+ }
@@ -0,0 +1,157 @@
1
+ {
2
+ "common.cancel": "Abbrechen",
3
+ "common.close": "Schließen",
4
+ "common.confirm": "Bestätigen",
5
+ "common.copy": "Kopieren",
6
+ "common.error": "Fehler:",
7
+ "common.loading": "Wird geladen…",
8
+ "common.modified": "geändert",
9
+ "common.refresh": "Aktualisieren",
10
+ "common.reload": "Neu laden",
11
+ "common.remove": "Entfernen",
12
+ "common.save": "Speichern",
13
+ "common.saving": "Wird gespeichert…",
14
+ "common.search": "Suchen…",
15
+ "node.commissioning.closed": "Kopplungsfenster geschlossen",
16
+ "node.diag.age_hours": "{n} h",
17
+ "node.diag.age_minutes": "{n} min",
18
+ "node.diag.age_seconds": "{n} s",
19
+ "node.diag.col_fabric": "Fabric",
20
+ "node.diag.col_peer_idle": "Controller still seit",
21
+ "node.diag.col_session": "Sitzung",
22
+ "node.diag.col_subscriptions": "Abonnements",
23
+ "node.diag.compat_ok": "Keine bekannten Unverträglichkeiten für die gekoppelten Ökosysteme.",
24
+ "node.diag.compatibility": "Ökosystem-Kompatibilität",
25
+ "node.diag.discovery": "Auffindbarkeit",
26
+ "node.diag.discovery_ok": "Der Knoten kündigt sich korrekt an — Controller können ihn finden.",
27
+ "node.diag.ecosystem.amazon": "Alexa",
28
+ "node.diag.ecosystem.apple": "Apple",
29
+ "node.diag.ecosystem.aqara": "Aqara",
30
+ "node.diag.ecosystem.google": "Google",
31
+ "node.diag.ecosystem.home_assistant": "Home Assistant",
32
+ "node.diag.ecosystem.smartthings": "SmartThings",
33
+ "node.diag.ecosystem.unknown": "Unbekannter Controller",
34
+ "node.diag.events": "Letzte Ereignisse",
35
+ "node.diag.events_empty": "Seit dem Start des Knotens nichts aufgezeichnet.",
36
+ "node.diag.events_hint": "Was geschehen ist, im Unterschied zu dem, was gerade gilt. Nur im Speicher gehalten und beim Neustart verloren.",
37
+ "node.diag.kind_discovery": "Auffindbarkeit",
38
+ "node.diag.kind_pairing": "Kopplung",
39
+ "node.diag.kind_session": "Sitzung",
40
+ "node.diag.no_sessions": "Gerade ist kein Controller verbunden.",
41
+ "node.diag.no_subscriptions": "verbunden, empfängt aber nichts",
42
+ "node.diag.not_advertising": "Die DNS-SD-Ankündigung ist abgeschaltet; kein Controller kann diesen Knoten finden.",
43
+ "node.diag.pase": "Kopplung",
44
+ "node.diag.port": "Port",
45
+ "node.diag.sessions": "Verbundene Controller",
46
+ "node.diag.sessions_hint": "Wie lange jeder Controller schon schweigt. Ein Controller, der ohne Abmelden verschwindet, lässt seine Sitzung offen und sendet einfach nichts mehr.",
47
+ "node.diag.sessions_occupancy": "Sitzungs-IDs: {live} aktiv · {reserved} für laufende Handshakes reserviert · {free} von {capacity} frei",
48
+ "node.diag.severity.error": "Blockierend",
49
+ "node.diag.severity.warning": "Warnung",
50
+ "node.diag.title": "Knotendiagnose",
51
+ "node.endpoints.empty": "Noch ist kein Endpunkt freigegeben.",
52
+ "node.endpoints.hint": "Was ein Controller sieht. Endpunktnummern stammen aus gespeicherter Identität; sie bleiben über Neustarts gleich, lassen sich aber nicht aus der Elementliste ableiten.",
53
+ "node.endpoints.part_of": "Teil von #{id}",
54
+ "node.endpoints.reachable": "Erreichbar",
55
+ "node.endpoints.title": "Endpunkte",
56
+ "node.endpoints.unreachable": "Nicht erreichbar",
57
+ "node.fabric.label_unknown": "(ohne Bezeichnung)",
58
+ "node.fabric.share": "Knoten mit einem weiteren Controller teilen",
59
+ "node.fabric.share_go": "Zur Kopplungsseite",
60
+ "node.fabric.share_hint": "Ein weiterer Controller wird über ein Kopplungsfenster mit QR-Code, Countdown und Schließen-Knopf hinzugefügt — all das liegt auf der Kopplungsseite.",
61
+ "node.fabric.unpair_confirm": "Diese Fabric entfernen?",
62
+ "node.fabric.unpaired": "Fabric entfernt.",
63
+ "node.fabrics.col_fabric": "Fabric #",
64
+ "node.fabrics.col_label": "Bezeichnung",
65
+ "node.fabrics.col_node_id": "Node-ID",
66
+ "node.fabrics.col_vendor": "Hersteller",
67
+ "node.fabrics.empty": "Noch keine Fabric gekoppelt.",
68
+ "node.fabrics.node_id_rounded": "gerundet",
69
+ "node.fabrics.node_id_rounded_hint": "Diese Node-ID übersteigt die Genauigkeit, die diese Liste überträgt; die letzten Stellen können von denen des Controllers abweichen.",
70
+ "node.maint.reassemble": "Endpunkte neu aufbauen",
71
+ "node.maint.reassemble_done": "Endpunkte neu aufgebaut.",
72
+ "node.maint.reassemble_hint": "Baut die freigegebenen Endpunkte aus der aktuellen Auswahl neu auf. Controller behalten ihre Kopplung.",
73
+ "node.maint.reset": "Alle Kopplungen entfernen",
74
+ "node.maint.reset_confirm": "Alle Kopplungen entfernen?",
75
+ "node.maint.reset_confirm_body": "Jeder gekoppelte Controller verliert diesen Knoten und muss ihn erneut hinzufügen. Das lässt sich nicht rückgängig machen.",
76
+ "node.maint.reset_confirm_label": "Alle entfernen",
77
+ "node.maint.reset_done": "Alle Kopplungen entfernt.",
78
+ "node.maint.reset_hint": "Setzt den Knoten in den ungekoppelten Zustand zurück. Jeder Controller muss ihn erneut hinzufügen.",
79
+ "node.maint.title": "Wartung",
80
+ "node.pair.add_controller": "Weiteren Controller hinzufügen",
81
+ "node.pair.already_paired": "Controller, die diesen Knoten bereits halten: {count}. Ein neues Fenster fügt einen weiteren hinzu; bestehende Kopplungen bleiben unberührt.",
82
+ "node.pair.close_window": "Kopplungsfenster schließen",
83
+ "node.pair.copied": "In die Zwischenablage kopiert.",
84
+ "node.pair.copy_failed": "Der Browser verweigert den Zugriff auf die Zwischenablage — bitte den Code von Hand übernehmen.",
85
+ "node.pair.copy_manual_code": "Manuellen Kopplungscode kopieren",
86
+ "node.pair.copy_qr_payload": "QR-Inhalt kopieren",
87
+ "node.pair.load_error": "Der Kopplungszustand konnte nicht geladen werden.",
88
+ "node.pair.loading": "Kopplungszustand wird geladen…",
89
+ "node.pair.manual_code": "Manueller Code",
90
+ "node.pair.minutes": "Min.",
91
+ "node.pair.qr_caption": "QR-Code mit der App des Controllers scannen",
92
+ "node.pair.qr_payload": "QR-Inhalt",
93
+ "node.pair.success": "Controller erfolgreich gekoppelt.",
94
+ "node.pair.waiting": "Noch ist kein Endpunkt freigegeben — die Kopplung öffnet, sobald der Knoten mindestens einen bereitstellt.",
95
+ "node.pair.window_open": "Kopplungsfenster offen",
96
+ "node.pair.window_open_duration": "Kopplungsfenster öffnen",
97
+ "node.selection.apply": "Übernehmen",
98
+ "node.selection.back": "Zurück",
99
+ "node.selection.bulk_expose": "Auswahl freigeben",
100
+ "node.selection.bulk_hide": "Auswahl ausblenden",
101
+ "node.selection.col_device_type": "Gerätetyp",
102
+ "node.selection.col_name": "Name",
103
+ "node.selection.col_select": "Auswahl",
104
+ "node.selection.col_state": "Zustand",
105
+ "node.selection.discard": "Verwerfen",
106
+ "node.selection.drawer_aria": "Elementdetails",
107
+ "node.selection.drawer_clusters": "Cluster",
108
+ "node.selection.drawer_device_type": "Gerätetyp",
109
+ "node.selection.drawer_source": "Herkunft des Verdikts",
110
+ "node.selection.drawer_state": "Zustand",
111
+ "node.selection.empty": "Keine freigebbaren Elemente gefunden.",
112
+ "node.selection.exposed": "Freigegeben",
113
+ "node.selection.filter_all": "Alle",
114
+ "node.selection.group_count": "{count} Elemente",
115
+ "node.selection.legend": "Legende",
116
+ "node.selection.mappable_hint": "Vollständig abbildbar.",
117
+ "node.selection.name": "Name",
118
+ "node.selection.partially_mappable_hint": "Teilweise abbildbar — ein Teil des Elements bleibt unveröffentlicht.",
119
+ "node.selection.pending": "{count} ungespeicherte Änderung(en)",
120
+ "node.selection.preview": "Änderungen prüfen",
121
+ "node.selection.preview_changed": "Verdikt ändert sich",
122
+ "node.selection.preview_hint": "Die Verdikte der Elemente nach den Änderungen. Geschrieben wird erst beim Übernehmen.",
123
+ "node.selection.preview_title": "Was die Änderungen bewirken",
124
+ "node.selection.saved_toast": "{count} Änderung(en) übernommen.",
125
+ "node.selection.search_placeholder": "Name, Gruppe, Werte suchen…",
126
+ "node.selection.select_all": "Alle auswählen",
127
+ "node.selection.select_row": "Zeile auswählen",
128
+ "node.selection.state_available": "Verfügbar (nicht freigegeben)",
129
+ "node.selection.state_exposed": "Freigegeben",
130
+ "node.selection.state_partial": "Teilweise abbildbar",
131
+ "node.selection.state_unmappable": "Nicht abbildbar",
132
+ "node.selection.unavailable": "Dieser Host stellt keine Auswahl bereit.",
133
+ "node.selection.unmappable_checkbox_title": "Nicht auf einen Endpunkt abbildbar",
134
+ "node.selection.unmappable_hint": "Nicht auf einen Endpunkt abbildbar.",
135
+ "node.status.advertising": "wird angekündigt",
136
+ "node.status.disabled": "Dieser Host stellt keinen Knoten bereit.",
137
+ "node.status.enabled": "Knoten aktiv",
138
+ "node.status.endpoints": "{count} Endpunkte",
139
+ "node.status.exposed": "{count} Elemente freigegeben",
140
+ "node.status.fabrics": "{count} Fabrics",
141
+ "node.status.healthy": "gesund",
142
+ "node.status.listen_addr": "Empfängt auf {addr}",
143
+ "node.status.listening": "empfangsbereit",
144
+ "node.status.load_error": "Der Status des Knotens konnte nicht geladen werden.",
145
+ "node.status.not_listening": "nicht empfangsbereit",
146
+ "node.status.unhealthy": "gestört",
147
+ "node.subtitle": "Kopplung, Fabrics, freigegebene Elemente und Diagnose dieses Knotens.",
148
+ "node.tab.diagnostics": "Diagnose",
149
+ "node.tab.endpoints": "Endpunkte",
150
+ "node.tab.fabrics": "Fabrics",
151
+ "node.tab.pair": "Koppeln",
152
+ "node.tab.selection": "Auswahl",
153
+ "node.tab.status": "Status",
154
+ "node.title": "Knoten",
155
+ "select.placeholder": "Auswählen…",
156
+ "shell.language": "Sprache"
157
+ }
@@ -0,0 +1,157 @@
1
+ {
2
+ "common.cancel": "Cancel",
3
+ "common.close": "Close",
4
+ "common.confirm": "Confirm",
5
+ "common.copy": "Copy",
6
+ "common.error": "Error:",
7
+ "common.loading": "Loading…",
8
+ "common.modified": "modified",
9
+ "common.refresh": "Refresh",
10
+ "common.reload": "Reload",
11
+ "common.remove": "Remove",
12
+ "common.save": "Save",
13
+ "common.saving": "Saving…",
14
+ "common.search": "Search…",
15
+ "node.commissioning.closed": "Commissioning window closed",
16
+ "node.diag.age_hours": "{n}h",
17
+ "node.diag.age_minutes": "{n}min",
18
+ "node.diag.age_seconds": "{n}s",
19
+ "node.diag.col_fabric": "Fabric",
20
+ "node.diag.col_peer_idle": "Controller quiet for",
21
+ "node.diag.col_session": "Session",
22
+ "node.diag.col_subscriptions": "Subscriptions",
23
+ "node.diag.compat_ok": "No known incompatibilities for the paired ecosystems.",
24
+ "node.diag.compatibility": "Ecosystem compatibility",
25
+ "node.diag.discovery": "Discovery",
26
+ "node.diag.discovery_ok": "The node advertises correctly — controllers can discover it.",
27
+ "node.diag.ecosystem.amazon": "Alexa",
28
+ "node.diag.ecosystem.apple": "Apple",
29
+ "node.diag.ecosystem.aqara": "Aqara",
30
+ "node.diag.ecosystem.google": "Google",
31
+ "node.diag.ecosystem.home_assistant": "Home Assistant",
32
+ "node.diag.ecosystem.smartthings": "SmartThings",
33
+ "node.diag.ecosystem.unknown": "Unknown controller",
34
+ "node.diag.events": "Recent events",
35
+ "node.diag.events_empty": "Nothing recorded since the node started.",
36
+ "node.diag.events_hint": "What happened, as opposed to what is currently true. Kept in memory only and lost on restart.",
37
+ "node.diag.kind_discovery": "Discovery",
38
+ "node.diag.kind_pairing": "Pairing",
39
+ "node.diag.kind_session": "Session",
40
+ "node.diag.no_sessions": "No controller is connected right now.",
41
+ "node.diag.no_subscriptions": "connected but receiving nothing",
42
+ "node.diag.not_advertising": "DNS-SD advertising is switched off, so no controller can discover this node.",
43
+ "node.diag.pase": "Pairing",
44
+ "node.diag.port": "Port",
45
+ "node.diag.sessions": "Connected controllers",
46
+ "node.diag.sessions_hint": "How long each controller has been quiet. A controller that goes away without disconnecting leaves its session open and simply stops sending.",
47
+ "node.diag.sessions_occupancy": "Session ids: {live} live · {reserved} reserved for handshakes in progress · {free} of {capacity} free",
48
+ "node.diag.severity.error": "Blocking",
49
+ "node.diag.severity.warning": "Warning",
50
+ "node.diag.title": "Node diagnostics",
51
+ "node.endpoints.empty": "No endpoint is exposed yet.",
52
+ "node.endpoints.hint": "What a controller sees. Endpoint numbers come from stored identity, so they stay the same across restarts but cannot be derived from the item list.",
53
+ "node.endpoints.part_of": "part of #{id}",
54
+ "node.endpoints.reachable": "Reachable",
55
+ "node.endpoints.title": "Endpoints",
56
+ "node.endpoints.unreachable": "Unreachable",
57
+ "node.fabric.label_unknown": "(no label)",
58
+ "node.fabric.share": "Share the node with another controller",
59
+ "node.fabric.share_go": "Go to the pairing page",
60
+ "node.fabric.share_hint": "Adding another controller opens a commissioning window with a QR code, a countdown, and a way to close it again — all of that lives on the pairing page.",
61
+ "node.fabric.unpair_confirm": "Remove this fabric?",
62
+ "node.fabric.unpaired": "Fabric removed.",
63
+ "node.fabrics.col_fabric": "Fabric #",
64
+ "node.fabrics.col_label": "Label",
65
+ "node.fabrics.col_node_id": "Node ID",
66
+ "node.fabrics.col_vendor": "Vendor",
67
+ "node.fabrics.empty": "No fabrics paired yet.",
68
+ "node.fabrics.node_id_rounded": "rounded",
69
+ "node.fabrics.node_id_rounded_hint": "This node id exceeds the precision this list transports, so its last digits may differ from the ones the controller shows.",
70
+ "node.maint.reassemble": "Reassemble endpoints",
71
+ "node.maint.reassemble_done": "Endpoints reassembled.",
72
+ "node.maint.reassemble_hint": "Rebuilds the exposed endpoints from the current selection. Controllers keep their pairing.",
73
+ "node.maint.reset": "Remove all pairings",
74
+ "node.maint.reset_confirm": "Remove all pairings?",
75
+ "node.maint.reset_confirm_body": "Every paired controller loses this node and has to add it again. This cannot be undone.",
76
+ "node.maint.reset_confirm_label": "Remove all",
77
+ "node.maint.reset_done": "All pairings removed.",
78
+ "node.maint.reset_hint": "Returns the node to its unpaired state. Every controller has to add it again.",
79
+ "node.maint.title": "Maintenance",
80
+ "node.pair.add_controller": "Add another controller",
81
+ "node.pair.already_paired": "Controllers already holding this node: {count}. Opening a window adds another one; the existing pairings are untouched.",
82
+ "node.pair.close_window": "Close pairing window",
83
+ "node.pair.copied": "Copied to the clipboard.",
84
+ "node.pair.copy_failed": "The browser refused clipboard access — copy the code by hand.",
85
+ "node.pair.copy_manual_code": "Copy the manual pairing code",
86
+ "node.pair.copy_qr_payload": "Copy the QR payload",
87
+ "node.pair.load_error": "Failed to load commissioning state.",
88
+ "node.pair.loading": "Loading commissioning state…",
89
+ "node.pair.manual_code": "Manual code",
90
+ "node.pair.minutes": "min",
91
+ "node.pair.qr_caption": "Scan the QR code with your controller app",
92
+ "node.pair.qr_payload": "QR payload",
93
+ "node.pair.success": "Controller paired successfully.",
94
+ "node.pair.waiting": "No endpoint is exposed yet — pairing opens once the node exposes at least one.",
95
+ "node.pair.window_open": "Pairing window open",
96
+ "node.pair.window_open_duration": "Open pairing window",
97
+ "node.selection.apply": "Apply",
98
+ "node.selection.back": "Back",
99
+ "node.selection.bulk_expose": "Expose selection",
100
+ "node.selection.bulk_hide": "Hide selection",
101
+ "node.selection.col_device_type": "Device type",
102
+ "node.selection.col_name": "Name",
103
+ "node.selection.col_select": "Select",
104
+ "node.selection.col_state": "State",
105
+ "node.selection.discard": "Discard",
106
+ "node.selection.drawer_aria": "Item detail",
107
+ "node.selection.drawer_clusters": "Clusters",
108
+ "node.selection.drawer_device_type": "Device type",
109
+ "node.selection.drawer_source": "Verdict source",
110
+ "node.selection.drawer_state": "State",
111
+ "node.selection.empty": "No exposable items found.",
112
+ "node.selection.exposed": "Exposed",
113
+ "node.selection.filter_all": "All",
114
+ "node.selection.group_count": "{count} items",
115
+ "node.selection.legend": "Legend",
116
+ "node.selection.mappable_hint": "Fully mappable.",
117
+ "node.selection.name": "Name",
118
+ "node.selection.partially_mappable_hint": "Partially mappable — some of the item stays unexposed.",
119
+ "node.selection.pending": "{count} unsaved change(s)",
120
+ "node.selection.preview": "Preview changes",
121
+ "node.selection.preview_changed": "verdict changes",
122
+ "node.selection.preview_hint": "The verdicts the items carry after the changes. Nothing is written until you apply.",
123
+ "node.selection.preview_title": "What the changes do",
124
+ "node.selection.saved_toast": "{count} update(s) applied.",
125
+ "node.selection.search_placeholder": "Search name, group, values…",
126
+ "node.selection.select_all": "Select all",
127
+ "node.selection.select_row": "Select row",
128
+ "node.selection.state_available": "Available (not exposed)",
129
+ "node.selection.state_exposed": "Exposed",
130
+ "node.selection.state_partial": "Partially mappable",
131
+ "node.selection.state_unmappable": "Cannot be mapped",
132
+ "node.selection.unavailable": "This host serves no selection.",
133
+ "node.selection.unmappable_checkbox_title": "Cannot be mapped to an endpoint",
134
+ "node.selection.unmappable_hint": "Not mappable to an endpoint.",
135
+ "node.status.advertising": "advertising",
136
+ "node.status.disabled": "This host serves no node.",
137
+ "node.status.enabled": "Node active",
138
+ "node.status.endpoints": "{count} endpoints",
139
+ "node.status.exposed": "{count} items exposed",
140
+ "node.status.fabrics": "{count} fabrics",
141
+ "node.status.healthy": "healthy",
142
+ "node.status.listen_addr": "Listening on {addr}",
143
+ "node.status.listening": "listening",
144
+ "node.status.load_error": "The node's status could not be loaded.",
145
+ "node.status.not_listening": "not listening",
146
+ "node.status.unhealthy": "unhealthy",
147
+ "node.subtitle": "Pairing, fabrics, exposed items and diagnostics of this node.",
148
+ "node.tab.diagnostics": "Diagnostics",
149
+ "node.tab.endpoints": "Endpoints",
150
+ "node.tab.fabrics": "Fabrics",
151
+ "node.tab.pair": "Pair",
152
+ "node.tab.selection": "Selection",
153
+ "node.tab.status": "Status",
154
+ "node.title": "Node",
155
+ "select.placeholder": "Select…",
156
+ "shell.language": "Language"
157
+ }
@@ -0,0 +1,50 @@
1
+ // The catalogues and the translator. A host passes its own `t` through the
2
+ // fabric-ui context (it may wrap `createTranslator` with catalogues of its
3
+ // own); the standalone shell provides the default. Mirrors openccu-loom
4
+ // assets/ui/src/lib/i18n.ts:36, :8688-8719 at b40b289a (format, the
5
+ // fallback chain active locale → English → raw key, catalogKeys).
6
+ import en from "./en.json";
7
+ import de from "./de.json";
8
+
9
+ export type Catalog = Record<string, string>;
10
+ export type Vars = Record<string, string | number>;
11
+ export type Translate = (key: string, vars?: Vars) => string;
12
+
13
+ /** The catalogues the package ships. */
14
+ export const catalogs: Record<string, Catalog> = { en, de };
15
+
16
+ /** Replaces `{name}` placeholders; an unknown one stays as written. */
17
+ export function format(s: string, vars?: Vars): string {
18
+ if (!vars) return s;
19
+ return s.replace(/\{(\w+)\}/g, (_, key: string) => {
20
+ const v = vars[key];
21
+ return v == null ? `{${key}}` : String(v);
22
+ });
23
+ }
24
+
25
+ /**
26
+ * A translator over the shipped catalogues plus `extra` (a host's own
27
+ * keys, or overrides of ours, by locale). `locale` is read on every call,
28
+ * so a translator whose locale getter reads reactive state re-renders the
29
+ * text that calls it when the language changes.
30
+ */
31
+ export function createTranslator(locale: () => string, extra: Record<string, Catalog> = {}): Translate {
32
+ return (key, vars) => {
33
+ const loc = locale();
34
+ const value = extra[loc]?.[key] ?? catalogs[loc]?.[key] ?? extra.en?.[key] ?? en[key as keyof typeof en] ?? key;
35
+ return format(value, vars);
36
+ };
37
+ }
38
+
39
+ /** The English translator the kit falls back to outside a fabric-ui context. */
40
+ export const defaultT: Translate = createTranslator(() => "en");
41
+
42
+ /** The keys one shipped catalogue defines, without the English fallback. */
43
+ export function catalogKeys(locale: string): readonly string[] {
44
+ return Object.keys(catalogs[locale] ?? {});
45
+ }
46
+
47
+ /** "de" when the browser's first language is German, "en" otherwise. */
48
+ export function detectLocale(languages: readonly string[] = typeof navigator === "undefined" ? [] : navigator.languages): "en" | "de" {
49
+ return (languages[0] ?? "").toLowerCase().startsWith("de") ? "de" : "en";
50
+ }
@@ -0,0 +1,10 @@
1
+ // @sukramj/fabric-ui — the pages, the context they read, the client, the
2
+ // translator and the theme. Subpath exports: ./pages, ./kit, ./client,
3
+ // ./i18n, ./theme, ./theme/tokens.css.
4
+ export * from "./pages";
5
+ export * from "./context";
6
+ export * from "./api";
7
+ export { createNodeStore, errorText, type NodeStore, type Commissioning, type CommissioningPhase } from "./store.svelte";
8
+ export { catalogs, createTranslator, defaultT, detectLocale, format, catalogKeys, type Catalog, type Translate, type Vars } from "./i18n";
9
+ export * from "./theme";
10
+ export { renderQrSvg } from "./qr";
@@ -0,0 +1,26 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui//Badge.svelte:1-43 at b40b289a. -->
2
+ <script lang="ts">
3
+ import type { Snippet } from "svelte";
4
+ import { cn } from "./utils";
5
+
6
+ type Variant = "default" | "success" | "warning" | "danger" | "muted";
7
+ // title is a plain HTML passthrough: a badge that carries meaning in its
8
+ // colour needs a text equivalent, and colour alone is not one.
9
+ type Props = { variant?: Variant; class?: string; title?: string; children?: Snippet };
10
+
11
+ let { variant = "default", class: className, title, children }: Props = $props();
12
+
13
+ // A 15% tint of the matching token as the fill and the token as the text
14
+ // colour, so the badge follows the active skin and flips with .dark.
15
+ const variants: Record<Variant, string> = {
16
+ default: "bg-[color-mix(in_srgb,var(--ha-primary-color)_15%,transparent)] text-[var(--ha-primary-color)]",
17
+ success: "bg-[color-mix(in_srgb,var(--ha-success-color)_15%,transparent)] text-[var(--ha-success-color)]",
18
+ warning: "bg-[color-mix(in_srgb,var(--ha-warning-color)_15%,transparent)] text-[var(--ha-warning-color)]",
19
+ danger: "bg-[color-mix(in_srgb,var(--ha-error-color)_15%,transparent)] text-[var(--ha-error-color)]",
20
+ muted: "bg-[var(--ha-secondary-background-color)] text-[var(--ha-secondary-text-color)]",
21
+ };
22
+ </script>
23
+
24
+ <span class={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium", variants[variant], className)} {title}>
25
+ {@render children?.()}
26
+ </span>
@@ -0,0 +1,49 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui//Button.svelte:1-72 at b40b289a; the disabled fills use the
2
+ tokens instead of the slate ramp. -->
3
+ <script lang="ts">
4
+ import type { Snippet } from "svelte";
5
+ import type { HTMLButtonAttributes } from "svelte/elements";
6
+ import { cn } from "./utils";
7
+
8
+ type Variant = "default" | "outline" | "ghost" | "destructive" | "outline-destructive";
9
+ type Size = "sm" | "md" | "lg" | "icon";
10
+
11
+ type Props = HTMLButtonAttributes & { variant?: Variant; size?: Size; children?: Snippet };
12
+
13
+ let { variant = "default", size = "md", class: className, children, ...rest }: Props = $props();
14
+
15
+ // A filled variant greys out explicitly when disabled: at 50% opacity a
16
+ // saturated fill still reads as active on a dark card.
17
+ const disabledFill =
18
+ "disabled:bg-[var(--ha-secondary-background-color)] disabled:text-[var(--ha-disabled-text-color)] disabled:shadow-none";
19
+ const disabledMuted = "disabled:opacity-50 disabled:text-[var(--ha-disabled-text-color)]";
20
+ const variants: Record<Variant, string> = {
21
+ default: `bg-[var(--ha-primary-color)] text-white hover:bg-[var(--ha-primary-color-hover)] shadow-sm ${disabledFill}`,
22
+ outline: `border border-[var(--ha-divider-color)] bg-[var(--ha-card-background-color)] text-[var(--ha-primary-text-color)] hover:bg-[var(--ha-secondary-background-color)] ${disabledMuted}`,
23
+ ghost: `text-[var(--ha-primary-text-color)] hover:bg-[var(--ha-secondary-background-color)] ${disabledMuted}`,
24
+ destructive: `bg-[var(--ha-error-color)] text-white hover:brightness-95 shadow-sm ${disabledFill}`,
25
+ // Outline weight, red border and text; it fills red on hover, at the
26
+ // point of commitment.
27
+ "outline-destructive": `border border-[color-mix(in_srgb,var(--ha-error-color)_40%,transparent)] bg-[var(--ha-card-background-color)] text-[var(--ha-error-color)] hover:border-[var(--ha-error-color)] hover:bg-[var(--ha-error-color)] hover:text-white ${disabledMuted}`,
28
+ };
29
+ // Touch-first heights: even sm clears ~36px.
30
+ const sizes: Record<Size, string> = {
31
+ sm: "h-9 px-3 text-sm",
32
+ md: "h-10 px-4 text-sm",
33
+ lg: "h-11 px-5 text-base",
34
+ icon: "h-10 w-10",
35
+ };
36
+ </script>
37
+
38
+ <button
39
+ type="button"
40
+ class={cn(
41
+ "inline-flex items-center justify-center gap-2 rounded-md font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ha-primary-color)] disabled:cursor-not-allowed",
42
+ variants[variant],
43
+ sizes[size],
44
+ className,
45
+ )}
46
+ {...rest}
47
+ >
48
+ {@render children?.()}
49
+ </button>
@@ -0,0 +1,16 @@
1
+ <!-- Ported from openccu-loom assets/ui/src/lib/components/ui//Card.svelte:1-18 at b40b289a. -->
2
+ <script lang="ts">
3
+ import type { Snippet } from "svelte";
4
+ import type { HTMLAttributes } from "svelte/elements";
5
+ import { cn } from "./utils";
6
+
7
+ type Props = HTMLAttributes<HTMLDivElement> & { children?: Snippet };
8
+ let { class: className, children, ...rest }: Props = $props();
9
+ </script>
10
+
11
+ <div
12
+ class={cn("rounded-[var(--ha-radius-card)] border border-[var(--ha-divider-color)] bg-[var(--ha-card-background-color)] shadow-[var(--ha-elevation-card)]", className)}
13
+ {...rest}
14
+ >
15
+ {@render children?.()}
16
+ </div>