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