@sorb/leaf 0.2.0 → 0.3.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/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/TokenProvider.jsx
2
- import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import React, { useCallback, useEffect, useMemo, useRef } from "react";
3
3
 
4
4
  // src/context.js
5
5
  import { createContext, useContext } from "react";
@@ -92,6 +92,112 @@ var applyTokens = (tokens) => {
92
92
  root.style.setProperty(`--${key}`, result.value);
93
93
  });
94
94
  };
95
+ var clearTokenOverrides = (tokens) => {
96
+ const root = document.documentElement;
97
+ Object.keys(tokens).forEach((key) => {
98
+ root.style.removeProperty(`--${key}`);
99
+ });
100
+ };
101
+ var MODE_STYLESHEET_ID = "sorb-tokens";
102
+ var injectModeStylesheet = (css) => {
103
+ let tag = document.getElementById(MODE_STYLESHEET_ID);
104
+ if (!tag) {
105
+ tag = document.createElement("style");
106
+ tag.id = MODE_STYLESHEET_ID;
107
+ document.head.appendChild(tag);
108
+ }
109
+ tag.textContent = css;
110
+ };
111
+ var clearModeStylesheet = () => {
112
+ const tag = document.getElementById(MODE_STYLESHEET_ID);
113
+ if (tag && tag.parentNode) tag.parentNode.removeChild(tag);
114
+ };
115
+
116
+ // src/modeStylesheet.js
117
+ var buildModeStylesheet = (lightVars, darkVars, darkMode) => {
118
+ const lightDecls = normalizeDecls(lightVars);
119
+ const hasDark = !!darkMode && !!darkVars && Object.keys(darkVars).length > 0;
120
+ if (!hasDark) {
121
+ return `:root {
122
+ ${indent(lightDecls)}
123
+ }
124
+ `;
125
+ }
126
+ const darkDecls = normalizeDecls(darkVars);
127
+ const darkSelector = darkMode.darkSelector;
128
+ const lightSelector = darkMode.lightSelector;
129
+ const mediaScopeSelector = lightSelector ? `:root:not(${lightSelector})` : ":root";
130
+ const lines = [];
131
+ lines.push(":root {");
132
+ lines.push(indent([...lightDecls, "color-scheme: light;"]));
133
+ lines.push("}");
134
+ lines.push("@media (prefers-color-scheme: dark) {");
135
+ lines.push(` ${mediaScopeSelector} {`);
136
+ lines.push(indent([...darkDecls, "color-scheme: dark;"], 2));
137
+ lines.push(" }");
138
+ lines.push("}");
139
+ lines.push(`${darkSelector} {`);
140
+ lines.push(indent([...darkDecls, "color-scheme: dark;"]));
141
+ lines.push("}");
142
+ if (lightSelector) {
143
+ lines.push(`${lightSelector} {`);
144
+ lines.push(indent([...lightDecls, "color-scheme: light;"]));
145
+ lines.push("}");
146
+ }
147
+ return `${lines.join("\n")}
148
+ `;
149
+ };
150
+ var normalizeDecls = (vars) => {
151
+ if (!vars) return [];
152
+ return Object.entries(vars).reduce((acc, [key, value]) => {
153
+ const result = sanitizeCssValue(String(value));
154
+ if (!result.ok) return acc;
155
+ const cssVar = key.startsWith("--") ? key : `--${key}`;
156
+ acc.push(`${cssVar}: ${result.value};`);
157
+ return acc;
158
+ }, []);
159
+ };
160
+ var indent = (lines, level = 1) => {
161
+ const pad = " ".repeat(level);
162
+ return lines.map((l) => `${pad}${l}`).join("\n");
163
+ };
164
+
165
+ // node_modules/@sorb/core/src/index.js
166
+ var TIERS = Object.freeze(["component", "semantic", "primitive"]);
167
+ var TIER_RANK = Object.freeze({ component: 0, semantic: 1, primitive: 2 });
168
+ var connectors = Object.freeze({
169
+ source: /* @__PURE__ */ new Map(),
170
+ codeSource: /* @__PURE__ */ new Map(),
171
+ target: /* @__PURE__ */ new Map()
172
+ });
173
+ function registerTarget(adapter) {
174
+ connectors.target.set(adapter.id, adapter);
175
+ return adapter;
176
+ }
177
+
178
+ // src/targets/reactBootstrap.js
179
+ var SORB_TOKENSET_FORMAT_ID = "sorb/tokenset-esm";
180
+ var reactBootstrapTarget = {
181
+ id: "react-bootstrap",
182
+ emitFormat: SORB_TOKENSET_FORMAT_ID,
183
+ // The Bootstrap-styled vocab namespace (matches `sorb-demo/src/sorbConfig.js`'s
184
+ // `preview.expectPrefixes: ['bs-']`).
185
+ expectPrefixes: ["bs-"],
186
+ // Left undefined on purpose — see file header.
187
+ inject: void 0,
188
+ // Bootstrap 5.3's native dark-mode convention (real-dark-mode spec D1): a
189
+ // `data-bs-theme` attribute on any ancestor (Bootstrap recommends
190
+ // `<html>`) selects the mode; absent ⇒ OS `prefers-color-scheme` governs.
191
+ darkMode: {
192
+ strategy: "attribute",
193
+ attribute: "data-bs-theme",
194
+ darkSelector: '[data-bs-theme="dark"]',
195
+ lightSelector: '[data-bs-theme="light"]'
196
+ }
197
+ };
198
+ if (typeof registerTarget === "function") {
199
+ registerTarget(reactBootstrapTarget);
200
+ }
95
201
 
96
202
  // src/previewGuard.js
97
203
  var DEFAULT_ORIGIN = "http://localhost:7777";
@@ -134,8 +240,192 @@ var shouldLoadPreview = (config) => {
134
240
  return { allowed: false, origin, reason: "origin-not-allowlisted" };
135
241
  };
136
242
 
137
- // src/TokenProvider.jsx
138
- import { jsx } from "react/jsx-runtime";
243
+ // src/previewVocab.js
244
+ var countMatchingPrefixes = (tokens, prefixes) => {
245
+ const list = Array.isArray(prefixes) ? prefixes : [];
246
+ return Object.keys(tokens || {}).filter((key) => list.some((p) => key.startsWith(p))).length;
247
+ };
248
+ var checkPreviewVocabulary = ({ tokens, expectPrefixes, previewId }) => {
249
+ if (!Array.isArray(expectPrefixes) || expectPrefixes.length === 0) return false;
250
+ const appliedCount = Object.keys(tokens || {}).length;
251
+ if (appliedCount === 0) return false;
252
+ const matched = countMatchingPrefixes(tokens, expectPrefixes);
253
+ if (matched > 0) return false;
254
+ try {
255
+ console.warn(
256
+ `[Sorb] preview ${previewId ? `"${previewId}" ` : ""}applied ${appliedCount} tokens but none match expected prefixes ${JSON.stringify(expectPrefixes)} \u2014 the app may not visibly re-skin (token-vocabulary mismatch).`
257
+ );
258
+ } catch (e) {
259
+ }
260
+ return true;
261
+ };
262
+
263
+ // src/bridgeAuth.js
264
+ var bridgeHeaders = (key, base) => {
265
+ const headers = base ? { ...base } : {};
266
+ if (typeof key === "string" && key.trim() !== "") {
267
+ headers.Authorization = `Bearer ${key.trim()}`;
268
+ }
269
+ return headers;
270
+ };
271
+
272
+ // src/connection.js
273
+ var DEFAULT_CLOUD_BASE = "https://api.sorbcloud.com";
274
+ var getOrgKey = (config) => {
275
+ if (!config) return null;
276
+ const key = config.orgKey || config.publishableKey;
277
+ return typeof key === "string" && key.trim() !== "" ? key.trim() : null;
278
+ };
279
+ var shouldResolveOrgConnection = (config) => {
280
+ const key = getOrgKey(config);
281
+ if (!key) return false;
282
+ const explicitOrigin = config && config.preview && config.preview.origin;
283
+ return !(typeof explicitOrigin === "string" && explicitOrigin.trim() !== "");
284
+ };
285
+ var resolveOrgConnection = async (orgKey, opts) => {
286
+ const { cloudBase = DEFAULT_CLOUD_BASE, fetchImpl } = opts || {};
287
+ const doFetch = fetchImpl || (typeof fetch !== "undefined" ? fetch : null);
288
+ if (!doFetch || typeof orgKey !== "string" || orgKey.trim() === "") return null;
289
+ try {
290
+ const base = cloudBase.replace(/\/$/, "");
291
+ const url = `${base}/api/orgs/resolve?key=${encodeURIComponent(orgKey.trim())}`;
292
+ const res = await doFetch(url);
293
+ if (!res || !res.ok) return null;
294
+ const data = await res.json();
295
+ if (!data || typeof data !== "object") return null;
296
+ if (typeof data.bridgeUrl !== "string" || data.bridgeUrl.trim() === "") return null;
297
+ const bridgeMode = typeof data.bridgeMode === "string" ? data.bridgeMode : "C";
298
+ return {
299
+ bridgeMode,
300
+ bridgeUrl: data.bridgeUrl,
301
+ orgId: typeof data.orgId === "string" ? data.orgId : null,
302
+ tokenSource: typeof data.tokenSource === "string" ? data.tokenSource : null,
303
+ // sorb-cloud's /api/orgs/resolve returns this as a boolean (entitlement
304
+ // flag), not an object — see cloud src/lib/orgResolve.ts.
305
+ previewPersistence: typeof data.previewPersistence === "boolean" ? data.previewPersistence : null,
306
+ transport: data.transport === "poll" || data.transport === "sse" ? data.transport : bridgeMode === "A" ? "sse" : "poll"
307
+ };
308
+ } catch (e) {
309
+ return null;
310
+ }
311
+ };
312
+ var buildEffectivePreviewConfig = (config, resolved) => {
313
+ const base = config && config.preview || {};
314
+ if (!resolved) return base;
315
+ const orgKey = getOrgKey(config);
316
+ return {
317
+ ...base,
318
+ enabled: true,
319
+ origin: resolved.bridgeUrl,
320
+ allowedOrigins: [...Array.isArray(base.allowedOrigins) ? base.allowedOrigins : [], resolved.bridgeUrl],
321
+ key: base.key || orgKey || void 0
322
+ };
323
+ };
324
+ var buildEffectiveConfig = (config, resolved) => {
325
+ if (!resolved) return config;
326
+ return { ...config, preview: buildEffectivePreviewConfig(config, resolved) };
327
+ };
328
+
329
+ // src/sse.js
330
+ var buildSubscribeUrl = (bridgeUrl, orgId, previewId, key) => {
331
+ const base = String(bridgeUrl).replace(/\/$/, "");
332
+ const path = `${base}/orgs/${encodeURIComponent(orgId)}/preview/${encodeURIComponent(previewId)}/subscribe`;
333
+ if (typeof key === "string" && key.trim() !== "") {
334
+ return `${path}?key=${encodeURIComponent(key.trim())}`;
335
+ }
336
+ return path;
337
+ };
338
+ var parsePreviewFrame = (raw) => {
339
+ let frame;
340
+ try {
341
+ frame = JSON.parse(raw);
342
+ } catch (e) {
343
+ return null;
344
+ }
345
+ if (!frame || typeof frame !== "object") return null;
346
+ if (frame.type === "ping") return { type: "ping" };
347
+ if (frame.type === "delete") return { type: "delete", tokens: null };
348
+ if ((frame.type === "snapshot" || frame.type === "update") && frame.tokens && typeof frame.tokens === "object") {
349
+ return { type: frame.type, tokens: frame.tokens };
350
+ }
351
+ return null;
352
+ };
353
+ var createPreviewSubscription = ({ EventSourceImpl, url, onTokens, onDelete, onError }) => {
354
+ if (typeof EventSourceImpl !== "function") return null;
355
+ const es = new EventSourceImpl(url);
356
+ es.onmessage = (evt) => {
357
+ const parsed = parsePreviewFrame(evt && evt.data);
358
+ if (!parsed || parsed.type === "ping") return;
359
+ if (parsed.type === "delete") {
360
+ if (onDelete) onDelete();
361
+ return;
362
+ }
363
+ onTokens(parsed.tokens);
364
+ };
365
+ const handleError = (evt) => {
366
+ if (onError) onError(evt);
367
+ };
368
+ if (typeof es.addEventListener === "function") {
369
+ es.addEventListener("error", handleError);
370
+ } else {
371
+ es.onerror = handleError;
372
+ }
373
+ return () => {
374
+ try {
375
+ es.close();
376
+ } catch (e) {
377
+ }
378
+ };
379
+ };
380
+
381
+ // src/previewMode.js
382
+ var isModeAwarePreviewBody = (body) => !!body && typeof body === "object" && !Array.isArray(body) && "tokens" in body;
383
+ var resolvePreviewBody = (body, fallbackDarkMode) => {
384
+ if (!isModeAwarePreviewBody(body)) {
385
+ return { kind: "flat", tokens: (
386
+ /** @type {import('./types').TokenSet} */
387
+ body
388
+ ) };
389
+ }
390
+ const wrapper = (
391
+ /** @type {{ tokens: import('./types').TokenSet, darkTokens?: import('./types').TokenSet, darkMode?: import('@sorb/core').DarkModeConvention }} */
392
+ body
393
+ );
394
+ const darkTokens = wrapper.darkTokens;
395
+ if (darkTokens && Object.keys(darkTokens).length > 0) {
396
+ return {
397
+ kind: "mode-aware",
398
+ lightTokens: wrapper.tokens,
399
+ darkTokens,
400
+ darkMode: wrapper.darkMode ?? fallbackDarkMode
401
+ };
402
+ }
403
+ return { kind: "flat", tokens: wrapper.tokens };
404
+ };
405
+
406
+ // src/modeAction.js
407
+ var darkClassName = (darkModeConvention) => {
408
+ const selector = String(darkModeConvention?.darkSelector || "").trim();
409
+ const match = /^\.([a-zA-Z0-9_-]+)$/.exec(selector);
410
+ return match ? match[1] : "dark";
411
+ };
412
+ var resolveModeAction = (darkModeConvention, next) => {
413
+ const strategy = darkModeConvention?.strategy || "attribute";
414
+ if (strategy === "media") {
415
+ return { type: "none" };
416
+ }
417
+ if (strategy === "class") {
418
+ const className = darkClassName(darkModeConvention);
419
+ return next === "dark" ? { type: "class-add", className } : { type: "class-remove", className };
420
+ }
421
+ const attribute = darkModeConvention?.attribute || "data-bs-theme";
422
+ return next === "auto" ? { type: "attr-remove", attribute } : { type: "attr-set", attribute, value: next };
423
+ };
424
+
425
+ // src/core.js
426
+ var EventSourceCtor = typeof EventSource !== "undefined" ? EventSource : null;
427
+ var matchMediaFn = typeof matchMedia !== "undefined" ? matchMedia : null;
428
+ var DARK_MEDIA_QUERY = "(prefers-color-scheme: dark)";
139
429
  var devWarn = (msg) => {
140
430
  try {
141
431
  if (typeof process !== "undefined" && process.env && true) {
@@ -144,52 +434,187 @@ var devWarn = (msg) => {
144
434
  } catch (e) {
145
435
  }
146
436
  };
147
- var SorbProvider = ({ config, children }) => {
148
- const [activeTokens, setActiveTokens] = useState(config.tokens);
149
- const [isPreview, setIsPreview] = useState(false);
150
- const [previewId, setPreviewId] = useState(null);
151
- const pollRef = useRef(null);
152
- const loadCommitted = useCallback(() => {
153
- applyTokens(config.tokens);
154
- setActiveTokens(config.tokens);
155
- setIsPreview(false);
156
- setPreviewId(null);
157
- }, [config.tokens]);
158
- const loadPreview = useCallback(
159
- async (id) => {
160
- const guard = shouldLoadPreview(config);
161
- if (!guard.allowed) {
162
- loadCommitted();
163
- return false;
164
- }
165
- const origin = guard.origin;
166
- try {
167
- const res = await fetch(`${origin}/preview/${id}`);
168
- if (!res.ok) throw new Error("preview not found");
169
- const tokens = await res.json();
170
- applyTokens(tokens);
171
- setActiveTokens(tokens);
172
- setIsPreview(true);
173
- setPreviewId(id);
174
- return true;
175
- } catch (e) {
176
- loadCommitted();
177
- return false;
178
- }
179
- },
180
- [config, loadCommitted]
181
- );
182
- const clearPreview = useCallback(() => {
183
- if (pollRef.current) clearInterval(pollRef.current);
184
- const params = new URLSearchParams(location.search);
185
- params.delete("preview");
186
- const qs = params.toString();
187
- history.replaceState(null, "", qs ? `?${qs}` : location.pathname);
437
+ var warnedDeprecations = /* @__PURE__ */ new Set();
438
+ function warnDeprecated(resolved) {
439
+ if (typeof process !== "undefined" && false) return;
440
+ for (let i = 0; i < resolved.length; i++) {
441
+ const token = resolved[i];
442
+ if (!token.deprecated) continue;
443
+ if (warnedDeprecations.has(token.id)) continue;
444
+ warnedDeprecations.add(token.id);
445
+ const replacedBy = token.replacedBy || token.$extensions && token.$extensions.sorb && token.$extensions.sorb.replacedBy || null;
446
+ if (replacedBy) {
447
+ console.warn("[@sorb/leaf] Deprecated token: " + token.id + " \u2014 use " + replacedBy + " instead");
448
+ } else {
449
+ console.warn("[@sorb/leaf] Deprecated token: " + token.id + " is deprecated");
450
+ }
451
+ }
452
+ }
453
+ function sorbInit(config) {
454
+ let activeTokens = config.tokens;
455
+ let isPreview = false;
456
+ let previewId = null;
457
+ let previewMismatch = false;
458
+ let pollId = null;
459
+ let cancelled = false;
460
+ let unsubscribeSSE = null;
461
+ const hasDarkMode = !!(config.darkTokens && Object.keys(config.darkTokens).length > 0);
462
+ const darkModeConvention = config.darkModeConvention || reactBootstrapTarget.darkMode;
463
+ let mode = "auto";
464
+ let systemScheme = matchMediaFn ? matchMediaFn(DARK_MEDIA_QUERY).matches ? "dark" : "light" : "light";
465
+ const listeners = /* @__PURE__ */ new Set();
466
+ const getState = () => ({
467
+ tokens: activeTokens,
468
+ isPreview,
469
+ previewId,
470
+ previewMismatch,
471
+ mode,
472
+ resolvedScheme: mode === "auto" ? systemScheme : mode
473
+ });
474
+ const notify = () => {
475
+ const state = getState();
476
+ listeners.forEach((listener) => listener(state));
477
+ };
478
+ const subscribe = (listener) => {
479
+ listeners.add(listener);
480
+ return () => listeners.delete(listener);
481
+ };
482
+ let mql = null;
483
+ const onSchemeChange = (e) => {
484
+ systemScheme = e.matches ? "dark" : "light";
485
+ notify();
486
+ };
487
+ if (matchMediaFn) {
488
+ mql = matchMediaFn(DARK_MEDIA_QUERY);
489
+ if (typeof mql.addEventListener === "function") mql.addEventListener("change", onSchemeChange);
490
+ else if (typeof mql.addListener === "function") mql.addListener(onSchemeChange);
491
+ }
492
+ let inlineTokens = null;
493
+ const applyFlat = (tokens) => {
494
+ clearModeStylesheet();
495
+ applyTokens(tokens);
496
+ inlineTokens = tokens;
497
+ };
498
+ const applyModeAware = (lightTokens, darkTokens, convention) => {
499
+ if (inlineTokens) {
500
+ clearTokenOverrides(inlineTokens);
501
+ inlineTokens = null;
502
+ }
503
+ injectModeStylesheet(buildModeStylesheet(lightTokens, darkTokens, convention));
504
+ };
505
+ const loadCommitted = () => {
506
+ if (hasDarkMode) {
507
+ applyModeAware(config.tokens, config.darkTokens, darkModeConvention);
508
+ } else {
509
+ applyFlat(config.tokens);
510
+ }
511
+ activeTokens = config.tokens;
512
+ isPreview = false;
513
+ previewId = null;
514
+ previewMismatch = false;
515
+ notify();
516
+ };
517
+ const applyPreviewTokens = (body, id, effectiveConfig) => {
518
+ const resolved = resolvePreviewBody(body, darkModeConvention);
519
+ let flatTokens;
520
+ if (resolved.kind === "mode-aware") {
521
+ applyModeAware(resolved.lightTokens, resolved.darkTokens, resolved.darkMode);
522
+ flatTokens = resolved.lightTokens;
523
+ } else {
524
+ applyFlat(resolved.tokens);
525
+ flatTokens = resolved.tokens;
526
+ }
527
+ activeTokens = flatTokens;
528
+ isPreview = true;
529
+ previewId = id;
530
+ previewMismatch = checkPreviewVocabulary({
531
+ tokens: flatTokens,
532
+ expectPrefixes: effectiveConfig.preview?.expectPrefixes,
533
+ previewId: id
534
+ });
535
+ notify();
536
+ };
537
+ const loadPreview = async (id, effectiveConfig) => {
538
+ const cfg = effectiveConfig || config;
539
+ const guard = shouldLoadPreview(cfg);
540
+ if (!guard.allowed) {
541
+ loadCommitted();
542
+ return false;
543
+ }
544
+ const origin = guard.origin;
545
+ try {
546
+ const res = await fetch(`${origin}/preview/${id}`, {
547
+ headers: bridgeHeaders(cfg.preview?.key)
548
+ });
549
+ if (!res.ok) throw new Error("preview not found");
550
+ const tokens = await res.json();
551
+ applyPreviewTokens(tokens, id, cfg);
552
+ return true;
553
+ } catch (e) {
554
+ loadCommitted();
555
+ return false;
556
+ }
557
+ };
558
+ const clearPreview = () => {
559
+ if (pollId) {
560
+ clearInterval(pollId);
561
+ pollId = null;
562
+ }
563
+ if (typeof location !== "undefined" && typeof history !== "undefined") {
564
+ const params = new URLSearchParams(location.search);
565
+ params.delete("preview");
566
+ const qs = params.toString();
567
+ history.replaceState(null, "", qs ? `?${qs}` : location.pathname);
568
+ }
188
569
  loadCommitted();
189
- }, [loadCommitted]);
190
- useEffect(() => {
191
- const guard = shouldLoadPreview(config);
192
- const id = new URLSearchParams(location.search).get("preview");
570
+ };
571
+ const setMode = (next) => {
572
+ mode = next;
573
+ if (typeof document !== "undefined") {
574
+ const action = resolveModeAction(darkModeConvention, next);
575
+ switch (action.type) {
576
+ case "attr-set":
577
+ document.documentElement.setAttribute(action.attribute, action.value);
578
+ break;
579
+ case "attr-remove":
580
+ document.documentElement.removeAttribute(action.attribute);
581
+ break;
582
+ case "class-add":
583
+ document.documentElement.classList.add(action.className);
584
+ break;
585
+ case "class-remove":
586
+ document.documentElement.classList.remove(action.className);
587
+ break;
588
+ case "none":
589
+ default:
590
+ break;
591
+ }
592
+ }
593
+ notify();
594
+ };
595
+ const destroy = () => {
596
+ cancelled = true;
597
+ if (pollId) clearInterval(pollId);
598
+ if (unsubscribeSSE) unsubscribeSSE();
599
+ if (mql) {
600
+ if (typeof mql.removeEventListener === "function") mql.removeEventListener("change", onSchemeChange);
601
+ else if (typeof mql.removeListener === "function") mql.removeListener(onSchemeChange);
602
+ }
603
+ listeners.clear();
604
+ };
605
+ const init = async () => {
606
+ if (config.resolved && config.resolved.length) warnDeprecated(config.resolved);
607
+ let effectiveConfig = config;
608
+ let resolvedConnection = null;
609
+ if (shouldResolveOrgConnection(config)) {
610
+ resolvedConnection = await resolveOrgConnection(getOrgKey(config), {
611
+ cloudBase: config.cloudBase
612
+ });
613
+ if (cancelled) return;
614
+ effectiveConfig = buildEffectiveConfig(config, resolvedConnection);
615
+ }
616
+ const guard = shouldLoadPreview(effectiveConfig);
617
+ const id = typeof location !== "undefined" ? new URLSearchParams(location.search).get("preview") : null;
193
618
  if (!guard.allowed || !id) {
194
619
  if (id && !guard.allowed) {
195
620
  devWarn(
@@ -199,16 +624,213 @@ var SorbProvider = ({ config, children }) => {
199
624
  loadCommitted();
200
625
  return;
201
626
  }
202
- loadPreview(id).then((ok) => {
203
- if (!ok) return;
204
- const interval = config.preview?.pollInterval ?? 1500;
205
- pollRef.current = setInterval(() => loadPreview(id), interval);
206
- });
627
+ const ok = await loadPreview(id, effectiveConfig);
628
+ if (!ok || cancelled) return;
629
+ const useSSE = resolvedConnection && resolvedConnection.transport === "sse" && resolvedConnection.orgId && EventSourceCtor;
630
+ if (useSSE) {
631
+ const url = buildSubscribeUrl(
632
+ resolvedConnection.bridgeUrl,
633
+ resolvedConnection.orgId,
634
+ id,
635
+ effectiveConfig.preview?.key
636
+ );
637
+ unsubscribeSSE = createPreviewSubscription({
638
+ EventSourceImpl: EventSourceCtor,
639
+ url,
640
+ onTokens: (tokens) => applyPreviewTokens(tokens, id, effectiveConfig),
641
+ onDelete: () => loadCommitted(),
642
+ onError: () => devWarn("SSE preview subscription error \u2014 preview may be stale")
643
+ });
644
+ }
645
+ if (!unsubscribeSSE) {
646
+ const interval = effectiveConfig.preview?.pollInterval ?? 1500;
647
+ pollId = setInterval(() => loadPreview(id, effectiveConfig), interval);
648
+ }
649
+ };
650
+ init();
651
+ return { getState, subscribe, setMode, clearPreview, destroy };
652
+ }
653
+
654
+ // src/legacyMap.js
655
+ var normalizeProp = (prop) => {
656
+ if (typeof prop !== "string") return "";
657
+ return prop.trim().replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase();
658
+ };
659
+ var canonicalizeColor = (v) => {
660
+ const hex = v.match(/^#([0-9a-f]{3,8})$/);
661
+ if (hex) {
662
+ let h = hex[1];
663
+ if (h.length === 3 || h.length === 4) {
664
+ h = h.split("").map((c) => c + c).join("");
665
+ }
666
+ if (h.length !== 6 && h.length !== 8) return null;
667
+ const r = parseInt(h.slice(0, 2), 16);
668
+ const g = parseInt(h.slice(2, 4), 16);
669
+ const b = parseInt(h.slice(4, 6), 16);
670
+ if (h.length === 8) {
671
+ const a = parseInt(h.slice(6, 8), 16) / 255;
672
+ const as = String(Math.round(a * 1e3) / 1e3);
673
+ return `rgba(${r}, ${g}, ${b}, ${as})`;
674
+ }
675
+ return `rgb(${r}, ${g}, ${b})`;
676
+ }
677
+ const fn = v.match(/^(rgba?)\(([^)]*)\)$/);
678
+ if (fn) {
679
+ const parts = fn[2].split(",").map((p) => p.trim()).filter((p) => p !== "");
680
+ if (parts.length === 3) return `rgb(${parts[0]}, ${parts[1]}, ${parts[2]})`;
681
+ if (parts.length === 4) return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${parts[3]})`;
682
+ }
683
+ return null;
684
+ };
685
+ var normalizeValue = (value) => {
686
+ if (value == null) return "";
687
+ let v = String(value).trim().toLowerCase();
688
+ if (v === "") return "";
689
+ v = v.replace(/\s+/g, " ");
690
+ const color = canonicalizeColor(v);
691
+ if (color) return color;
692
+ if (/^-?\d*\.?\d+$/.test(v)) v = `${v}px`;
693
+ return v;
694
+ };
695
+ var indexLegacyMap = (legacyMap) => {
696
+ const idx = /* @__PURE__ */ new Map();
697
+ if (!Array.isArray(legacyMap)) return idx;
698
+ for (const row of legacyMap) {
699
+ if (!row || row.cssVar == null || row.raw == null || row.prop == null) continue;
700
+ const p = normalizeProp(row.prop);
701
+ const entry = {
702
+ normValue: normalizeValue(row.raw),
703
+ cssVar: String(row.cssVar).replace(/^--/, ""),
704
+ raw: String(row.raw)
705
+ };
706
+ const list = idx.get(p);
707
+ if (list) list.push(entry);
708
+ else idx.set(p, [entry]);
709
+ }
710
+ return idx;
711
+ };
712
+ var computeLegacyOverride = (prop, computedValue, legacyMap) => {
713
+ const idx = legacyMap instanceof Map ? legacyMap : indexLegacyMap(legacyMap);
714
+ const list = idx.get(normalizeProp(prop));
715
+ if (!list || list.length === 0) return null;
716
+ const target = normalizeValue(computedValue);
717
+ if (target === "") return null;
718
+ for (const entry of list) {
719
+ if (entry.normValue === target) {
720
+ return `var(--${entry.cssVar}, ${entry.raw})`;
721
+ }
722
+ }
723
+ return null;
724
+ };
725
+
726
+ // src/legacyDom.js
727
+ var applyLegacyMap = (root, legacyMap) => {
728
+ const restores = [];
729
+ const handle = { restores };
730
+ if (typeof document === "undefined") return handle;
731
+ const start = root ?? document.body;
732
+ if (!start || !Array.isArray(legacyMap) || legacyMap.length === 0) return handle;
733
+ const idx = indexLegacyMap(legacyMap);
734
+ if (idx.size === 0) return handle;
735
+ const props = Array.from(idx.keys());
736
+ const getView = () => {
737
+ const doc = start.ownerDocument || (start.nodeType === 9 ? start : document);
738
+ return doc.defaultView || (typeof window !== "undefined" ? window : null);
739
+ };
740
+ const view = getView();
741
+ if (!view || typeof view.getComputedStyle !== "function") return handle;
742
+ const visit = (el) => {
743
+ if (!el || el.nodeType !== 1) return;
744
+ const cs = view.getComputedStyle(el);
745
+ for (const prop of props) {
746
+ const computed = cs.getPropertyValue(prop);
747
+ const override = computeLegacyOverride(prop, computed, idx);
748
+ if (override == null) continue;
749
+ const prev = el.style.getPropertyValue(prop);
750
+ restores.push({ el: (
751
+ /** @type {HTMLElement} */
752
+ el
753
+ ), prop, prev });
754
+ el.style.setProperty(prop, override);
755
+ }
756
+ };
757
+ if (start.nodeType === 1) visit(
758
+ /** @type {Element} */
759
+ start
760
+ );
761
+ const all = start.querySelectorAll ? start.querySelectorAll("*") : [];
762
+ for (const el of all) visit(el);
763
+ return handle;
764
+ };
765
+ var clearLegacyMap = (handle) => {
766
+ if (!handle || !Array.isArray(handle.restores)) return;
767
+ for (const { el, prop, prev } of handle.restores) {
768
+ if (!el || !el.style) continue;
769
+ if (prev === "" || prev == null) el.style.removeProperty(prop);
770
+ else el.style.setProperty(prop, prev);
771
+ }
772
+ handle.restores = [];
773
+ };
774
+
775
+ // src/TokenProvider.jsx
776
+ import { jsx } from "react/jsx-runtime";
777
+ var matchMediaFn2 = typeof matchMedia !== "undefined" ? matchMedia : null;
778
+ var DARK_MEDIA_QUERY2 = "(prefers-color-scheme: dark)";
779
+ var SorbProvider = ({ config, legacyMap, children }) => {
780
+ const instanceRef = useRef(null);
781
+ const legacyHandleRef = useRef(null);
782
+ const [state, setState] = React.useState(() => ({
783
+ tokens: config.tokens,
784
+ isPreview: false,
785
+ previewId: null,
786
+ previewMismatch: false,
787
+ mode: "auto",
788
+ resolvedScheme: matchMediaFn2 ? matchMediaFn2(DARK_MEDIA_QUERY2).matches ? "dark" : "light" : "light"
789
+ }));
790
+ const resolvedLegacyMap = legacyMap ?? config.legacyMap ?? null;
791
+ useEffect(() => {
792
+ const instance = sorbInit(config);
793
+ instanceRef.current = instance;
794
+ setState(instance.getState());
795
+ const unsubscribe = instance.subscribe(setState);
796
+ return () => {
797
+ unsubscribe();
798
+ instance.destroy();
799
+ instanceRef.current = null;
800
+ };
801
+ }, []);
802
+ useEffect(() => {
803
+ if (!resolvedLegacyMap || resolvedLegacyMap.length === 0) return void 0;
804
+ if (typeof document === "undefined") return void 0;
805
+ if (legacyHandleRef.current) clearLegacyMap(legacyHandleRef.current);
806
+ legacyHandleRef.current = applyLegacyMap(document.body, resolvedLegacyMap);
207
807
  return () => {
208
- if (pollRef.current) clearInterval(pollRef.current);
808
+ if (legacyHandleRef.current) {
809
+ clearLegacyMap(legacyHandleRef.current);
810
+ legacyHandleRef.current = null;
811
+ }
209
812
  };
813
+ }, [resolvedLegacyMap, state.tokens]);
814
+ const setMode = useCallback((next) => {
815
+ if (instanceRef.current) instanceRef.current.setMode(next);
210
816
  }, []);
211
- return /* @__PURE__ */ jsx(TokenContext.Provider, { value: { tokens: activeTokens, isPreview, previewId, clearPreview }, children });
817
+ const clearPreview = useCallback(() => {
818
+ if (instanceRef.current) instanceRef.current.clearPreview();
819
+ }, []);
820
+ const value = useMemo(
821
+ () => ({
822
+ tokens: state.tokens,
823
+ isPreview: state.isPreview,
824
+ previewId: state.previewId,
825
+ previewMismatch: state.previewMismatch,
826
+ clearPreview,
827
+ mode: state.mode,
828
+ setMode,
829
+ resolvedScheme: state.resolvedScheme
830
+ }),
831
+ [state, clearPreview, setMode]
832
+ );
833
+ return /* @__PURE__ */ jsx(TokenContext.Provider, { value, children });
212
834
  };
213
835
 
214
836
  // src/PreviewBanner.jsx
@@ -230,15 +852,21 @@ var useIsPreview = () => {
230
852
  return useTokenContext().isPreview;
231
853
  };
232
854
  var usePreviewState = () => {
233
- const { isPreview, previewId, clearPreview } = useTokenContext();
234
- return { isPreview, previewId, clearPreview };
855
+ const { isPreview, previewId, previewMismatch, clearPreview } = useTokenContext();
856
+ return { isPreview, previewId, previewMismatch, clearPreview };
857
+ };
858
+ var useTheme = () => {
859
+ const { mode, setMode, resolvedScheme } = useTokenContext();
860
+ return { mode, setMode, resolvedScheme };
235
861
  };
236
862
 
237
863
  // src/PreviewBanner.jsx
238
864
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
239
865
  var PreviewBanner = () => {
240
- const { isPreview, previewId, clearPreview } = usePreviewState();
866
+ const { isPreview, previewId, previewMismatch, clearPreview } = usePreviewState();
241
867
  if (!isPreview) return null;
868
+ const background = previewMismatch ? "var(--sorb-preview-warning-bg, #B54708)" : "#3B5BDB";
869
+ const accent = previewMismatch ? "var(--sorb-preview-warning-accent, #F59E0B)" : "transparent";
242
870
  return /* @__PURE__ */ jsxs(
243
871
  "div",
244
872
  {
@@ -249,7 +877,8 @@ var PreviewBanner = () => {
249
877
  bottom: 0,
250
878
  left: 0,
251
879
  right: 0,
252
- background: "#3B5BDB",
880
+ background,
881
+ borderTop: `3px solid ${accent}`,
253
882
  color: "#fff",
254
883
  padding: "10px 20px",
255
884
  display: "flex",
@@ -264,7 +893,7 @@ var PreviewBanner = () => {
264
893
  },
265
894
  children: [
266
895
  /* @__PURE__ */ jsxs("span", { children: [
267
- /* @__PURE__ */ jsx2("strong", { style: { fontWeight: 600 }, children: "Sorb preview active" }),
896
+ /* @__PURE__ */ jsx2("strong", { style: { fontWeight: 600 }, children: previewMismatch ? "Sorb preview active \u2014 may not re-skin" : "Sorb preview active" }),
268
897
  previewId && /* @__PURE__ */ jsx2(
269
898
  "code",
270
899
  {
@@ -279,7 +908,7 @@ var PreviewBanner = () => {
279
908
  children: previewId
280
909
  }
281
910
  ),
282
- /* @__PURE__ */ jsx2("span", { style: { marginLeft: "8px", opacity: 0.75, fontSize: "12px" }, children: "Token changes from Figma are live" })
911
+ /* @__PURE__ */ jsx2("span", { style: { marginLeft: "8px", opacity: 0.75, fontSize: "12px" }, children: previewMismatch ? "No matching tokens for this app \u2014 colours may be unchanged" : "Token changes from Figma are live" })
283
912
  ] }),
284
913
  /* @__PURE__ */ jsx2(
285
914
  "button",
@@ -307,12 +936,61 @@ var PreviewBanner = () => {
307
936
  );
308
937
  };
309
938
 
939
+ // src/ThemeToggle.jsx
940
+ import React3 from "react";
941
+ import { jsx as jsx3 } from "react/jsx-runtime";
942
+ var OPTIONS = [
943
+ { value: "light", label: "Light" },
944
+ { value: "dark", label: "Dark" },
945
+ { value: "auto", label: "Auto" }
946
+ ];
947
+ var ThemeToggle = ({ className } = {}) => {
948
+ const { mode, setMode } = useTheme();
949
+ return /* @__PURE__ */ jsx3(
950
+ "div",
951
+ {
952
+ role: "radiogroup",
953
+ "aria-label": "Color mode",
954
+ className,
955
+ style: {
956
+ display: "inline-flex",
957
+ gap: "4px",
958
+ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
959
+ fontSize: "13px"
960
+ },
961
+ children: OPTIONS.map(({ value, label }) => {
962
+ const active = mode === value;
963
+ return /* @__PURE__ */ jsx3(
964
+ "button",
965
+ {
966
+ type: "button",
967
+ role: "radio",
968
+ "aria-checked": active,
969
+ onClick: () => setMode(value),
970
+ style: {
971
+ padding: "4px 10px",
972
+ borderRadius: "6px",
973
+ border: "1px solid rgba(0,0,0,0.15)",
974
+ background: active ? "var(--sorb-theme-toggle-active-bg, #3B5BDB)" : "transparent",
975
+ color: active ? "#fff" : "inherit",
976
+ cursor: "pointer",
977
+ fontWeight: active ? 600 : 400
978
+ },
979
+ children: label
980
+ },
981
+ value
982
+ );
983
+ })
984
+ }
985
+ );
986
+ };
987
+
310
988
  // src/verify.js
311
989
  var toCssVar = (name) => {
312
990
  const s = String(name).trim();
313
991
  return s.startsWith("--") ? s : `--${s}`;
314
992
  };
315
- var verifyResolved = async (tokens, { origin = "http://localhost:7777", fetch: fetchImpl } = {}) => {
993
+ var verifyResolved = async (tokens, { origin = "http://localhost:7777", key, fetch: fetchImpl } = {}) => {
316
994
  if (typeof document === "undefined" || !document.documentElement) {
317
995
  return { ok: false, reason: "no-dom" };
318
996
  }
@@ -333,7 +1011,8 @@ var verifyResolved = async (tokens, { origin = "http://localhost:7777", fetch: f
333
1011
  try {
334
1012
  const res = await f(`${base}/verify/app`, {
335
1013
  method: "POST",
336
- headers: { "Content-Type": "application/json" },
1014
+ // Hosted bridge needs the bearer key; localhost (no key) sends no header.
1015
+ headers: bridgeHeaders(key, { "Content-Type": "application/json" }),
337
1016
  body: JSON.stringify({ values })
338
1017
  });
339
1018
  if (!res.ok) {
@@ -350,12 +1029,40 @@ var verifyResolved = async (tokens, { origin = "http://localhost:7777", fetch: f
350
1029
  return { ok: false, reason: "bridge-unreachable", error: e && e.message };
351
1030
  }
352
1031
  };
1032
+
1033
+ // src/darkModeConventions.js
1034
+ var tailwindDarkMode = {
1035
+ strategy: "class",
1036
+ darkSelector: ".dark"
1037
+ };
1038
+ var dataThemeDarkMode = {
1039
+ strategy: "attribute",
1040
+ attribute: "data-theme",
1041
+ darkSelector: '[data-theme="dark"]',
1042
+ lightSelector: '[data-theme="light"]'
1043
+ };
353
1044
  export {
1045
+ MODE_STYLESHEET_ID,
354
1046
  PreviewBanner,
355
1047
  SorbProvider,
1048
+ ThemeToggle,
1049
+ applyLegacyMap,
1050
+ buildModeStylesheet,
1051
+ clearLegacyMap,
1052
+ clearModeStylesheet,
1053
+ computeLegacyOverride,
1054
+ dataThemeDarkMode,
1055
+ indexLegacyMap,
1056
+ injectModeStylesheet,
1057
+ normalizeProp,
1058
+ normalizeValue,
1059
+ reactBootstrapTarget,
356
1060
  sanitizeCssValue,
1061
+ sorbInit,
1062
+ tailwindDarkMode,
357
1063
  useIsPreview,
358
1064
  usePreviewState,
1065
+ useTheme,
359
1066
  useToken,
360
1067
  useTokens,
361
1068
  verifyResolved