@emdzej/bimmerz-ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # @emdzej/bimmerz-ui
2
2
 
3
- Shared **Svelte 5** components for the bimmerz app family. Source-only —
4
- consumer apps' Vite + svelte-plugin compiles them. No pre-compiled output.
3
+ Shared **Svelte 5** components + lifecycle hooks for the bimmerz app
4
+ family (dashx, ediabasx, inpax, ncsx, …). Source-only — consumer
5
+ apps' Vite + svelte-plugin compiles them. No pre-compiled output.
5
6
 
6
7
  ## Convention: source-only Svelte libs
7
8
 
8
- Svelte components ship as `.svelte` files, not pre-compiled JS. The
9
+ Svelte components ship as `.svelte` files, rune helpers as
10
+ `.svelte.ts` — both compiled by the consumer's Svelte plugin. The
9
11
  `package.json` carries:
10
12
 
11
13
  ```json
@@ -22,18 +24,19 @@ Svelte components ship as `.svelte` files, not pre-compiled JS. The
22
24
  }
23
25
  ```
24
26
 
25
- The `svelte` export condition is what the consumer's Vite plugin reads to
26
- locate the source. The `types` condition lets `svelte-check` resolve them
27
- the same way without an explicit `.d.ts` step.
27
+ The `svelte` export condition is what the consumer's Vite plugin
28
+ reads to locate the source. The `types` condition lets
29
+ `svelte-check` resolve them the same way without an explicit
30
+ `.d.ts` step.
28
31
 
29
- **Why:** Svelte 5's compiler output depends on the consumer's compiler
30
- version + rune config. Pre-compiling locks the output to whichever Svelte
31
- version the lib was built against and breaks tree-shaking + HMR in the
32
- consuming app. Letting consumers compile means every version, every dev
33
- mode, every runes setting works.
32
+ **Why:** Svelte 5's compiler output depends on the consumer's
33
+ compiler version + rune config. Pre-compiling locks the output to
34
+ whichever Svelte version the lib was built against and breaks
35
+ tree-shaking + HMR in the consuming app. Letting consumers compile
36
+ means every version, every dev mode, every runes setting works.
34
37
 
35
- A consumer's `vite.config.ts` should also tell `optimizeDeps` about us so
36
- the dev server pre-bundles the source:
38
+ A consumer's `vite.config.ts` should also tell `optimizeDeps`
39
+ about us so the dev server pre-bundles the source:
37
40
 
38
41
  ```ts
39
42
  optimizeDeps: {
@@ -43,23 +46,20 @@ optimizeDeps: {
43
46
 
44
47
  ## Required peer setup
45
48
 
46
- Components use semantic colour tokens (`bg-surface`, `text-foreground`,
47
- `text-accent`, …) from [`@emdzej/bimmerz-theme`](../theme). Wire that
48
- preset into the consumer app's `tailwind.config.ts` first; the
49
- components break visually without it.
49
+ Components use semantic colour tokens (`bg-surface`,
50
+ `text-foreground`, `text-accent`, `m-stripe`, …) from
51
+ [`@emdzej/bimmerz-theme`](../theme). Wire that preset into the
52
+ consumer app's `tailwind.config.ts` and import its `tokens.css`
53
+ first; the components break visually without those tokens.
50
54
 
51
55
  ## Components
52
56
 
53
57
  | Name | Purpose |
54
58
  |---|---|
55
- | `<Brand body="EDIABAS" suffix="X" />` | Split-colour wordmark — body in foreground, suffix in accent. |
59
+ | `<Brand body suffix class? />` | Split-colour wordmark — body in foreground, suffix in accent. |
60
+ | `<MStripe class? />` | BMW M tricolour bar (light-blue / dark-blue / red) used as the page-top signature. Renders the `.m-stripe` element from `@emdzej/bimmerz-theme/tokens.css`. |
56
61
 
57
- More to come — About dialog, Settings dialog scaffolding, Connect button,
58
- install picker. The existing app-specific implementations in inpax-web /
59
- ncsx-web / ediabasx-web are slated for consolidation here once their
60
- shapes stabilise.
61
-
62
- ## Usage
62
+ ### `<Brand>`
63
63
 
64
64
  ```svelte
65
65
  <script lang="ts">
@@ -67,7 +67,110 @@ shapes stabilise.
67
67
  </script>
68
68
 
69
69
  <Brand body="EDIABAS" suffix="X" />
70
+ <Brand body="DASH" suffix="X" class="text-2xl" />
71
+ ```
72
+
73
+ Default styling is `text-sm font-bold tracking-wide`. Pass extra
74
+ Tailwind classes via `class` for hero / welcome-screen variants.
75
+ Each consumer app picks its own `accent` colour via
76
+ `tailwind.config.ts` extend.
77
+
78
+ ### `<MStripe>`
79
+
80
+ ```svelte
81
+ <script lang="ts">
82
+ import { MStripe } from "@emdzej/bimmerz-ui";
83
+ </script>
84
+
85
+ <MStripe />
86
+ <MStripe class="h-2" /> <!-- thicker variant -->
70
87
  ```
71
88
 
72
- Pair with `@emdzej/bimmerz-theme` for the colour tokens. Each consumer
73
- app picks its own `accent` colour via `tailwind.config.ts` extend.
89
+ Renders three bands (`m-stripe__band--light`, `--dark`, `--red`).
90
+ Colours resolve through the `.m-stripe` CSS in
91
+ `@emdzej/bimmerz-theme/tokens.css` — consumer must have those
92
+ tokens imported. Default height is 4 px.
93
+
94
+ ## Hooks
95
+
96
+ ### `useEmbeddedAutoConnect`
97
+
98
+ Embedded-mode lifecycle hook for every dongle-hosted app. Browser
99
+ builds keep the manual "Connect" button; embedded builds (those
100
+ served by the bimmerz-box at `/dashx/`, `/inpax/`, …) wire this
101
+ hook into `App.svelte` to **auto-connect on open**, **auto-disconnect
102
+ on close**, and **auto-reconnect with exponential backoff** on
103
+ transient transport drops.
104
+
105
+ The hook is a **no-op when `isEmbedded === false`**, so calling it
106
+ unconditionally from `App.svelte` is the intended pattern. Must be
107
+ called inside a Svelte 5 component context (uses `$effect`).
108
+
109
+ ```svelte
110
+ <script lang="ts">
111
+ import { useEmbeddedAutoConnect } from "@emdzej/bimmerz-ui";
112
+ import { isEmbedded } from "./lib/embedded";
113
+ import { app } from "./lib/state.svelte";
114
+ import { connect, disconnect } from "./lib/connection.svelte";
115
+
116
+ useEmbeddedAutoConnect({
117
+ isEmbedded,
118
+ connect,
119
+ disconnect,
120
+ /* Optional readiness gate — inpax/ncsx wait for the install to
121
+ load before connecting; dashx/ediabasx can omit this. */
122
+ isReady: () => app.install !== null,
123
+ /* Drives the auto-reconnect loop: when this flips back to
124
+ false (transient Wi-Fi drop, dongle reboot, bus-off), the
125
+ hook re-enters the backoff retry loop. */
126
+ isConnected: () => app.status.kind === "connected",
127
+ });
128
+ </script>
129
+ ```
130
+
131
+ **Behaviour:**
132
+
133
+ | Event | Effect |
134
+ |---|---|
135
+ | Mount, `isEmbedded` + `isReady` true | Call `connect()` once. |
136
+ | `connect()` throws | Retry with exponential backoff (1 → 2 → 4 → 8 → 16 → 30 s cap). Reset on success. |
137
+ | `isConnected()` observed false later | Re-enter the backoff retry loop. |
138
+ | `beforeunload` / `pagehide` | Fire-and-forget `disconnect()` so the dongle WebSocket closes cleanly. Both events handled — `pagehide` covers mobile Safari's bfcache. |
139
+ | `isEmbedded` false | Hook does nothing. Manual Connect button stays in charge. |
140
+
141
+ **Options:**
142
+
143
+ ```ts
144
+ interface AutoConnectOptions {
145
+ isEmbedded: boolean;
146
+ connect: () => Promise<void>;
147
+ disconnect: () => Promise<void>;
148
+ isReady?: () => boolean; // default: always ready
149
+ isConnected?: () => boolean; // omit to attempt only once
150
+ maxBackoffMs?: number; // default 30_000
151
+ initialBackoffMs?: number; // default 1_000
152
+ log?: (msg: string, level?: "info" | "warn" | "error") => void;
153
+ }
154
+ ```
155
+
156
+ **Where to use it:**
157
+
158
+ - **dashx-web** — `connect()` opens the RPC CAN session at
159
+ `${origin}/rpc/can/0`. No `isReady` gate needed.
160
+ - **ediabasx-web** — `connect()` opens the ediabasx RPC session at
161
+ `${origin}/rpc/ediabasx`. No `isReady` gate needed.
162
+ - **inpax-web** / **ncsx-web** — `connect()` opens the ediabasx-server
163
+ RPC session over WebSocket. `isReady` should return true once the
164
+ remote install has loaded; otherwise the hook idles until then.
165
+
166
+ **Why centralise this**: every embedded app needs the same
167
+ lifecycle (connect on mount, clean disconnect on close, retry on
168
+ transient failure). Without the shared hook each app drifts —
169
+ different backoff curves, different unload event sets, different
170
+ reconnect semantics. One implementation, four consumers.
171
+
172
+ ## Versioning
173
+
174
+ `0.2.0` adds the `useEmbeddedAutoConnect` hook. `Brand` + `MStripe`
175
+ APIs are unchanged from `0.1.x`. SemVer minor — existing consumers
176
+ upgrade without code changes.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@emdzej/bimmerz-ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
- "description": "Shared Svelte 5 components for the bimmerz app family — About dialog, Settings scaffolding, Connect button, brand wordmark, install picker. Source-only — consumer apps' Vite compiles them.",
5
+ "description": "Shared Svelte 5 components + lifecycle hooks for the bimmerz app family — brand wordmark, M-stripe, embedded-mode auto-connect hook. Source-only — consumer apps' Vite compiles them.",
6
6
  "svelte": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
8
  "exports": {
package/src/index.ts CHANGED
@@ -14,3 +14,13 @@
14
14
 
15
15
  export { default as Brand } from "./Brand.svelte";
16
16
  export { default as MStripe } from "./MStripe.svelte";
17
+
18
+ /* Embedded-mode lifecycle hook — shared across every dongle-hosted
19
+ app (dashx / ediabasx / inpax / ncsx). Browser builds keep the
20
+ manual Connect button; embedded builds wire this hook into
21
+ App.svelte to auto-connect on mount + auto-disconnect on
22
+ beforeunload + auto-reconnect with backoff on transient drops. */
23
+ export {
24
+ useEmbeddedAutoConnect,
25
+ type AutoConnectOptions,
26
+ } from "./useEmbeddedAutoConnect.svelte.js";
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `useEmbeddedAutoConnect` — shared embedded-mode lifecycle hook
3
+ * for every bimmerz app (dashx / ediabasx / inpax / ncsx / …).
4
+ *
5
+ * Embedded builds run inside the bimmerz-box dongle and serve the
6
+ * SPA at a fixed path (`/dashx/`, `/inpax/`, …) with the backend
7
+ * always at `window.location.origin`. The user opening the page
8
+ * explicitly navigated to the dongle — there's no ambiguity about
9
+ * what to connect to, so the "Connect" button is friction the
10
+ * embedded form factor doesn't need.
11
+ *
12
+ * What the hook does, called once from `App.svelte`:
13
+ *
14
+ * 1. **On mount** — if `isEmbedded` is true AND optional
15
+ * `isReady()` gate returns true (inpax/ncsx use this to wait
16
+ * for the install to load), kick off `connect()` once.
17
+ * 2. **Auto-reconnect** — when `isConnected()` is observed to
18
+ * flip back to false (transient Wi-Fi drop, dongle reboot,
19
+ * bus-off recovery), retry with exponential backoff
20
+ * (1 s → 2 → 4 → 8 → 16 → 30 s cap). Successful connect
21
+ * resets the backoff.
22
+ * 3. **On unload / unmount** — call `disconnect()` so the
23
+ * dongle-side WebSocket closes cleanly instead of waiting
24
+ * on TCP keep-alive. Avoids "duplicate session" rejection
25
+ * on the next visit if the dongle tracks single-client
26
+ * sessions per endpoint.
27
+ *
28
+ * The hook is **a no-op when `isEmbedded === false`**. Browser
29
+ * builds keep their manual Connect button — Web Serial picker
30
+ * needs a user gesture anyway, so auto-connect couldn't help.
31
+ *
32
+ * Must be called inside a Svelte 5 component context (uses
33
+ * `$effect`).
34
+ */
35
+
36
+ export interface AutoConnectOptions {
37
+ /**
38
+ * Whether this build is the dongle-embedded variant. When
39
+ * false the hook does nothing.
40
+ */
41
+ isEmbedded: boolean;
42
+
43
+ /**
44
+ * Open the bus / RPC session. May throw — the hook catches and
45
+ * schedules a retry. Should be idempotent (a second call while
46
+ * already connected should not error or duplicate work).
47
+ */
48
+ connect: () => Promise<void>;
49
+
50
+ /**
51
+ * Tear down the connection. Called on `beforeunload`. Should be
52
+ * fast and tolerant of being called while already disconnected.
53
+ */
54
+ disconnect: () => Promise<void>;
55
+
56
+ /**
57
+ * Reactive readiness check — return true when the app is ready
58
+ * to connect. Used by inpax/ncsx to wait for the install to
59
+ * load; dashx/ediabasx can omit it (always ready).
60
+ */
61
+ isReady?: () => boolean;
62
+
63
+ /**
64
+ * Reactive connection-state read. Drives auto-reconnect: when
65
+ * this flips from true to false the hook starts a backoff loop.
66
+ * If omitted the hook only ever attempts a single connect.
67
+ */
68
+ isConnected?: () => boolean;
69
+
70
+ /**
71
+ * Cap on the exponential backoff (ms). Default 30 000.
72
+ */
73
+ maxBackoffMs?: number;
74
+
75
+ /**
76
+ * Initial backoff (ms). Default 1 000.
77
+ */
78
+ initialBackoffMs?: number;
79
+
80
+ /**
81
+ * Optional logger. The hook is intentionally quiet by default —
82
+ * embedded mode runs unattended for hours, log spam isn't useful.
83
+ * Supply this if you want connect attempts surfaced (a project
84
+ * usually wires it to its bimmerz-logger category).
85
+ */
86
+ log?: (message: string, level?: "info" | "warn" | "error") => void;
87
+ }
88
+
89
+ const DEFAULT_INITIAL_BACKOFF_MS = 1_000;
90
+ const DEFAULT_MAX_BACKOFF_MS = 30_000;
91
+
92
+ export function useEmbeddedAutoConnect(options: AutoConnectOptions): void {
93
+ /* Browser builds: do nothing. Calling the hook unconditionally
94
+ from App.svelte is the intended pattern; this guard is what
95
+ makes that cheap. */
96
+ if (!options.isEmbedded) return;
97
+
98
+ const initial = options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF_MS;
99
+ const cap = options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
100
+ const log = options.log ?? (() => undefined);
101
+
102
+ /* Connection-state loop. $effect re-runs whenever its tracked
103
+ reactive deps (`isReady()` + `isConnected()`) change. Each run
104
+ either:
105
+ • exits silently (already connected, or not ready), or
106
+ • starts an attempt → retry loop until success or unmount.
107
+ The cleanup cancels any pending retry so a re-run doesn't
108
+ stack timers. */
109
+ $effect(() => {
110
+ /* Read both gates synchronously so the effect tracks them. */
111
+ const ready = options.isReady ? options.isReady() : true;
112
+ const connected = options.isConnected ? options.isConnected() : false;
113
+
114
+ if (!ready) {
115
+ log("auto-connect: waiting for ready", "info");
116
+ return;
117
+ }
118
+ if (connected) {
119
+ /* Already connected — nothing to do. The effect re-runs if
120
+ `isConnected()` flips back to false. */
121
+ return;
122
+ }
123
+
124
+ let cancelled = false;
125
+ let backoff = initial;
126
+ let timer: ReturnType<typeof setTimeout> | null = null;
127
+
128
+ const attempt = async (): Promise<void> => {
129
+ if (cancelled) return;
130
+ try {
131
+ log(`auto-connect: attempting (backoff was ${backoff} ms)`, "info");
132
+ await options.connect();
133
+ backoff = initial;
134
+ log("auto-connect: success", "info");
135
+ /* Don't re-loop — the effect's next re-run (driven by
136
+ `isConnected()` going true) is what handles the
137
+ steady-state "connected" branch. */
138
+ } catch (err) {
139
+ const msg = err instanceof Error ? err.message : String(err);
140
+ log(`auto-connect: failed (${msg}); retry in ${backoff} ms`, "warn");
141
+ const wait = backoff;
142
+ backoff = Math.min(backoff * 2, cap);
143
+ if (!cancelled) {
144
+ timer = setTimeout(() => {
145
+ timer = null;
146
+ void attempt();
147
+ }, wait);
148
+ }
149
+ }
150
+ };
151
+
152
+ void attempt();
153
+
154
+ return () => {
155
+ cancelled = true;
156
+ if (timer !== null) {
157
+ clearTimeout(timer);
158
+ timer = null;
159
+ }
160
+ };
161
+ });
162
+
163
+ /* Clean shutdown on tab close. `beforeunload` is the canonical
164
+ signal; we register only in embedded mode so browser builds
165
+ never pay the cost. Disconnect is fire-and-forget — by the
166
+ time the promise resolves the page might be gone, that's
167
+ fine. */
168
+ $effect(() => {
169
+ if (typeof window === "undefined") return;
170
+ const handler = (): void => {
171
+ try {
172
+ void options.disconnect().catch(() => undefined);
173
+ } catch {
174
+ /* swallow */
175
+ }
176
+ };
177
+ window.addEventListener("beforeunload", handler);
178
+ /* `pagehide` is the mobile-Safari-friendly counterpart — fires
179
+ when the tab is suspended / put into the back/forward cache.
180
+ Adding both is safe; the disconnect is idempotent. */
181
+ window.addEventListener("pagehide", handler);
182
+ return () => {
183
+ window.removeEventListener("beforeunload", handler);
184
+ window.removeEventListener("pagehide", handler);
185
+ };
186
+ });
187
+ }