@jxsuite/studio 1.1.0 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,13 +39,13 @@
39
39
  "@atlaskit/pragmatic-drag-and-drop": "^2.0.1",
40
40
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0",
41
41
  "@jxsuite/ai": "^0.33.0",
42
- "@jxsuite/collab": "^0.4.0",
43
- "@jxsuite/create": "^1.1.0",
44
- "@jxsuite/formulas": "^0.0.2",
45
- "@jxsuite/markup": "^0.3.0",
46
- "@jxsuite/protocol": "^0.5.0",
47
- "@jxsuite/runtime": "^1.1.0",
48
- "@jxsuite/schema": "^1.1.0",
42
+ "@jxsuite/collab": "^0.4.1",
43
+ "@jxsuite/create": "^1.1.1",
44
+ "@jxsuite/formulas": "^0.0.3",
45
+ "@jxsuite/markup": "^0.3.1",
46
+ "@jxsuite/protocol": "^0.5.1",
47
+ "@jxsuite/runtime": "^1.2.0",
48
+ "@jxsuite/schema": "^1.2.0",
49
49
  "@spectrum-web-components/accordion": "^1.12.2",
50
50
  "@spectrum-web-components/action-bar": "1.12.2",
51
51
  "@spectrum-web-components/action-button": "^1.12.2",
@@ -6,7 +6,12 @@
6
6
  */
7
7
 
8
8
  import { postMessageChannel } from "./iframe-channel";
9
- import { installCanvasImageRetry, renderResolvedDocument } from "./iframe-render";
9
+ import {
10
+ applyPreviewColorScheme,
11
+ applySiteStyle,
12
+ installCanvasImageRetry,
13
+ renderResolvedDocument,
14
+ } from "./iframe-render";
10
15
  import { measureHits, startInteraction } from "./iframe-interaction";
11
16
  import {
12
17
  AUTO_SCROLL_STEP,
@@ -482,9 +487,20 @@ export function startCanvasIframe(opts: {
482
487
  clearIframeDrag();
483
488
  return;
484
489
  }
490
+ if (msg.kind === "setColorScheme") {
491
+ // Document-level attribute flip — deliberately patch-free (no render, no gen).
492
+ applyPreviewColorScheme(container.ownerDocument, msg.scheme);
493
+ return;
494
+ }
495
+ if (msg.kind === "siteStyleUpdate") {
496
+ // Live design-token edit: swap the site-style sheet in place, no re-render.
497
+ applySiteStyle(msg.siteStyle, msg.media);
498
+ return;
499
+ }
485
500
  if (msg.kind !== "render" || msg.gen < latestGen) {
486
501
  return;
487
502
  }
503
+ applyPreviewColorScheme(container.ownerDocument, msg.colorScheme ?? null);
488
504
  // A render replaces the DOM under a live edit session — COMMIT it first (never discard). The
489
505
  // Resulting editCommit/editEnd post synchronously here, so on the FIFO channel they precede
490
506
  // This render's renderComplete and the parent routes them by the host's not-yet-flipped tab
@@ -24,7 +24,15 @@ import { clearDragGhost, moveDragGhost } from "../panels/drag-ghost";
24
24
  import { applyDropInstruction } from "../panels/dnd";
25
25
  import { rectOf } from "../utils/geometry";
26
26
  import { effect, effectScope } from "../reactivity";
27
- import { canvasPanels, canvasWrap, pathsEqual, renderOnly, updateCanvas, updateUi } from "../store";
27
+ import {
28
+ canvasPanels,
29
+ canvasWrap,
30
+ pathsEqual,
31
+ projectState,
32
+ renderOnly,
33
+ updateCanvas,
34
+ updateUi,
35
+ } from "../store";
28
36
  import { activeTab, workspace } from "../workspace/workspace";
29
37
  import { collabState } from "../collab/collab-state";
30
38
  import { getPlatform, hasPlatform } from "../platform";
@@ -1370,6 +1378,7 @@ export async function mountIframeCanvas(
1370
1378
  // oxlint-disable-next-line unicorn/prefer-structured-clone
1371
1379
  const cloneableShadow = JSON.parse(JSON.stringify(doc)) as unknown;
1372
1380
  const message: ParentToIframe = {
1381
+ colorScheme: activeSchemeWire(),
1373
1382
  doc: cloneableDoc,
1374
1383
  docBase: resolved.docBase ?? `${canvasBaseOrigin()}/`,
1375
1384
  gen,
@@ -1419,6 +1428,7 @@ export function mountStylebookCanvas(
1419
1428
  // oxlint-disable-next-line unicorn/prefer-structured-clone
1420
1429
  const cloneableShadow = JSON.parse(JSON.stringify(generated.doc)) as unknown;
1421
1430
  const message: ParentToIframe = {
1431
+ colorScheme: activeSchemeWire(),
1422
1432
  doc: cloneableDoc,
1423
1433
  docBase: `${canvasBaseOrigin()}/`,
1424
1434
  gen,
@@ -1443,6 +1453,57 @@ export function mountStylebookCanvas(
1443
1453
  }
1444
1454
  }
1445
1455
 
1456
+ /** The active tab's forced preview scheme as wire data (auto → null). */
1457
+ function activeSchemeWire(): "light" | "dark" | null {
1458
+ const s = activeTab.value?.session.ui.previewColorScheme;
1459
+ return s === "light" || s === "dark" ? s : null;
1460
+ }
1461
+
1462
+ /**
1463
+ * Flip the color-scheme preview on every ready host (page and stylebook alike — both render
1464
+ * scheme-aware CSS). A pure attribute write iframe-side: no render, no patch, no gen.
1465
+ */
1466
+ export function postColorSchemeToLiveHosts(scheme: "light" | "dark" | null): void {
1467
+ for (const host of liveHosts) {
1468
+ if (!host.iframe.isConnected) {
1469
+ liveHosts.delete(host);
1470
+ continue;
1471
+ }
1472
+ if (host.ready) {
1473
+ host.channel.post({ kind: "setColorScheme", scheme });
1474
+ }
1475
+ }
1476
+ }
1477
+
1478
+ /**
1479
+ * Push the project's current site style to every ready PAGE host as an in-place sheet replace (live
1480
+ * design-token editing — stylebook hosts pre-merge site style into the specimen doc and are
1481
+ * skipped). Render-free; the next full render carries the same style via its own siteStyle.
1482
+ */
1483
+ export function postSiteStyleToLiveHosts(): void {
1484
+ const config = projectState?.projectConfig;
1485
+ if (!config) {
1486
+ return;
1487
+ }
1488
+ // Site config comes off a reactive store — only plain values may cross postMessage.
1489
+ // oxlint-disable-next-line unicorn/prefer-structured-clone
1490
+ const siteStyle = JSON.parse(JSON.stringify(config.style ?? null)) as Record<
1491
+ string,
1492
+ unknown
1493
+ > | null;
1494
+ // oxlint-disable-next-line unicorn/prefer-structured-clone
1495
+ const media = JSON.parse(JSON.stringify(config.$media ?? {})) as Record<string, string>;
1496
+ for (const host of liveHosts) {
1497
+ if (!host.iframe.isConnected) {
1498
+ liveHosts.delete(host);
1499
+ continue;
1500
+ }
1501
+ if (host.ready && !host.stylebook) {
1502
+ host.channel.post({ kind: "siteStyleUpdate", media, siteStyle });
1503
+ }
1504
+ }
1505
+ }
1506
+
1446
1507
  /**
1447
1508
  * Post a live style update to every ready stylebook host (gen-tagged per host so a stale update is
1448
1509
  * dropped iframe-side; the superseding render carries the same style). Returns how many hosts
@@ -61,8 +61,21 @@ export type ParentToIframe =
61
61
  docBase: string;
62
62
  mapperCtx: WireMapperCtx;
63
63
  siteStyle: Record<string, unknown> | null;
64
+ // Forced color-scheme preview (spec §9.5): "light"/"dark" sets data-color-scheme on the
65
+ // Iframe root, null clears it (auto — follow the OS).
66
+ colorScheme: "light" | "dark" | null;
64
67
  gen: number;
65
68
  }
69
+ // Flip the forced color-scheme preview on the iframe root without re-rendering — a document-level
70
+ // Idempotent attribute write, deliberately gen-less (like endEdit).
71
+ | { kind: "setColorScheme"; scheme: "light" | "dark" | null }
72
+ // Replace the injected site-style sheet in place (live design-token editing) — idempotent and
73
+ // Render-free; the superseding render carries the same style via its own siteStyle.
74
+ | {
75
+ kind: "siteStyleUpdate";
76
+ siteStyle: Record<string, unknown> | null;
77
+ media: Record<string, string>;
78
+ }
66
79
  // Ask the iframe to measure the given document paths and post their current rects back. Used to
67
80
  // Draw the selection overlay regardless of where the selection change originated (canvas click,
68
81
  // Layers panel, keyboard) — the parent can't measure iframe nodes itself (cross-origin bridge).
@@ -24,6 +24,7 @@ import {
24
24
  transposeCanvasUnits,
25
25
  } from "@jxsuite/runtime";
26
26
  import { classifyRenderNode, serializeJxPath } from "./path-mapping";
27
+ import { SITE_STYLE_ID, buildSiteStyleCSS } from "./site-style-css";
27
28
  import type { CanvasMode } from "./iframe-protocol";
28
29
  import type { JxDocument } from "@jxsuite/schema/types";
29
30
  import type { PathMapCtx } from "./path-mapping";
@@ -294,22 +295,42 @@ export function syncStylebookCss(doc: Document, mode: CanvasMode): void {
294
295
  doc.head.append(style);
295
296
  }
296
297
 
297
- /** Apply the project's site style: custom properties on :root, plain properties on <body>. */
298
- export function applySiteStyle(siteStyle: Record<string, unknown> | null | undefined): void {
298
+ /**
299
+ * Apply the project's site style as a real stylesheet (replace-in-place): custom properties on
300
+ * `:root`, plain properties on `body`, conditional blocks dual-emitted per the forced-scheme
301
+ * contract. A stylesheet — not inline root properties — so `:root[data-color-scheme]` override
302
+ * selectors can win (spec §9.5), and so removed tokens can't linger on reused iframes.
303
+ */
304
+ export function applySiteStyle(
305
+ siteStyle: Record<string, unknown> | null | undefined,
306
+ mediaQueries: Record<string, string> = {},
307
+ ): void {
308
+ const existing = document.head.querySelector(`#${SITE_STYLE_ID}`);
299
309
  if (!siteStyle || typeof siteStyle !== "object") {
310
+ existing?.remove();
300
311
  return;
301
312
  }
302
- const rootStyle = document.documentElement.style;
303
- const bodyStyle = document.body.style as unknown as Record<string, string>;
304
- for (const [key, value] of Object.entries(siteStyle)) {
305
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
306
- continue;
307
- }
308
- if (key.startsWith("--")) {
309
- rootStyle.setProperty(key, transposeCanvasUnits(String(value)));
310
- } else {
311
- bodyStyle[key] = transposeCanvasUnits(String(value));
312
- }
313
+ const css = buildSiteStyleCSS(siteStyle, mediaQueries, transposeCanvasUnits);
314
+ if (existing) {
315
+ existing.textContent = css;
316
+ return;
317
+ }
318
+ const tag = document.createElement("style");
319
+ tag.id = SITE_STYLE_ID;
320
+ tag.textContent = css;
321
+ document.head.append(tag);
322
+ }
323
+
324
+ /**
325
+ * Force or clear the color-scheme preview on the iframe's root element (the platform's
326
+ * data-color-scheme contract, spec §9.5). Survives re-renders and patches — renders only replace
327
+ * the container's children, never the root element.
328
+ */
329
+ export function applyPreviewColorScheme(doc: Document, scheme: "light" | "dark" | null): void {
330
+ if (scheme) {
331
+ doc.documentElement.dataset.colorScheme = scheme;
332
+ } else {
333
+ delete doc.documentElement.dataset.colorScheme;
313
334
  }
314
335
  }
315
336
 
@@ -443,7 +464,7 @@ export async function renderResolvedDocument(opts: {
443
464
  // Iframe clears it (page-level templates are inert in design/edit via prepareForEditMode, so only
444
465
  // Component internals get stamped).
445
466
  setStampPropBindings(opts.mode === "design" || opts.mode === "edit");
446
- applySiteStyle(opts.siteStyle);
467
+ applySiteStyle(opts.siteStyle, (opts.doc as { $media?: Record<string, string> }).$media ?? {});
447
468
  injectHead(opts.doc);
448
469
  syncEditModeCss(opts.container.ownerDocument, opts.mode);
449
470
  syncStylebookCss(opts.container.ownerDocument, opts.mode);
@@ -0,0 +1,121 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Site-style stylesheet builder — pure functions extracted for testability.
4
+ *
5
+ * The canvas iframe injects the project's `style` as a real stylesheet instead of inline properties
6
+ * on the root element: inline custom properties would defeat the forced-scheme override selectors
7
+ * (`:root[data-color-scheme]`, spec §9.5), and object-valued `@--name` blocks used to be dropped
8
+ * entirely. Scheme-query blocks dual-emit through the runtime's schemeSelectors so the canvas
9
+ * honors both the OS preference and the preview toggle.
10
+ */
11
+
12
+ import { pureSchemeOf, schemeSelectors, camelToKebab } from "@jxsuite/runtime";
13
+
14
+ /** Id of the injected site-style tag (replace-in-place, never accumulate). */
15
+ export const SITE_STYLE_ID = "jx-site-style";
16
+
17
+ /** Serialize scalar declarations, kebab-casing property names and transposing values. */
18
+ function toDecls(props: Record<string, unknown>, transpose: (value: string) => string): string {
19
+ return Object.entries(props)
20
+ .map(([key, value]) => `${camelToKebab(key)}: ${transpose(String(value))}`)
21
+ .join("; ");
22
+ }
23
+
24
+ /**
25
+ * Build the site-style sheet text: custom properties on `:root`, plain properties on `body`,
26
+ * conditional `@`-blocks resolved against `mediaQueries` (scheme queries dual-emitted per the
27
+ * forced-scheme contract), and `color-scheme: light dark` declared when a scheme query exists.
28
+ *
29
+ * @param {Record<string, unknown>} siteStyle
30
+ * @param {Record<string, string>} mediaQueries
31
+ * @param {(value: string) => string} transpose - Unit transposer (canvas vh→cqh etc.)
32
+ * @returns {string}
33
+ */
34
+ export function buildSiteStyleCSS(
35
+ siteStyle: Record<string, unknown>,
36
+ mediaQueries: Record<string, string>,
37
+ transpose: (value: string) => string,
38
+ ): string {
39
+ const rules: string[] = [];
40
+ const rootProps: Record<string, unknown> = {};
41
+ const bodyProps: Record<string, unknown> = {};
42
+ const condBlocks: [string, Record<string, unknown>][] = [];
43
+
44
+ for (const [key, value] of Object.entries(siteStyle)) {
45
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
46
+ if (key.startsWith("@")) {
47
+ condBlocks.push([key, value as Record<string, unknown>]);
48
+ }
49
+ // Non-@ objects (nested element selectors) are page-content styling — the resolved doc's
50
+ // Own style pass covers those; the site sheet handles tokens + conditional overrides.
51
+ continue;
52
+ }
53
+ if (key.startsWith(":") || key.startsWith(".") || key.startsWith("[") || key.startsWith("@")) {
54
+ continue;
55
+ }
56
+ if (key.startsWith("--")) {
57
+ rootProps[key] = value;
58
+ } else {
59
+ bodyProps[key] = value;
60
+ }
61
+ }
62
+
63
+ const rootCSS = toDecls(rootProps, transpose);
64
+ if (rootCSS) {
65
+ rules.push(`:root { ${rootCSS} }`);
66
+ }
67
+ const bodyCSS = toDecls(bodyProps, transpose);
68
+ if (bodyCSS) {
69
+ rules.push(`body { ${bodyCSS} }`);
70
+ }
71
+
72
+ for (const [atKey, block] of condBlocks) {
73
+ const query = atKey.startsWith("@--")
74
+ ? (mediaQueries[atKey.slice(1)] ?? atKey.slice(1))
75
+ : atKey.startsWith("@(")
76
+ ? atKey.slice(1)
77
+ : null;
78
+ if (query === null) {
79
+ continue;
80
+ }
81
+ const scheme = pureSchemeOf(query);
82
+ const emit = (selector: string, props: string) => {
83
+ if (!props) {
84
+ return;
85
+ }
86
+ if (scheme) {
87
+ const { auto, forced } = schemeSelectors(selector, scheme);
88
+ rules.push(`@media ${query} { ${auto} { ${props} } }`, `${forced} { ${props} }`);
89
+ } else {
90
+ rules.push(`@media ${query} { ${selector} { ${props} } }`);
91
+ }
92
+ };
93
+ const condRoot: Record<string, unknown> = {};
94
+ const condBody: Record<string, unknown> = {};
95
+ const condSubs: [string, Record<string, unknown>][] = [];
96
+ for (const [k, v] of Object.entries(block)) {
97
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
98
+ if (!k.startsWith("@")) {
99
+ condSubs.push([k, v as Record<string, unknown>]);
100
+ }
101
+ continue;
102
+ }
103
+ if (k.startsWith("--")) {
104
+ condRoot[k] = v;
105
+ } else {
106
+ condBody[k] = v;
107
+ }
108
+ }
109
+ emit(":root", toDecls(condRoot, transpose));
110
+ emit("body", toDecls(condBody, transpose));
111
+ for (const [sel, sub] of condSubs) {
112
+ emit(sel, toDecls(sub, transpose));
113
+ }
114
+ }
115
+
116
+ if (Object.values(mediaQueries).some((q) => pureSchemeOf(q) !== null)) {
117
+ rules.push(":root { color-scheme: light dark }");
118
+ }
119
+
120
+ return rules.join("\n");
121
+ }
@@ -21,7 +21,7 @@ import type { TemplateResult } from "lit-html";
21
21
  import { getPlatform } from "../platform";
22
22
  import { effect, effectScope } from "../reactivity";
23
23
  import { createDocumentAssistant } from "../services/document-assistant";
24
- import { hasOpenAiKey } from "../services/ai-settings";
24
+ import { hasOpenAiKey, setOpenAiKey } from "../services/ai-settings";
25
25
  import { fetchAvailableModels, isManagedProxy, isProxyConfigured } from "../services/ai-models";
26
26
  import { createAiCredentialsForm } from "../ui/ai-credentials-form";
27
27
  import { clearMarkdownCache } from "./ai-chat/chat-markdown";
@@ -263,6 +263,58 @@ export async function seedAssistantPrompt(text: string): Promise<void> {
263
263
  await handleAssistantSend(text);
264
264
  }
265
265
 
266
+ // ─── Automation seeding (screenshot runner) ─────────────────────────────────
267
+
268
+ /** A canned tool-call chip for {@link seedAssistantMessages}. */
269
+ export interface SeededToolCall {
270
+ name: string;
271
+ /** JSON-encoded arguments, e.g. `{"path":["children",0],"text":"…"}`. */
272
+ arguments: string;
273
+ }
274
+
275
+ /** A canned transcript entry for {@link seedAssistantMessages}. */
276
+ export interface SeededAssistantMessage {
277
+ role: "user" | "assistant";
278
+ content: string;
279
+ toolCalls?: SeededToolCall[];
280
+ }
281
+
282
+ let seededCount = 0;
283
+
284
+ /**
285
+ * Automation-only seam (scripts/screenshots): stage a canned conversation without ever invoking a
286
+ * model. Stores an inert demo key so the key gate opens (localStorage-only on the dev server — no
287
+ * request fires), switches to the chat view, and pushes fully-formed messages straight into the
288
+ * reactive chat state — the same path session restore uses — so the panel repaints through its
289
+ * normal watcher.
290
+ */
291
+ export function seedAssistantMessages(messages: SeededAssistantMessage[]): void {
292
+ setOpenAiKey("sk-demo");
293
+ keyEditing = false;
294
+ view = "chat";
295
+ stickToBottom = true;
296
+ for (const msg of messages) {
297
+ seededCount += 1;
298
+ const seq = seededCount;
299
+ assistant.chatState.messages.push({
300
+ content: msg.content,
301
+ id: `seeded_${seq}`,
302
+ role: msg.role,
303
+ timestamp: seq,
304
+ ...(msg.toolCalls
305
+ ? {
306
+ toolCalls: msg.toolCalls.map((tc, i) => ({
307
+ arguments: tc.arguments,
308
+ id: `seeded_${seq}_tc${i}`,
309
+ name: tc.name,
310
+ })),
311
+ }
312
+ : {}),
313
+ });
314
+ }
315
+ scheduleAiRender();
316
+ }
317
+
266
318
  // ─── Controls ─────────────────────────────────────────────────────────────────
267
319
 
268
320
  function stop() {
@@ -23,7 +23,7 @@ import {
23
23
  import { inferInputType, propLabel } from "../utils/studio-utils";
24
24
  import { renderFieldRow } from "../ui/field-row";
25
25
  import { renderDynamicSlot } from "../ui/dynamic-slot";
26
- import { parseMediaEntries } from "../utils/canvas-media";
26
+ import { parseMediaEntries, schemeOfQuery } from "../utils/canvas-media";
27
27
  import { getEffectiveMedia, getEffectiveStyle } from "../site-context";
28
28
  import { computeInheritedStyle } from "../utils/inherited-style";
29
29
  import { mediaDisplayName } from "./shared";
@@ -313,6 +313,20 @@ function styleSidebarTemplate(
313
313
  const mediaNames = sizeBreakpoints.map((bp) => bp.name);
314
314
  const mediaTab = activeMediaTab || null;
315
315
 
316
+ // ── Scheme-layer routing (spec §9.5) ────────────────────────────────────────
317
+ // With the tab-bar scheme control forcing a scheme that has a matching declared scheme query,
318
+ // Base-context edits target that scheme's `@--name` block — no extra sidebar tabs. Breakpoint
319
+ // Tabs stay breakpoint-scoped (scheme × breakpoint compound blocks are unsupported).
320
+ const forcedScheme = tab.session.ui.previewColorScheme;
321
+ const schemeLayer =
322
+ mediaTab === null && (forcedScheme === "light" || forcedScheme === "dark")
323
+ ? (Object.entries(getEffectiveMedia(tab.doc.document.$media) ?? {}).find(
324
+ ([, q]) => schemeOfQuery(String(q)) === forcedScheme,
325
+ )?.[0] ?? null)
326
+ : null;
327
+ // The media context edits actually target: a valid breakpoint tab, else the scheme layer.
328
+ const editMedia = mediaTab && mediaNames.length > 0 ? mediaTab : schemeLayer;
329
+
316
330
  // ── Media tabs template ──────────────────────────────────────────────────
317
331
  const mediaTabsT =
318
332
  mediaNames.length > 0
@@ -337,7 +351,9 @@ function styleSidebarTemplate(
337
351
  : nothing;
338
352
 
339
353
  // ── Selector dropdown ──────────────────────────────────────────────────────
340
- const contextStyle = mediaTab ? (style[`@${mediaTab}`] as Record<string, unknown>) || {} : style;
354
+ const contextStyle = editMedia
355
+ ? (style[`@${editMedia}`] as Record<string, unknown>) || {}
356
+ : style;
341
357
  const existingSelectors = Object.keys(contextStyle).filter((s) => isNestedSelector(s));
342
358
  const existingSet = new Set(existingSelectors);
343
359
  const commonSet = new Set(COMMON_SELECTORS);
@@ -413,10 +429,21 @@ function styleSidebarTemplate(
413
429
  </sp-picker>
414
430
  `;
415
431
 
416
- // ── Combined toolbar (media tabs + selector) ───────────────────────────────
432
+ // ── Combined toolbar (media tabs + scheme badge + selector) ────────────────
433
+ // No scheme tabs — the tab-bar control is the one switch; the badge shows where edits land.
434
+ const schemeBadgeT = schemeLayer
435
+ ? html`
436
+ <span
437
+ class="style-scheme-badge"
438
+ title="Edits target the @${schemeLayer} variant — set the tab-bar scheme control to Auto to edit base styles"
439
+ >
440
+ ${mediaDisplayName(schemeLayer)} variant
441
+ </span>
442
+ `
443
+ : nothing;
417
444
  const toolbarT = html`
418
445
  <div class="style-toolbar">
419
- <div class="style-toolbar-tabs">${mediaTabsT}</div>
446
+ <div class="style-toolbar-tabs">${mediaTabsT} ${schemeBadgeT}</div>
420
447
  ${selectorT}
421
448
  </div>
422
449
  `;
@@ -450,15 +477,15 @@ function styleSidebarTemplate(
450
477
  // ── Determine the active style object ──────────────────────────────────────
451
478
  let activeStyle: JxStyle;
452
479
  let commitMutate: StyleMutateFn;
453
- if (activeSelector && isTagPath(activeSelector) && mediaTab && mediaNames.length > 0) {
454
- const mediaObj = getNestedStyle(style, `@${mediaTab}`) ?? {};
480
+ if (activeSelector && isTagPath(activeSelector) && editMedia) {
481
+ const mediaObj = getNestedStyle(style, `@${editMedia}`) ?? {};
455
482
  activeStyle = resolveNestedTagStyle(mediaObj, activeSelector);
456
483
  const stylePath = activeSelector.split(" ");
457
484
  commitMutate = (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
458
485
  mutateUpdateMediaNestedStylePath(
459
486
  t,
460
487
  sel,
461
- mediaTab,
488
+ editMedia,
462
489
  stylePath,
463
490
  prop,
464
491
  val as string | undefined,
@@ -468,14 +495,14 @@ function styleSidebarTemplate(
468
495
  const stylePath = activeSelector.split(" ");
469
496
  commitMutate = (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
470
497
  mutateUpdateNestedStylePath(t, sel, stylePath, prop, val as string | undefined);
471
- } else if (activeSelector && mediaTab && mediaNames.length > 0) {
472
- const mediaObj = getNestedStyle(style, `@${mediaTab}`) ?? {};
498
+ } else if (activeSelector && editMedia) {
499
+ const mediaObj = getNestedStyle(style, `@${editMedia}`) ?? {};
473
500
  activeStyle = getNestedStyle(mediaObj, activeSelector) ?? {};
474
501
  commitMutate = (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
475
502
  mutateUpdateMediaNestedStyle(
476
503
  t,
477
504
  sel,
478
- mediaTab,
505
+ editMedia,
479
506
  activeSelector,
480
507
  prop,
481
508
  val as string | undefined,
@@ -486,29 +513,32 @@ function styleSidebarTemplate(
486
513
  mutateUpdateNestedStyle(t, sel, activeSelector, prop, val as string | undefined);
487
514
  } else {
488
515
  activeStyle = {};
489
- const inMediaTab = mediaTab !== null && mediaNames.length > 0;
490
- const flatSource = inMediaTab ? (getNestedStyle(style, `@${mediaTab}`) ?? {}) : style;
516
+ const inMediaCtx = editMedia !== null;
517
+ const flatSource = inMediaCtx ? (getNestedStyle(style, `@${editMedia}`) ?? {}) : style;
491
518
  for (const [p, v] of Object.entries(flatSource)) {
492
519
  if (typeof v !== "object") {
493
520
  activeStyle[p] = v;
494
521
  }
495
522
  }
496
- commitMutate = inMediaTab
523
+ commitMutate = inMediaCtx
497
524
  ? (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
498
- mutateUpdateMediaStyle(t, sel, mediaTab, prop, val as string | undefined)
525
+ mutateUpdateMediaStyle(t, sel, editMedia, prop, val as string | undefined)
499
526
  : (t: Tab, prop: string, val: string | Record<string, unknown> | undefined) =>
500
527
  mutateUpdateStyle(t, sel, prop, val as string | undefined);
501
528
  }
502
529
  const commitStyle = (prop: string, val?: string | Record<string, unknown> | undefined) =>
503
530
  transactDoc(activeTab.value, (t) => commitMutate(t, prop, val));
504
531
 
505
- // ── Compute inherited style from higher breakpoints ──────────────────────
506
- const inheritedStyle: Record<string, string | number> = computeInheritedStyle(
507
- style,
508
- mediaNames,
509
- mediaTab,
510
- activeSelector,
511
- );
532
+ // ── Compute inherited style ────────────────────────────────────────────────
533
+ // Scheme layer: the base styles show through as inherited (placeholders); breakpoint tabs
534
+ // Inherit from higher breakpoints as before.
535
+ const inheritedStyle: Record<string, string | number> = schemeLayer
536
+ ? (Object.fromEntries(
537
+ Object.entries(
538
+ activeSelector ? (getNestedStyle(style, activeSelector) ?? {}) : style,
539
+ ).filter(([, v]) => typeof v !== "object"),
540
+ ) as Record<string, string | number>)
541
+ : computeInheritedStyle(style, mediaNames, mediaTab, activeSelector);
512
542
 
513
543
  // Auto-open sections that have properties
514
544
  const newSections = autoOpenSections({ style: activeStyle }, tab.session.ui.styleSections);
@@ -683,7 +713,7 @@ function styleSidebarTemplate(
683
713
  sec.$layout === "grid",
684
714
  inheritedStyle[prop] as string | undefined,
685
715
  templateSignals,
686
- `style|${sel.join("/")}|${mediaTab ?? ""}|${activeSelector ?? ""}|${prop}`,
716
+ `style|${sel.join("/")}|${editMedia ?? ""}|${activeSelector ?? ""}|${prop}`,
687
717
  ),
688
718
  );
689
719
  }
@@ -15,6 +15,7 @@ import { effect, effectScope } from "../reactivity";
15
15
  import { activeTab } from "../workspace/workspace";
16
16
  import { applyTransform, fitToScreen, resetZoom, setEditZoom } from "../canvas/canvas-utils";
17
17
  import { getEffectiveLayoutPath, getEffectiveMedia } from "../site-context";
18
+ import { isSchemeQuery } from "../utils/canvas-media";
18
19
  import { dynamicRouteParams, loadParamValues, pagePathsDef } from "../page-params";
19
20
  import { componentPropEntries, isComponentDoc } from "../component-props";
20
21
  import { mediaDisplayName } from "./shared";
@@ -75,6 +76,7 @@ export function mount(host: HTMLElement, ctx: TabBarCtx) {
75
76
  void tab.session.ui.editingFunction;
76
77
  void tab.session.ui.featureToggles;
77
78
  void tab.session.ui.preview;
79
+ void tab.session.ui.previewColorScheme;
78
80
  void tab.session.ui.previewParams;
79
81
  void tab.session.ui.previewProps;
80
82
  void tab.session.ui.showLayout;
@@ -308,13 +310,46 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
308
310
  }
309
311
  }
310
312
 
311
- // ── Right region: media feature toggles ──
313
+ // ── Right region: color-scheme preview (projects that declare a scheme query, spec §9.5) ──
314
+ // One switch per tab: drives the canvas data-color-scheme attribute and (in the style
315
+ // Sidebar) which scheme layer edits target. Ungated — works in every canvas mode.
312
316
  const { featureQueries } = ctx.parseMediaEntries(getEffectiveMedia(S.document?.$media));
317
+ const schemeQueries = featureQueries.filter(({ query }) => isSchemeQuery(query));
318
+ const plainQueries = featureQueries.filter(({ query }) => !isSchemeQuery(query));
319
+ const scheme = S.ui.previewColorScheme ?? "auto";
320
+ const schemeTpl =
321
+ schemeQueries.length > 0
322
+ ? html`
323
+ <sp-action-group compact size="s" class="tb-scheme">
324
+ ${(
325
+ [
326
+ ["auto", "Auto", "Follow the OS color scheme"],
327
+ ["light", "Light", "Force the light scheme"],
328
+ ["dark", "Dark", "Force the dark scheme"],
329
+ ] as const
330
+ ).map(
331
+ ([value, label, title]) => html`
332
+ <sp-action-button
333
+ toggles
334
+ size="s"
335
+ title=${title}
336
+ ?selected=${scheme === value}
337
+ @click=${() => updateUi("previewColorScheme", value)}
338
+ >
339
+ ${label}
340
+ </sp-action-button>
341
+ `,
342
+ )}
343
+ </sp-action-group>
344
+ `
345
+ : nothing;
346
+
347
+ // ── Right region: media feature toggles (non-scheme queries) ──
313
348
  const togglesTpl =
314
- featureQueries.length > 0
349
+ plainQueries.length > 0
315
350
  ? html`
316
351
  <sp-action-group compact size="s">
317
- ${featureQueries.map(
352
+ ${plainQueries.map(
318
353
  ({ name, query }: { name: string; query: string }) => html`
319
354
  <sp-action-button
320
355
  toggles
@@ -352,7 +387,7 @@ function tabBarTemplate(ctx: TabBarCtx): TemplateResult | typeof nothing {
352
387
  <div class="tab-bar">
353
388
  ${navTpl} ${zoomTpl}
354
389
  <div class="tb-spacer"></div>
355
- ${settingsTpl} ${togglesTpl} ${exportTpl}
390
+ ${settingsTpl} ${schemeTpl} ${togglesTpl} ${exportTpl}
356
391
  </div>
357
392
  `;
358
393
  }