@tileflow/dev 0.1.0-alpha.31 → 0.1.0-alpha.32

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/server.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  parseTileflowLineBackgroundGlyphPath,
6
6
  tileflowArtifactSessionSchemaVersion,
7
7
  tileflowLineBackgroundGlyphRoute
8
- } from "./chunk-2QXKVQ6O.js";
8
+ } from "./chunk-CM6ZTYDJ.js";
9
9
  import {
10
10
  getTileflowAssetBasePath,
11
11
  getTileflowAssetFileName,
@@ -13,7 +13,7 @@ import {
13
13
  normalizeTileflowBasePath,
14
14
  resolveTileflowArtifactPublicUrls
15
15
  } from "./chunk-UTRO4IBQ.js";
16
- import "./chunk-IR22ZTQW.js";
16
+ import "./chunk-F4PLTZS4.js";
17
17
  import "./chunk-7C4F6GAX.js";
18
18
  import {
19
19
  TileflowStyleValidationError
@@ -34,12 +34,1247 @@ import {
34
34
  tileflowThemeNameSchema
35
35
  } from "@tileflow/core";
36
36
 
37
+ // src/preview-controls.ts
38
+ import { parseResolvedTileflowMap, tileflowStyleFontFacesMetadataKey } from "@tileflow/core";
39
+ import { createStyleFromCatalogWithInspection } from "@tileflow/core/build";
40
+ var moduleNames = [
41
+ "land",
42
+ "water",
43
+ "nautical",
44
+ "roads",
45
+ "boundaries",
46
+ "labels",
47
+ "poi",
48
+ "aeroways",
49
+ "transit",
50
+ "addresses",
51
+ "landforms",
52
+ "buildings",
53
+ "vegetation"
54
+ ];
55
+ var fieldRules = {
56
+ "buildings.mode": ["flat", "3d"],
57
+ "buildings.threeDimensional.lighting": ["style", "sun"],
58
+ "buildings.threeDimensional.shadows": "boolean",
59
+ "buildings.threeDimensional.occlusion": "boolean",
60
+ "buildings.threeDimensional.windows": "boolean",
61
+ "buildings.threeDimensional.edges": [0, 1],
62
+ "buildings.threeDimensional.colorVariation": [0, 1],
63
+ "buildings.threeDimensional.nightLight": [0, 2],
64
+ "vegetation.mode": ["3d", "flat", "auto"],
65
+ "vegetation.fallback": ["flat", "none"],
66
+ "vegetation.visible": "boolean",
67
+ "vegetation.threeDimensional.finish": ["faceted", "smooth", "toon"],
68
+ "vegetation.threeDimensional.edges": [0, 1],
69
+ "vegetation.threeDimensional.heightScale": [0, 10],
70
+ "vegetation.threeDimensional.crownScale": [0, 10]
71
+ };
72
+ function record(value) {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+ function parsePreviewControls(text) {
76
+ if (text.length > 8192) throw new Error("Map controls exceed 8 KiB.");
77
+ const input = JSON.parse(text);
78
+ if (!record(input)) throw new Error("Map controls must be an object.");
79
+ for (const [module, patch] of Object.entries(input)) {
80
+ if (!moduleNames.includes(module) || !record(patch))
81
+ throw new Error("Unknown map control module: " + module);
82
+ const validate = (path, value) => {
83
+ const rule = path === module + ".enabled" ? "boolean" : fieldRules[path];
84
+ const valid = rule === "boolean" ? typeof value === "boolean" : rule && (typeof rule[0] === "number" ? typeof value === "number" && Number.isFinite(value) && value >= rule[0] && value <= rule[1] : typeof value === "string" && rule.includes(value));
85
+ if (!valid) throw new Error("Invalid map control: " + path);
86
+ };
87
+ for (const [key, value] of Object.entries(patch)) {
88
+ if (key === "threeDimensional" && record(value) && ["buildings", "vegetation"].includes(module)) {
89
+ for (const [nested, entry] of Object.entries(value))
90
+ validate(module + "." + key + "." + nested, entry);
91
+ } else validate(module + "." + key, value);
92
+ }
93
+ }
94
+ return input;
95
+ }
96
+ function describePreviewControls(artifacts, map, theme) {
97
+ const definition = artifacts.project.maps[map];
98
+ const style = artifacts.styles[map]?.[theme];
99
+ if (!definition || !style) throw new Error("Unknown map theme.");
100
+ const modules = definition.modules ?? {};
101
+ const metadata = style.metadata ?? {};
102
+ return {
103
+ map,
104
+ theme,
105
+ modules: moduleNames.map((id) => {
106
+ const config = modules[id] ?? { type: id };
107
+ const fields = [];
108
+ const record2 = config;
109
+ const add = (path, label, value, choices, min, max, step, inherit = false) => fields.push({
110
+ path,
111
+ label,
112
+ value,
113
+ ...choices ? { choices } : {},
114
+ ...inherit ? { inherit: true } : {},
115
+ ...min !== void 0 ? { min, max, step } : {}
116
+ });
117
+ if (id === "buildings") {
118
+ const design = metadata["tileflow:buildings"];
119
+ const authored = record2.threeDimensional;
120
+ const value = (key, fallback) => {
121
+ const resolved = design?.[key] ?? authored?.[key];
122
+ return typeof resolved === typeof fallback ? resolved : fallback;
123
+ };
124
+ add("threeDimensional.lighting", "Building light", value("lighting", "style"), [
125
+ "style",
126
+ "sun"
127
+ ]);
128
+ for (const [key, label] of [
129
+ ["shadows", "Cast shadows"],
130
+ ["occlusion", "Contact shadows"],
131
+ ["windows", "Night windows"]
132
+ ])
133
+ add("threeDimensional." + key, label, value(key, false));
134
+ for (const [key, label] of [
135
+ ["edges", "Edge definition"],
136
+ ["colorVariation", "Color variation"],
137
+ ["nightLight", "Night glow"]
138
+ ])
139
+ add(
140
+ "threeDimensional." + key,
141
+ label,
142
+ value(key, 0),
143
+ void 0,
144
+ 0,
145
+ key === "nightLight" ? 2 : 1,
146
+ 0.05
147
+ );
148
+ }
149
+ if (id === "vegetation") {
150
+ add(
151
+ "mode",
152
+ "Tree representation",
153
+ String(record2.mode ?? "3d"),
154
+ ["3d", "auto", "flat"],
155
+ void 0,
156
+ void 0,
157
+ void 0,
158
+ true
159
+ );
160
+ add("fallback", "Without 3D support", String(record2.fallback ?? "flat"), ["flat", "none"]);
161
+ add("visible", "Visible by default", record2.visible !== false);
162
+ const authored = record2.threeDimensional;
163
+ const design = metadata["tileflow:vegetation"];
164
+ add("threeDimensional.finish", "Tree finish", String(authored?.finish ?? "faceted"), [
165
+ "faceted",
166
+ "smooth",
167
+ "toon"
168
+ ]);
169
+ for (const [key, label] of [
170
+ ["heightScale", "Tree height"],
171
+ ["crownScale", "Crown size"]
172
+ ]) {
173
+ const value = design?.[key] ?? authored?.[key];
174
+ add(
175
+ "threeDimensional." + key,
176
+ label,
177
+ typeof value === "number" ? value : 1,
178
+ void 0,
179
+ 0,
180
+ 10,
181
+ 0.1
182
+ );
183
+ }
184
+ }
185
+ return { id, enabled: record2.enabled !== false, fields };
186
+ })
187
+ };
188
+ }
189
+ function compilePreviewControls(artifacts, map, theme, text, apiBaseUrl) {
190
+ if (artifacts.nativeBuild) throw new Error("Map controls require a web preview.");
191
+ const overrides = parsePreviewControls(text);
192
+ const definition = artifacts.project.maps[map];
193
+ const base = artifacts.styles[map]?.[theme];
194
+ if (!definition || !base) throw new Error("Unknown map theme.");
195
+ const modules = { ...definition.modules };
196
+ for (const [id, patch] of Object.entries(overrides)) {
197
+ const key = id;
198
+ const original = modules[key] ?? { type: id };
199
+ const merged = { ...original, ...patch };
200
+ if ("threeDimensional" in patch && patch.threeDimensional)
201
+ Object.assign(merged, {
202
+ threeDimensional: {
203
+ ..."threeDimensional" in original ? original.threeDimensional : {},
204
+ ...patch.threeDimensional
205
+ }
206
+ });
207
+ Object.assign(modules, { [id]: merged });
208
+ }
209
+ const resolved = parseResolvedTileflowMap({ ...definition, modules });
210
+ const result = createStyleFromCatalogWithInspection(
211
+ { ...artifacts.project, maps: { ...artifacts.project.maps, [map]: resolved } },
212
+ map,
213
+ {
214
+ theme,
215
+ apiBaseUrl,
216
+ preparedAssets: artifacts.mapAssets?.[map]
217
+ }
218
+ );
219
+ result.style.sources = Object.fromEntries(
220
+ Object.entries(result.style.sources).map(([id, source]) => [id, base.sources[id] ?? source])
221
+ );
222
+ if (base.glyphs) result.style.glyphs = base.glyphs;
223
+ if (base.sprite) result.style.sprite = base.sprite;
224
+ if (base.metadata?.[tileflowStyleFontFacesMetadataKey])
225
+ result.style.metadata = {
226
+ ...result.style.metadata,
227
+ [tileflowStyleFontFacesMetadataKey]: base.metadata[tileflowStyleFontFacesMetadataKey]
228
+ };
229
+ return result;
230
+ }
231
+
37
232
  // src/comparison.ts
38
233
  import { randomBytes } from "crypto";
234
+ import { readFile } from "fs/promises";
235
+
236
+ // src/devtools/inspector-styles.ts
237
+ var inspectorStyles = `
238
+ body.devtools-ready {
239
+ --paper:#F8F6F2; --surface:#F0EDE7; --ink:#30251F; --muted:#635449; --faint:#A0907F;
240
+ --border:#E1DCD3; --border-strong:#C7BFB3; --accent:#38668B; --dt-selected:#E6ECF2;
241
+ --hover:#E9E5DE; --accent-soft:#DFE8F0; --ok:#2F704B; --ok-soft:#E3EBDB; --warn:#815D21; --warn-soft:#F3E6CF;
242
+ --danger:#B13C34; --dt-string:var(--ink); --dt-number:var(--accent); --mono:'JetBrains Mono',ui-monospace,Menlo,monospace;
243
+ color-scheme:light; font:13px/1.4 Inter,system-ui,-apple-system,sans-serif; -webkit-font-smoothing:antialiased;
244
+ }
245
+ body.devtools-ready[data-inspector-theme=dark] {
246
+ --paper:#1D1816; --surface:#171311; --ink:#F5EDD9; --muted:#C3B7A9; --faint:#857869; --border:#40352F; --border-strong:#5A4B43;
247
+ --accent:#83ADD1; --dt-selected:#22303B; --hover:#2E2622; --accent-soft:#223039; --ok:#91B887; --ok-soft:#24301F; --warn:#E2B36F; --warn-soft:#3A2F20; --danger:#F1746B; color-scheme:dark;
248
+ }
249
+ @media(prefers-color-scheme:dark) { body.devtools-ready[data-inspector-theme=system] {
250
+ --paper:#1D1816; --surface:#171311; --ink:#F5EDD9; --muted:#C3B7A9; --faint:#857869; --border:#40352F; --border-strong:#5A4B43;
251
+ --accent:#83ADD1; --dt-selected:#22303B; --hover:#2E2622; --accent-soft:#223039; --ok:#91B887; --ok-soft:#24301F; --warn:#E2B36F; --warn-soft:#3A2F20; --danger:#F1746B; color-scheme:dark;
252
+ }}
253
+ .devtools-ready button,.devtools-ready input,.devtools-ready select { font:inherit; }
254
+ .devtools-ready button { border:1px solid var(--border); border-radius:6px; min-height:28px; padding:4px 10px; cursor:pointer; }
255
+ .devtools-ready button:disabled { cursor:default; opacity:.45; }
256
+ .devtools-ready button:hover:not(:disabled) { background:var(--hover); }
257
+ .devtools-ready input[type=range] { accent-color:var(--accent); }
258
+ .devtools-ready code,.devtools-ready pre { font-family:var(--mono); }
259
+ .devtools-ready .dt-head { height:40px; min-height:40px; flex-wrap:nowrap; gap:2px; padding:0 8px; background:var(--paper); }
260
+ .devtools-ready .dt-head-main { display:flex; align-items:center; flex:1; min-width:0; }
261
+ .devtools-ready .dt-tabs { flex:1; align-items:center; gap:2px; }
262
+ .devtools-ready .dt-tabs button { border:0; border-radius:6px; min-height:28px; padding:5px 10px; color:var(--muted); background:transparent; }
263
+ .devtools-ready .dt-tabs button[aria-selected=true] { color:var(--ink); background:var(--surface); }
264
+ .devtools-ready .dt-head > .dt-icon { width:28px; height:28px; margin:0; }
265
+ .devtools-ready .dt-icon[aria-pressed=true] { background:var(--accent-soft); color:var(--accent); }
266
+ .devtools-ready .dt-camera { display:flex; align-items:center; flex:0 0 auto; flex-wrap:wrap; gap:6px; margin:0; padding:8px 12px; border:0; border-bottom:1px solid var(--border); background:var(--paper); overflow:visible; }
267
+ .devtools-ready .dt-camera-field { display:flex; flex:0 0 auto; gap:0; height:30px; border:1px solid var(--border); border-radius:6px; background:var(--surface); overflow:hidden; }
268
+ .devtools-ready .dt-camera-field > span:first-child { padding:0 4px 0 9px; color:var(--faint); font-size:10px; text-transform:uppercase; letter-spacing:.04em; }
269
+ .devtools-ready .dt-camera-field input[type=number] { width:58px; min-height:0; padding:0 5px; border:0; background:transparent; font:12px var(--mono); appearance:textfield; color:var(--ink); }
270
+ .devtools-ready .dt-camera-field input::-webkit-inner-spin-button { appearance:none; }
271
+ .devtools-ready .dt-camera-field input[type=range] { width:110px; padding:0; margin:0 10px 0 2px; min-height:0; }
272
+ .devtools-ready .dt-unit { padding-right:9px; color:var(--faint); }
273
+ .devtools-ready .dt-copy-agent { margin-left:auto; background:var(--ink); color:var(--paper); border-color:var(--ink); font-weight:500; }
274
+ .devtools-ready .dt-copy-agent:hover { background:var(--ink); opacity:.88; }
275
+ .devtools-ready .dt-menu { top:38px; right:8px; width:236px; padding:10px; border-radius:10px; box-shadow:0 8px 28px #0002; }
276
+ .devtools-ready .dt-dock-options { display:grid; grid-template-columns:repeat(3,1fr); gap:6px; margin-bottom:8px; }
277
+ .devtools-ready .dt-dock-options button { width:auto; height:34px; border:1px solid var(--border); justify-content:center; }
278
+ .devtools-ready .dt-menu-label { font-size:11px; }
279
+ .devtools-ready .dt-segments,.devtools-ready .dt-layer-modes,.devtools-ready .controls-segments { display:inline-flex; padding:2px; background:var(--surface); border:1px solid var(--border); border-radius:6px; gap:2px; }
280
+ .devtools-ready .dt-segments button,.devtools-ready .dt-layer-modes button,.devtools-ready .controls-segments button { min-height:23px; border:0; border-radius:4px; padding:3px 9px; font-size:12px; background:transparent; color:var(--muted); }
281
+ .devtools-ready .dt-segments button[aria-pressed=true],.devtools-ready .dt-layer-modes button[aria-pressed=true],.devtools-ready .controls-segments button[aria-checked=true] { color:var(--ink); background:var(--paper); box-shadow:0 1px 2px #0001; }
282
+ .devtools-ready .dt-menu .dt-segments { display:flex; }.devtools-ready .dt-menu .dt-segments button { flex:1; }
283
+ .devtools-ready .dt-tools { padding:10px 12px; gap:8px; background:var(--paper); }
284
+ .devtools-ready .dt-tools input[type=search] { height:30px; padding:0 9px; background:var(--surface); border-radius:6px; width:auto; }
285
+ .devtools-ready .dt-subtabs { padding:0 20px; gap:18px; }
286
+ .devtools-ready .dt-subtabs button { background:transparent; border:0; border-radius:0; border-bottom:2px solid transparent; padding:8px 0 9px; color:var(--muted); min-height:36px; }
287
+ .devtools-ready .dt-subtabs button[aria-selected=true] { color:var(--ink); border-bottom-color:var(--ink); }
288
+ .devtools-ready input[role=switch] { appearance:none; width:26px!important; height:15px!important; min-height:0; flex:none; border:0; border-radius:8px; background:var(--faint); position:relative; padding:0; margin:0; cursor:pointer; }
289
+ .devtools-ready input[role=switch]::before { content:''; position:absolute; width:11px; height:11px; left:2px; top:2px; border-radius:50%; background:#fff; transition:transform .15s; }
290
+ .devtools-ready input[role=switch]:checked { background:var(--accent); }
291
+ .devtools-ready input[role=switch]:checked::before { transform:translateX(11px); }
292
+ .devtools-ready .dt-layer-split { display:grid; grid-template-columns:minmax(300px,360px) minmax(0,1fr); }
293
+ .devtools-ready .dt-layer-sidebar { display:flex; flex-direction:column; min-height:0; border-right:1px solid var(--border); }
294
+ .devtools-ready .dt-layer-list { flex:1; border:0; }.devtools-ready .dt-layer-tools { flex-direction:column; align-items:stretch; }
295
+ .devtools-ready .dt-layer-tools-row { display:flex; align-items:center; gap:8px; }.devtools-ready .dt-layer-total { margin-left:auto; font:11px var(--mono); }
296
+ .devtools-ready .dt-layer-visible { font-size:12px; }.devtools-ready .dt-layer-group-title { height:32px; font-size:11px; padding:0 12px 0 8px!important; }
297
+ .devtools-ready .dt-layer-group-title strong { text-transform:uppercase; letter-spacing:.05em; }
298
+ .devtools-ready .dt-layer-row { display:grid; grid-template-columns:20px 18px minmax(0,1fr) 64px; height:30px; padding:0 12px 0 6px; gap:8px; border-left:2px solid transparent; }
299
+ .devtools-ready .dt-layer-row[aria-pressed=true] { background:var(--dt-selected); border-left-color:var(--accent); color:var(--ink); }
300
+ .devtools-ready .dt-layer-glyph { color:var(--muted); font-size:15px; }.devtools-ready .dt-layer-label { font-size:12px; }
301
+ .devtools-ready .dt-layer-prefix { color:var(--faint); }.devtools-ready .dt-layer-name { font-weight:500; }
302
+ .devtools-ready .dt-eye { padding:0; border:0; width:20px; height:20px; min-height:20px; color:var(--faint); background:transparent; display:grid; place-items:center; opacity:0; }
303
+ .devtools-ready .dt-eye svg { width:14px; height:14px; }
304
+ .devtools-ready :is(.dt-layer-row,.dt-layer-group-title):hover .dt-eye,.devtools-ready .dt-eye:focus-visible,.devtools-ready .dt-eye[aria-pressed=true] { opacity:1; }
305
+ .dt-zrange { position:relative; display:block; width:64px; height:14px; }.dt-zrange::before { content:''; position:absolute; inset:6px 0; height:2px; background:var(--border); }
306
+ .dt-zrange i { position:absolute; top:5px; height:4px; border-radius:2px; background:var(--accent); opacity:.65; }.dt-zrange em { position:absolute; top:1px; height:12px; width:1px; background:var(--accent); }
307
+ .devtools-ready .dt-layer-heading { padding:14px 20px 6px; align-items:flex-start; border:0; }
308
+ .devtools-ready .dt-layer-title strong { font-size:16px; font-weight:600; }.devtools-ready .dt-layer-title > code { font-size:10px; color:var(--faint); }
309
+ .dt-layer-status { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); margin-top:6px; }.dt-layer-status[data-state=Enabled] { color:var(--ok); }.dt-layer-status[data-state="Outside zoom"] { color:var(--warn); }
310
+ .devtools-ready .dt-layer-rules { font-size:12px; }.devtools-ready .dt-layer-rules th { padding:8px 20px; font-size:11px; text-transform:uppercase; letter-spacing:.05em; }
311
+ .devtools-ready .dt-layer-rules th:first-child { width:42%; }.devtools-ready .dt-layer-rules td { padding:8px 20px; vertical-align:middle; min-height:44px; }
312
+ .devtools-ready .dt-layer-rules code { font:12px/1.5 var(--mono); }.devtools-ready .dt-layer-rule-name { color:var(--ink); }.devtools-ready .dt-layer-dependency { color:var(--accent); font:10px var(--mono); }
313
+ .devtools-ready .dt-layer-rule-editor { gap:9px; flex-wrap:wrap; }.devtools-ready .dt-layer-rule-editor input:not([type=color]):not([type=checkbox]):not([type=range]),.devtools-ready .dt-layer-rule-editor select { min-height:26px; border:1px solid var(--border); font:12px var(--mono); border-radius:6px; }
314
+ .devtools-ready .dt-layer-rule-editor input[type=color] { width:22px; height:22px; }
315
+ .devtools-ready .dt-layer-rule-editor input[type=range] { width:110px; min-width:70px; padding:0; flex:0 1 110px; }
316
+ .dt-rule-toggle { display:flex; align-items:center; gap:10px; }.dt-rule-toggle input { width:14px; height:14px; min-height:0; margin:0; accent-color:var(--accent); }
317
+ .dt-rule-choices { display:inline-flex; flex-wrap:wrap; border:1px solid var(--border); border-radius:6px; overflow:hidden; }.devtools-ready .dt-rule-choices button { min-height:26px; border:0; border-radius:0; font:11px var(--mono); padding:3px 8px; }
318
+ .dt-rule-spark { display:flex; align-items:center; gap:10px; color:var(--accent); font:11px var(--mono); margin-bottom:5px; }.dt-rule-spark svg { width:130px; height:26px; overflow:visible; }
319
+ .dt-filter-chips { display:flex; flex-wrap:wrap; gap:6px; margin-block:10px; }.dt-filter-chip { padding:4px 9px; background:var(--surface); border:1px solid var(--border); border-radius:13px; font:11px var(--mono); }
320
+ .devtools-ready .dt-layer-section,.devtools-ready .dt-layer-origin { padding:12px 20px; }.devtools-ready .dt-layer-section h4 { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
321
+ .devtools-ready .dt-layer-evidence { padding:0; }.devtools-ready .dt-layer-evidence-row { padding:12px 20px; font-size:12px; grid-template-columns:20px 130px 1fr; }
322
+ .devtools-ready .dt-layer-evidence-row[data-state=pass] .dt-layer-indicator { color:var(--ok); }.devtools-ready .dt-layer-evidence-row[data-state=warn] .dt-layer-indicator { color:var(--warn); }
323
+ .devtools-ready .dt-layer-json { font:11px/1.6 var(--mono); color:var(--ink); }
324
+ .devtools-ready .dt-table { font:12px var(--mono); }.devtools-ready .dt-table th { padding:8px 12px; font:11px Inter,system-ui,sans-serif; text-transform:uppercase; border-right:0; }.devtools-ready .dt-table td { height:32px; padding:6px 12px; }.devtools-ready .dt-table tbody tr:nth-child(even) { background:transparent; }
325
+ .devtools-ready .dt-network-body { display:grid; grid-template-columns:minmax(300px,360px) minmax(0,1fr); }.dt-tiles-sidebar { display:flex; flex-direction:column; min-height:0; border-right:1px solid var(--border); }.devtools-ready .dt-network-detail { border:0; min-width:0; }
326
+ .devtools-ready .dt-tile-heading { padding:14px 20px 8px; }.dt-tile-heading strong { font:15px var(--mono); }.dt-tile-status { padding:0 20px 10px; color:var(--muted); font-size:12px; }
327
+ .dt-tile-grid { display:grid; gap:3px; padding:12px; }.devtools-ready .dt-tile-cell { aspect-ratio:1; padding:0; font:10px var(--mono); min-width:0; min-height:20px; border-radius:4px; background:color-mix(in srgb,var(--accent) var(--weight,10%),var(--paper)); }
328
+ .devtools-ready .dt-tile-cell[aria-pressed=true] { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft); }.dt-tile-grid-title { margin:10px 12px 0; color:var(--muted); font-size:11px; }
329
+ .dt-tile-chip-list { display:flex; gap:6px; flex-wrap:wrap; padding:10px 20px; border-bottom:1px solid var(--border); }.devtools-ready .dt-tile-chip { font:11px var(--mono); border-radius:13px; }
330
+ .dt-asset-layout { display:grid; grid-template-columns:minmax(0,1fr) 300px; min-height:0; flex:1; }.dt-asset-main { min-width:0; overflow:auto; padding:12px; }.dt-asset-aside { overflow:auto; border-left:1px solid var(--border); }.dt-asset-aside h3 { font:500 15px var(--mono); margin:16px 20px; }.dt-asset-variants { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1px; background:var(--border); }.dt-asset-variant { padding:20px 10px; display:flex; flex-direction:column; align-items:center; gap:10px; background:var(--surface); font:10px var(--mono); overflow-wrap:anywhere; }.dt-asset-variant[data-night=true] { background:#2e2622; color:#f5edd9; }
331
+ .devtools-ready .dt-sprite-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(104px,1fr)); gap:4px; }.devtools-ready .dt-sprite-card { border:1px solid transparent; border-radius:8px; background:transparent; padding:16px 6px 10px; cursor:pointer; }.dt-sprite-card[aria-pressed=true] { background:var(--dt-selected)!important; border-color:var(--accent)!important; }
332
+ .dt-debug-overlay { display:grid; grid-template-columns:84px 1fr auto; align-items:center; gap:16px; padding:10px 20px; border-bottom:1px solid var(--border); cursor:pointer; }.dt-debug-overlay:hover { background:var(--hover); }.dt-debug-overlay small { display:block; color:var(--muted); font-size:12px; }.dt-debug-preview { width:84px; height:58px; border-radius:6px; overflow:hidden; }.dt-debug-preview svg { width:100%; height:100%; }
333
+ .dt-section-heading { background:var(--surface); padding:8px 20px; font-size:11px; font-weight:600; letter-spacing:.05em; text-transform:uppercase; border-bottom:1px solid var(--border); }
334
+ .devtools-ready .dt-overdraw-help { margin:0; border:0; border-bottom:1px solid var(--border); border-radius:0; padding:12px 20px 12px 120px; background:var(--surface); }.dt-debug-scale { display:flex; align-items:center; gap:12px; padding:16px 20px; flex-wrap:wrap; }.dt-debug-scale input[type=number] { width:64px; }
335
+ .devtools-ready .map-controls { padding:0!important; background:var(--paper); }.devtools-ready .controls-heading { display:none; }.devtools-ready .controls-grid { display:block; }.devtools-ready .controls-selection,.devtools-ready .controls-runtime { border:0; padding:0; }
336
+ .devtools-ready .controls-section-title { margin:0; padding:8px 20px; background:var(--surface); border-bottom:1px solid var(--border); font-size:11px; letter-spacing:.05em; }.devtools-ready .control-field { display:grid; grid-template-columns:200px 1fr; align-items:center; margin:0; padding:10px 20px; border-bottom:1px solid var(--border); gap:16px; font-size:13px; }.devtools-ready .control-field select { max-width:100%; width:max-content; min-width:132px; }
337
+ .devtools-ready #controls-modules { display:block; }.devtools-ready .control-module { border:0; border-bottom:1px solid var(--border); border-radius:0; margin:0; background:var(--paper); }.devtools-ready .control-module-header { padding:10px 20px; }.devtools-ready .control-module-header label { justify-content:flex-start; font-size:13px; font-weight:500; }.devtools-ready .control-module-header input { order:-1; margin-right:12px; }.devtools-ready .control-module fieldset { padding:0; background:var(--surface); }.devtools-ready .control-module .control-field { padding-left:58px; border:0; }.devtools-ready .dt-scene-content { max-width:1000px; width:100%; }.dt-palettes { display:flex; gap:8px; flex-wrap:wrap; }.devtools-ready .dt-palette { display:flex; align-items:center; gap:8px; padding:5px 10px 5px 5px; }.dt-palette-swatches { display:grid; grid-template-columns:repeat(2,11px); grid-template-rows:repeat(2,11px); overflow:hidden; border-radius:4px; }
338
+ @container(max-width:760px) {
339
+ .devtools-ready .dt-layer-split,.devtools-ready .dt-network-body { display:flex; flex-direction:column; }
340
+ .devtools-ready .dt-layer-sidebar,.devtools-ready .dt-tiles-sidebar { flex:0 0 36%; min-height:150px; border:0; border-bottom:1px solid var(--border); }
341
+ .devtools-ready .dt-layer-detail,.devtools-ready .dt-network-detail { flex:1; min-height:0; border:0; }
342
+ .devtools-ready .dt-layer-title { flex-basis:180px; }.devtools-ready .dt-camera-field input[type=range] { width:80px; }
343
+ .devtools-ready .dt-layer-rules th:first-child { width:45%; }.devtools-ready .dt-layer-rules td { padding:8px 12px; }
344
+ .dt-asset-layout { grid-template-columns:1fr; }.dt-asset-aside { border:0; border-top:1px solid var(--border); }
345
+ .devtools-ready .control-field { grid-template-columns:160px 1fr; }.devtools-ready .control-module .control-field { padding-left:30px; }
346
+ }
347
+ @media(prefers-reduced-motion:reduce) { .devtools-ready * { scroll-behavior:auto!important; transition:none!important; animation:none!important; } }
348
+
349
+ .dt-palettes{display:flex;gap:8px;flex-wrap:wrap}.devtools-ready .dt-palettes button{display:flex;flex-direction:column;gap:7px;padding:10px 14px;min-width:110px;background:var(--paper);text-align:left;text-transform:capitalize}.devtools-ready .dt-palettes button[aria-checked=true]{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}.dt-palette-swatches{display:flex;height:26px;width:100%;overflow:hidden;border-radius:4px}.dt-palette-swatches i{flex:1;min-width:20px}.devtools-ready .dt-palette-field{align-items:flex-start;flex-direction:column}.dt-model-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px}.devtools-ready .dt-model-card{display:flex;flex-direction:column;align-items:center;padding:0;overflow:hidden;border-radius:10px}.dt-model-card canvas{background:linear-gradient(var(--surface),var(--paper));max-width:100%;height:auto!important}.dt-model-card strong{font:500 12px var(--mono);padding:9px 12px 2px;overflow-wrap:anywhere}.dt-model-card small{font-size:11px;color:var(--muted);padding:0 12px 9px}.dt-model-card[aria-pressed=true]{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}.dt-asset-aside>canvas{display:block;margin:auto;max-width:100%;touch-action:none;cursor:grab}.dt-font-ranges{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.dt-font-ranges small{color:var(--muted);font-size:11px}
350
+
351
+ .dt-debug-specimen{display:flex;align-items:center;gap:18px;min-height:70px;padding:12px 20px;border:1px solid var(--border);border-radius:8px;background:var(--surface)}.dt-rule-font-preview{overflow:hidden;max-width:100%}.dt-rule-font-preview canvas{max-width:100%;height:auto!important}
352
+ /* Keep the new shell independent of the legacy in-header camera and toolbar breakpoints. */
353
+ .devtools-ready .dt-shell>.dt-camera{order:0;flex:0 0 auto;margin:0;border-top:0;background:var(--paper);padding:8px 12px}
354
+ .devtools-ready .dt-shell>.dt-head{flex-wrap:nowrap}.devtools-ready .dt-shell .dt-head-main{display:flex;gap:2px}
355
+ .devtools-ready .dt-shell .dt-layer-tools input[type=search]{order:0;flex:0 0 auto;width:100%;height:30px}
356
+ .devtools-ready .dt-shell .dt-layer-tools{flex-wrap:nowrap}.devtools-ready .dt-shell .dt-layer-tools-row{flex:none;min-height:28px}
357
+ .devtools-ready .dt-shell .dt-layer-row{display:grid;grid-template-columns:20px 18px minmax(0,1fr) 64px;height:30px;min-height:30px;padding:0 12px 0 6px;gap:8px}
358
+ .devtools-ready .dt-shell .dt-layer-label{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
359
+ .devtools-ready .dt-shell .dt-layer-heading{flex:none}.devtools-ready .dt-shell .dt-layer-title{flex:1 1 180px}
360
+ .devtools-ready .dt-shell .dt-layer-sidebar .dt-layer-list{flex:1;min-height:0}.devtools-ready .dt-shell .map-controls .control-field{display:grid;grid-template-columns:160px minmax(0,1fr);padding:12px 20px;margin:0}
361
+ .devtools-ready .dt-shell .control-module-header label{display:flex;gap:12px}.devtools-ready .dt-shell .map-controls input[role=switch]{width:26px!important;height:15px!important;min-height:0;border:0;background:var(--faint)}.devtools-ready .dt-shell .map-controls input[role=switch]:checked{background:var(--accent)}.devtools-ready .dt-shell .map-controls input[role=switch]::before{width:11px;height:11px;top:2px;left:2px;background:white}.devtools-ready .dt-shell .map-controls input[role=switch]:checked::before{transform:translateX(11px)}
362
+ @container(max-width:760px){.devtools-ready .dt-shell .dt-layer-sidebar,.devtools-ready .dt-shell .dt-tiles-sidebar{min-height:110px;flex:0 0 38%}.devtools-ready .dt-shell .dt-layer-tools{padding:8px 12px}.devtools-ready .dt-shell .dt-tabs button{padding-inline:7px}.devtools-ready .dt-shell .dt-head{padding-inline:4px}.devtools-ready .dt-shell .dt-camera-field{flex:0 0 auto}}
363
+ `;
364
+
365
+ // src/devtools/styles.ts
366
+ var legacyStyles = `
367
+ body.devtools-ready { color-scheme:light dark; --paper:light-dark(#fff,#202124); --surface:light-dark(#f1f3f4,#292a2d); --ink:light-dark(#202124,#e8eaed); --muted:light-dark(#62676e,#9aa0a6); --border:light-dark(#dadce0,#3c4043); --accent:light-dark(#1a73e8,#8ab4f8); --dt-selected:light-dark(#e8f0fe,#394457); --dt-string:light-dark(#a31515,#e6b98b); --dt-number:light-dark(#1a1aa6,#a8c7fa); font:12px/1.45 Arial,system-ui,sans-serif; background:var(--paper); }
368
+ .devtools-ready button,.devtools-ready input,.devtools-ready select,.devtools-ready summary { min-height:24px; border-radius:2px; font:inherit; }
369
+ .devtools-ready button { padding:3px 8px; background:var(--paper); color:var(--ink); }
370
+ .devtools-ready button:hover { background:var(--surface); border-color:var(--border); }
371
+ .devtools-ready button:focus-visible,.devtools-ready input:focus-visible,.devtools-ready select:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
372
+ .devtools-ready button[aria-pressed=true] { color:var(--accent); background:var(--dt-selected); }
373
+ .devtools-ready .tool-icon { width:14px; height:14px; }
374
+ .devtools-ready .workspace { display:grid; grid-template-columns:minmax(0,1fr); grid-template-rows:minmax(180px,1fr) var(--dt-height,340px); flex:1; min-height:0; }
375
+ .devtools-ready .workspace[data-dock=right] { grid-template-columns:minmax(200px,1fr) var(--dt-width,460px); grid-template-rows:minmax(0,1fr); }
376
+ .devtools-ready .workspace[data-dock=left] { grid-template-columns:var(--dt-width,460px) minmax(200px,1fr); grid-template-rows:minmax(0,1fr); }
377
+ .devtools-ready .workspace[data-dock=left] > .stage { grid-column:2; grid-row:1; }
378
+ .devtools-ready .workspace[data-dock=left] > .dt-shell { grid-column:1; grid-row:1; border-right:1px solid var(--border); }
379
+ .devtools-ready .workspace[data-collapsed=true] { grid-template-columns:minmax(0,1fr); grid-template-rows:minmax(0,1fr); }
380
+ .devtools-ready .workspace[data-collapsed=true] > .dt-shell { display:none; }
381
+ .devtools-ready .workspace > .inspector { display:none !important; }
382
+ .devtools-ready .inspect-hint { top:8px; font-size:11px; padding:4px 7px; background:var(--paper); color:var(--ink); border:1px solid var(--accent); }
383
+ #dt-reopen { display:none; position:absolute; top:8px; left:8px; z-index:10; box-shadow:0 1px 4px #0002; }
384
+ .workspace[data-collapsed=true] > #dt-reopen { display:block; }
385
+ .devtools-ready .workspace[data-collapsed=true] > .stage { grid-column:1; grid-row:1; }
386
+ .dt-shell { container-type:inline-size; display:flex; flex-direction:column; min-width:0; min-height:0; overflow:visible; position:relative; background:var(--paper); border-top:1px solid var(--border); color:var(--ink); }
387
+ .dt-head { min-height:40px; display:flex; align-items:center; border-bottom:1px solid var(--border); background:var(--surface); padding:0 3px; position:relative; flex:none; }
388
+ .dt-head-main { display:flex; align-items:center; gap:12px; flex:1; min-width:0; align-self:stretch; }
389
+ .dt-tabs { display:flex; min-width:0; overflow-x:auto; scrollbar-width:none; flex:0 1 auto; align-self:stretch; }
390
+ .dt-tabs button { border:0; border-bottom:2px solid transparent; background:transparent; border-radius:0; white-space:nowrap; padding:8px 10px; min-height:38px; }
391
+ .dt-tabs button[aria-selected=true] { color:var(--accent); border-bottom-color:var(--accent); background:var(--paper); }
392
+ .dt-head .dt-icon,.dt-tools .dt-icon { display:inline-flex; align-items:center; justify-content:center; border:0; background:transparent; padding:3px; width:26px; height:26px; flex:none; }
393
+ .dt-head .dt-icon:disabled { opacity:.35; cursor:default; }
394
+ .dt-icon svg { width:16px; height:16px; pointer-events:none; }
395
+ .dt-divider { height:16px; border-left:1px solid var(--border); margin:0 4px; }
396
+ .dt-camera { display:flex; align-items:center; flex-wrap:nowrap; gap:8px; min-width:0; flex:none; border:0; border-left:1px solid var(--border); margin:5px 0; padding:0 0 0 12px; overflow-x:auto; scrollbar-width:thin; }
397
+ .dt-camera-field { display:flex; align-items:center; gap:4px; min-width:0; flex:none; }
398
+ .dt-camera-field > label { color:var(--muted); font-size:11px; line-height:1; }
399
+ .dt-stepper { display:flex; align-items:stretch; height:26px; border:1px solid var(--border); border-radius:4px; overflow:hidden; background:var(--paper); }
400
+ .dt-stepper input[type=number] { appearance:textfield; -moz-appearance:textfield; width:54px; min-width:0; min-height:0; padding:2px 0; border:0; border-radius:0; background:transparent; color:var(--ink); text-align:center; font-variant-numeric:tabular-nums; }
401
+ .dt-stepper input::-webkit-inner-spin-button,.dt-stepper input::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; }
402
+ .dt-stepper button { width:24px; min-height:0; padding:0; border:0; border-radius:0; background:transparent; font-size:15px; line-height:1; }
403
+ .dt-stepper button:disabled { color:var(--muted); opacity:.4; cursor:default; }
404
+ .dt-camera:disabled { opacity:.5; }
405
+ .dt-camera-reset { flex:none; height:26px; }
406
+ .dt-menu { position:absolute; z-index:40; right:5px; top:40px; padding:8px; background:var(--paper); color:var(--ink); border:1px solid var(--border); box-shadow:0 4px 16px #0003; min-width:190px; border-radius:4px; }
407
+ :is([data-dock=left],[data-dock=right]) .dt-head { flex-wrap:wrap; }
408
+ :is([data-dock=left],[data-dock=right]) .dt-head > .dt-icon { margin-block:6px; }
409
+ :is([data-dock=left],[data-dock=right]) .dt-head-main { display:contents; }
410
+ :is([data-dock=left],[data-dock=right]) .dt-tabs { flex:1; }
411
+ :is([data-dock=left],[data-dock=right]) .dt-camera-field { flex:1 0 96px; }
412
+ :is([data-dock=left],[data-dock=right]) .dt-stepper { flex:1; min-width:0; }
413
+ :is([data-dock=left],[data-dock=right]) .dt-stepper input[type=number] { flex:1; width:0; }
414
+ :is([data-dock=left],[data-dock=right]) .dt-stepper button { flex:none; width:22px; }
415
+ :is([data-dock=left],[data-dock=right]) .dt-menu { top:38px; }
416
+ @container(max-width:1050px) {
417
+ .dt-head { flex-wrap:wrap; }
418
+ .dt-head > .dt-icon { margin-block:6px; }
419
+ .dt-head-main { display:contents; }
420
+ .dt-tabs { flex:1; }
421
+ .dt-camera-field { flex:1 0 96px; }
422
+ .dt-stepper { flex:1; min-width:0; }
423
+ .dt-stepper input[type=number] { flex:1; width:0; }
424
+ .dt-stepper button { flex:none; width:22px; }
425
+ .dt-menu { top:38px; }
426
+ }
427
+ .dt-menu-label { color:var(--muted); margin:2px 0 6px; }
428
+ .dt-dock-options { display:flex; gap:4px; }
429
+ .dt-dock-options button { padding:6px; display:flex; }
430
+ .dt-dock-options svg { width:24px; height:20px; }
431
+ .dt-resize { position:absolute; z-index:5; height:6px; left:0; right:0; top:-4px; cursor:ns-resize; touch-action:none; }
432
+ [data-dock=right] > .dt-shell > .dt-resize { top:0; bottom:0; left:-4px; width:6px; height:auto; right:auto; cursor:ew-resize; }
433
+ [data-dock=left] > .dt-shell > .dt-resize { top:0; bottom:0; right:-4px; width:6px; height:auto; left:auto; cursor:ew-resize; }
434
+ .dt-resize-shield { position:absolute; inset:0; z-index:30; background:transparent; }
435
+ .dt-body { flex:1; display:flex; flex-direction:column; min-height:0; overflow:hidden; }
436
+ .dt-panel { flex:1; min-height:0; min-width:0; display:flex; flex-direction:column; overflow:hidden; }
437
+ .dt-tools { min-height:29px; display:flex; flex-wrap:wrap; align-items:center; gap:5px; border-bottom:1px solid var(--border); padding:3px 6px; flex:none; }
438
+ .dt-tools input[type=search] { width:160px; flex:1; min-width:75px; border:1px solid var(--border); background:var(--paper); color:var(--ink); padding:2px 6px; }
439
+ .dt-tools label { display:flex; align-items:center; gap:4px; white-space:nowrap; color:var(--muted); }
440
+ .dt-tools select { max-width:160px; padding:2px; }
441
+ .dt-tools input[type=checkbox] { min-height:0; margin:0; accent-color:var(--accent); }
442
+ .dt-tools button { border-color:transparent; background:transparent; }
443
+ .dt-footer { min-height:23px; padding:3px 8px; border-top:1px solid var(--border); color:var(--muted); font-size:11px; flex:none; display:flex; gap:12px; }
444
+ .dt-scroll { flex:1; overflow:auto; min-height:0; min-width:0; }
445
+ .dt-empty { color:var(--muted); padding:22px; text-align:center; margin:auto; max-width:420px; }
446
+ .dt-empty strong { color:var(--ink); display:block; margin-bottom:6px; font-weight:400; }
447
+ .dt-inspect-grid { display:grid; grid-template-columns:minmax(220px,1fr) minmax(300px,42%); flex:1; min-height:0; }
448
+ .dt-tree-column { display:flex; flex-direction:column; min-height:0; border-right:1px solid var(--border); }
449
+ .dt-tree { flex:1; min-height:0; overflow:auto; font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace; padding:5px 0; }
450
+ .dt-tree-row { display:flex; width:100%; gap:6px; align-items:center; min-height:23px; padding:2px 8px; white-space:nowrap; border:0 !important; border-radius:0 !important; text-align:left; background:transparent !important; font:inherit !important; }
451
+ .dt-tree-row[aria-selected=true] { background:var(--dt-selected) !important; }
452
+ .dt-tree-row:hover { background:var(--surface) !important; }
453
+ .dt-tree-row .dt-token { color:var(--dt-number); }
454
+ .dt-tree-row .dt-attr { color:var(--dt-string); }
455
+ .dt-tree-row .dt-dim { color:var(--muted); }
456
+ .dt-tree-row .dt-truncate { overflow:hidden; text-overflow:ellipsis; }
457
+ .dt-details { min-width:0; min-height:0; display:flex; flex-direction:column; }
458
+ .dt-subtabs { display:flex; gap:2px; border-bottom:1px solid var(--border); flex:none; overflow-x:auto; }
459
+ .dt-subtabs button { border:0; border-bottom:2px solid transparent; border-radius:0; padding:4px 8px; white-space:nowrap; }
460
+ .dt-subtabs button[aria-selected=true] { border-bottom-color:var(--accent); color:var(--accent); }
461
+ .dt-rule { border-bottom:1px solid var(--border); padding:7px 9px; font:12px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace; }
462
+ .dt-rule-title { display:flex; justify-content:space-between; gap:8px; color:var(--ink); overflow-wrap:anywhere; margin-bottom:4px; }
463
+ .dt-rule-origin { color:var(--muted); font:11px/1.6 Arial,sans-serif; text-align:right; }
464
+ .dt-property { display:flex; align-items:center; gap:4px; padding:1px 0 1px 14px; min-height:24px; }
465
+ .dt-property-name { color:light-dark(#881391,#c58af9); flex:none; }
466
+ .dt-property input[type=text],.dt-property input[type=number],.dt-property select { border:1px solid transparent; background:transparent; min-width:30px; flex:1; width:100%; padding:0 2px; color:var(--dt-string); border-radius:0; font:inherit; min-height:22px; }
467
+ .dt-property input:focus,.dt-property input:hover { border-color:var(--border); }
468
+ .dt-property input[type=color] { width:14px; height:14px; padding:0; min-height:0; border:0; flex:none; background:transparent; cursor:pointer; }
469
+ .dt-property input[type=color]::-webkit-color-swatch-wrapper { padding:0; }
470
+ .dt-property input[type=color]::-webkit-color-swatch { border:1px solid var(--border); }
471
+ .dt-property .dt-revert { border:0; padding:0 3px; min-height:18px; color:var(--muted); background:transparent; }
472
+ .dt-property[data-modified=true] { background:color-mix(in srgb,var(--accent) 8%,transparent); }
473
+ .dt-expression { display:block; padding-left:14px; color:var(--muted); white-space:pre-wrap; overflow-wrap:anywhere; font-size:11px; }
474
+ .dt-table { border-collapse:collapse; width:100%; font-size:11px; text-align:left; }
475
+ .dt-table th { position:sticky; top:0; z-index:1; background:var(--surface); font-weight:400; border-bottom:1px solid var(--border); border-right:1px solid var(--border); padding:3px 7px; white-space:nowrap; }
476
+ .dt-table th button { font:inherit; border:0; padding:0; min-height:18px; background:transparent; }
477
+ .dt-table td { padding:3px 7px; border-bottom:1px solid color-mix(in srgb,var(--border) 45%,transparent); max-width:320px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; height:24px; }
478
+ .dt-table tbody tr:nth-child(even) { background:color-mix(in srgb,var(--surface) 55%,transparent); }
479
+ .dt-table tbody tr[aria-selected=true] { background:var(--dt-selected); }
480
+ .dt-table tbody tr:hover { background:var(--surface); }
481
+ .dt-table .dt-cell-button { text-align:left; border:0; background:transparent; min-height:18px; padding:0; color:inherit; max-width:100%; overflow:hidden; text-overflow:ellipsis; font:inherit; }
482
+ .dt-table .dt-number { font-variant-numeric:tabular-nums; text-align:right; }
483
+ .dt-table .dt-error { color:light-dark(#b3261e,#f2b8b5); }
484
+ .dt-network-body { display:flex; flex:1; min-height:0; min-width:0; }
485
+ .dt-network-body > .dt-scroll { flex:1; min-width:0; }
486
+ .dt-network-body[data-selected=true] > .dt-scroll { flex:1 1 55%; }
487
+ .dt-network-detail { display:flex; flex-direction:column; flex:1 1 45%; min-width:290px; min-height:0; border-left:1px solid var(--border); background:var(--paper); }
488
+ .dt-network-detail[hidden] { display:none; }
489
+ .dt-tile-row { cursor:pointer; }
490
+ .dt-tile-heading { flex:none; justify-content:space-between; }
491
+ .dt-tile-heading strong { font-size:12px; font-weight:500; }
492
+ .dt-tile-heading button { border:0; font-size:18px; }
493
+ .dt-tile-content { min-height:0; }
494
+ .dt-tile-content select { width:100%; min-width:0; background:var(--paper); color:var(--ink); border:1px solid var(--border); padding:4px; }
495
+ .dt-tile-paging { justify-content:space-between; }
496
+ .dt-tile-feature { border-bottom:1px solid var(--border); padding:5px 8px; }
497
+ .dt-tile-feature summary { cursor:pointer; overflow-wrap:anywhere; font-size:11px; }
498
+ .dt-tile-feature details { padding-left:12px; }
499
+ .dt-tile-feature pre { white-space:pre-wrap; overflow-wrap:anywhere; font:11px/1.6 ui-monospace,monospace; color:var(--dt-string); margin:6px 0; }
500
+ :is([data-dock=left],[data-dock=right]) .dt-network-body { flex-direction:column; }
501
+ :is([data-dock=left],[data-dock=right]) .dt-network-body[data-selected=true] > .dt-scroll { flex:0 0 35%; }
502
+ :is([data-dock=left],[data-dock=right]) .dt-network-detail { flex:1; min-width:0; border-left:0; border-top:1px solid var(--border); }
503
+ @container(max-width:680px) {
504
+ .dt-network-body { flex-direction:column; }
505
+ .dt-network-body[data-selected=true] > .dt-scroll { flex:0 0 35%; }
506
+ .dt-network-detail { flex:1; min-width:0; border-left:0; border-top:1px solid var(--border); }
507
+ }
508
+ .dt-kv { display:grid; grid-template-columns:minmax(100px,auto) minmax(0,1fr); gap:5px 12px; font:12px/1.5 ui-monospace,monospace; padding:8px; margin:0; }
509
+ .dt-kv dt { color:var(--muted); overflow-wrap:anywhere; }.dt-kv dd { margin:0; overflow-wrap:anywhere; }
510
+ .dt-note { color:var(--muted); font-size:11px; padding:6px 8px; margin:0; }
511
+ .dt-status-message { color:var(--accent); padding:4px 8px; border-top:1px solid var(--border); font-size:11px; }
512
+ .dt-shell .notice { position:static; padding:5px 9px; border:0; font:11px/1.5 ui-monospace,monospace; box-shadow:none; color:light-dark(#b3261e,#f2b8b5); }
513
+ .dt-scene-content { display:flex; flex-direction:column; flex:1; min-height:0; min-width:0; width:100%; max-width:1000px; align-self:flex-start; }
514
+ .dt-shell .map-controls { flex:1; width:100%; max-width:none; padding:0 16px 16px; border:0; overflow:auto; background:var(--paper); }
515
+ .dt-shell .controls-grid,.dt-shell #controls-modules { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 24px; align-items:start; }
516
+ .dt-shell .controls-grid { border-bottom:1px solid var(--border); padding-bottom:8px; }
517
+ .dt-shell .controls-selection,.dt-shell .controls-runtime { border:0; padding-bottom:0; }
518
+ .dt-shell .map-controls .control-field,.dt-shell .control-module-header label { display:grid; grid-template-columns:min(36%,160px) minmax(0,1fr); align-items:center; gap:12px; min-height:28px; }
519
+ .dt-shell .map-controls .control-field > select,.dt-shell .map-controls .control-field > input[type=number] { justify-self:start; width:min(200px,100%); max-width:100%; }
520
+ .dt-shell .map-controls input[type=checkbox] { appearance:none; -webkit-appearance:none; position:relative; justify-self:start; flex:none; width:34px; height:20px; min-height:20px; margin:0; padding:0; border:1px solid light-dark(#9aa0a6,#7c838d); border-radius:999px; background:light-dark(#e3e7ed,#444950); cursor:pointer; transition:background-color 120ms ease,border-color 120ms ease; }
521
+ .dt-shell .map-controls input[type=checkbox]::before { content:''; position:absolute; top:2px; left:2px; width:14px; height:14px; border-radius:50%; background:light-dark(#fff,#c4c8cf); box-shadow:0 1px 2px #0003; transition:transform 120ms ease,background-color 120ms ease; }
522
+ .dt-shell .map-controls input[type=checkbox]:checked { background:var(--accent); border-color:var(--accent); }
523
+ .dt-shell .map-controls input[type=checkbox]:checked::before { transform:translateX(14px); background:light-dark(#fff,#202124); }
524
+ .dt-shell .map-controls input[type=checkbox]:hover:not(:disabled) { box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 12%,transparent); }
525
+ .dt-shell .map-controls input[type=checkbox]:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
526
+ .dt-shell .map-controls input[type=checkbox]:disabled { cursor:not-allowed; }
527
+ .dt-shell .map-controls .control-field:has(input[type=checkbox]:not(:disabled)) { cursor:pointer; }
528
+ .dt-shell .controls-heading button { min-width:60px; }
529
+ .dt-shell .controls-segments button { padding:4px 10px; border-radius:0; }
530
+ @media (prefers-reduced-motion:reduce) { .dt-shell .map-controls input[type=checkbox],.dt-shell .map-controls input[type=checkbox]::before { transition:none; } }
531
+ .dt-shell .control-module { border:0; border-bottom:1px solid var(--border); border-radius:0; background:var(--paper); margin:0; }
532
+ .dt-shell .control-module-header { padding:5px 0; }.dt-shell .control-module-header label { font-size:12px; font-weight:400; }
533
+ .dt-shell .control-module fieldset { min-width:0; padding:0 0 8px 16px; }.dt-shell .control-module summary { padding:2px 0 5px; }
534
+ .dt-shell .controls-section-title { margin:10px 0 4px; font-size:11px; letter-spacing:0; text-transform:none; }
535
+ .dt-shell .control-field { margin:5px 0; }
536
+ @container(max-width:680px) { .dt-shell .controls-grid,.dt-shell #controls-modules { grid-template-columns:minmax(0,1fr); }.dt-shell .map-controls .control-field,.dt-shell .control-module-header label { grid-template-columns:min(36%,160px) minmax(0,1fr); }.dt-shell .controls-segments button { padding-inline:8px; } }
537
+ .dt-shell .controls-code { background:var(--surface); }.dt-shell .controls-selection .control-field select { max-width:100%; }
538
+ .dt-shell .panel-heading h2 { font:12px Arial,sans-serif; }.dt-shell .review-tabs { margin:0; padding:0; }
539
+ .dt-assets-content { scrollbar-gutter:stable; }
540
+ .dt-font-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,350px),1fr)); max-width:1100px; }
541
+ .dt-font-card { min-width:0; margin:0; padding:14px 12px; border-bottom:1px solid var(--border); }
542
+ .dt-font-card figcaption { margin-bottom:8px; font:12px/1.5 ui-monospace,monospace; }
543
+ .dt-font-sample { min-height:94px; overflow-x:auto; border:1px solid var(--border); border-radius:3px; background:#fff; color:#303b4b; }
544
+ .dt-font-preview { display:block; }
545
+ .dt-font-loading { display:block; padding:15px 10px; font-size:11px; color:#62676e; }
546
+ .dt-sprite-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(112px,1fr)); gap:1px; padding:1px; background:var(--border); }
547
+ .dt-sprite-card { margin:0; min-width:0; padding:10px 8px 8px; background:var(--paper); text-align:center; }
548
+ .dt-sprite-card:hover { background:var(--dt-selected); }
549
+ .dt-sprite-preview { height:60px; display:flex; align-items:center; justify-content:center; border:1px solid #dadce0; border-radius:3px; background:repeating-conic-gradient(#e8eaed 0% 25%,#fff 0% 50%) 0 0 / 12px 12px; color:#62676e; }
550
+ .dt-sprite-card figcaption { margin-top:7px; font:11px/1.4 ui-monospace,monospace; overflow-wrap:anywhere; }
551
+ .dt-sprite-size { display:block; min-height:15px; margin-top:3px; color:var(--muted); font-size:10px; }
552
+ .dt-geometry-json { width:100%; min-height:100px; resize:vertical; background:var(--paper); color:var(--ink); border:1px solid var(--border); font:11px/1.5 ui-monospace,monospace; padding:6px; }
553
+ .dt-vertex { position:absolute; width:10px; height:10px; border:2px solid #1a73e8; background:#fff; transform:translate(-50%,-50%); pointer-events:auto; padding:0; min-height:0; border-radius:1px; cursor:move; touch-action:none; }
554
+ .dt-geometry-handles { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:hidden; }
555
+ .dt-performance { padding:10px; }.dt-performance svg { width:100%; height:90px; display:block; }.dt-performance dl { max-width:600px; }
556
+ .dt-overdraw-help { max-width:640px; margin:10px 10px 0; padding:10px 12px; border:1px solid var(--border); border-left:3px solid var(--accent); border-radius:4px; background:var(--surface); }
557
+ .dt-overdraw-help[hidden] { display:none; }
558
+ .dt-overdraw-help strong { font-size:12px; }.dt-overdraw-help p { margin:5px 0 0; line-height:1.5; }
559
+ .dt-overdraw-legend { display:flex; flex-wrap:wrap; gap:6px 20px; margin:10px 0 8px; }
560
+ .dt-overdraw-key { display:inline-flex; align-items:center; gap:6px; font:11px/1.4 ui-monospace,monospace; }
561
+ .dt-overdraw-swatch { width:24px; height:10px; border:1px solid #0002; border-radius:2px; }
562
+ .dt-overdraw-caption { color:var(--muted); font-size:11px; }
563
+ .dt-badge { background:var(--dt-selected); color:var(--accent); border-radius:7px; padding:0 5px; margin-left:3px; font:10px/1.5 Arial,sans-serif; }
564
+ [data-dock=left] .dt-inspect-grid,[data-dock=right] .dt-inspect-grid { display:flex; flex-direction:column; }
565
+ [data-dock=left] .dt-tree-column,[data-dock=right] .dt-tree-column { flex:0 0 32%; border-right:0; border-bottom:1px solid var(--border); }
566
+ [data-dock=left] .dt-details,[data-dock=right] .dt-details { flex:1; }
567
+ .dt-zoom { padding:8px; }.dt-zoom > select { width:100%; margin-bottom:8px; }
568
+ @container(max-width:680px) { .dt-inspect-grid { display:flex;flex-direction:column; }.dt-tree-column { flex:0 0 32%;border-right:0;border-bottom:1px solid var(--border); }.dt-details { flex:1; } }
569
+ `;
570
+ var devtoolsStyles = legacyStyles + inspectorStyles;
571
+
572
+ // src/workbench-client.ts
573
+ var workbenchClient = String.raw`
574
+ let wbCatalog;
575
+ let wbLastContext = "";
576
+ let wbInterval;
577
+ let wbRefreshing = false;
578
+ let wbError = "";
579
+ let wbLastPublished = 0;
580
+ let wbPickerKey = "";
581
+ let wbTimeOfDay = "";
582
+ const wbPaletteColors = new Map();
583
+ const wbClient = "view-" + Math.random().toString(36).slice(2);
584
+ const wb = (id) => document.getElementById("wb-" + id);
585
+ if (options.workbenchUrl) initializeWorkbench();
586
+
587
+ function initializeWorkbench() {
588
+ wb("theme").addEventListener("change", () => selectWorkbenchAppearance());
589
+ for (const [moment, label] of Object.entries({day:"Day", dawn:"Dawn", dusk:"Dusk", night:"Night"})) {
590
+ const segment = document.createElement("button");
591
+ segment.type = "button"; segment.value = moment; segment.textContent = label;
592
+ segment.setAttribute("role", "radio"); segment.setAttribute("aria-checked", "false");
593
+ segment.tabIndex = -1; segment.disabled = true;
594
+ segment.addEventListener("click", () => selectWorkbenchAppearance(moment));
595
+ segment.addEventListener("keydown", (event) => {
596
+ const direction = {ArrowRight:1, ArrowDown:1, ArrowLeft:-1, ArrowUp:-1}[event.key];
597
+ if (!direction && event.key !== "Home" && event.key !== "End") return;
598
+ event.preventDefault();
599
+ const enabled = [...wb("time-of-day").querySelectorAll("button")].filter((item) => !item.disabled);
600
+ if (!enabled.length) return;
601
+ const index = event.key === "Home" ? 0 : event.key === "End" ? enabled.length - 1 : (enabled.indexOf(segment) + direction + enabled.length) % enabled.length;
602
+ const next = enabled[index];
603
+ selectWorkbenchAppearance(next.value); next.focus();
604
+ });
605
+ wb("time-of-day").append(segment);
606
+ }
607
+ void refreshWorkbench();
608
+ wbInterval = setInterval(refreshWorkbench, 2500);
609
+ addEventListener("beforeunload", () => clearInterval(wbInterval));
610
+ addEventListener("keydown", (event) => {
611
+ if (event.target.closest?.("input,select,textarea,[contenteditable]")) return;
612
+ if (event.key === "i" && !event.metaKey && !event.ctrlKey) setInspectionEnabled(!inspectionEnabled, true);
613
+ });
614
+ }
615
+
616
+ async function workbenchRequest(path, body) {
617
+ const response = await fetch(options.workbenchUrl + path, body === undefined
618
+ ? {cache:"no-store"}
619
+ : {method:"POST", headers:{"Content-Type":"application/json", "X-Tileflow-Workbench":"1"}, body:JSON.stringify(body)});
620
+ const result = await response.json();
621
+ if (!response.ok) throw new Error(result.message || "Map DevTools request failed.");
622
+ return result;
623
+ }
624
+
625
+ async function refreshWorkbench() {
626
+ if (wbRefreshing) return;
627
+ wbRefreshing = true;
628
+ try {
629
+ wbCatalog = await workbenchRequest("");
630
+ const side = states[activeSideId];
631
+ const owner = wbCatalog.sides[side.serviceId || side.id];
632
+ resolveWorkbenchAppearance(side, owner);
633
+ if (side.bridgeConnected && side.workbenchMap && side.workbenchTheme && !side.workbenchRestored) {
634
+ side.workbenchRestored = true;
635
+ if (side.id === "left") {
636
+ const query = new URL(location.href).searchParams;
637
+ const requested = owner?.selections.find((entry) => !entry.scene && entry.map === side.workbenchMap && entry.id === query.get("view"));
638
+ if (requested) {
639
+ side.requestedTimeOfDay = query.get("time-of-day");
640
+ applyWorkbenchAppearance(side, requested);
641
+ }
642
+ }
643
+ }
644
+ syncWorkbenchPickers();
645
+ const failure = side.runtimeFailed ? side.runtimeErrors.at(-1) : owner?.status === "invalid" ? owner.diagnostics.map((item) => item.message).join(" ") : wbError;
646
+ wb("notice").hidden = !failure;
647
+ wb("notice").textContent = failure ? (owner?.status === "invalid" ? "Showing the last available preview. " : "") + failure : "";
648
+ await publishWorkbenchContext();
649
+ } catch (error) { showWorkbenchError(error); }
650
+ finally { wbRefreshing = false; }
651
+ }
652
+
653
+ function resolveWorkbenchAppearance(side, owner) {
654
+ const source = new URL(side.definition.previewUrl, location.href);
655
+ const startup = owner?.selections.find((entry) => entry.scene && entry.scene === source.searchParams.get("scene"));
656
+ const metadata = side.bridgeConnected ? side.map?.getStyle?.()?.metadata : undefined;
657
+ // The command chooses the map. Never infer it from catalog order or a saved URL's view.
658
+ side.workbenchMap ||= source.searchParams.get("map") || startup?.map || metadata?.["tileflow:map"];
659
+ if (!side.pendingAppearance && metadata?.["tileflow:map"] === side.workbenchMap) side.workbenchTheme = metadata?.["tileflow:theme"] || side.workbenchTheme;
660
+ side.workbenchTheme ||= source.searchParams.get("theme") || startup?.theme;
661
+ }
662
+
663
+ function applyWorkbenchAppearance(side, selected) {
664
+ if (selected.map !== side.workbenchMap || selected.scene) return;
665
+ const changed = selected.theme !== side.workbenchTheme;
666
+ side.workbenchTheme = selected.theme;
667
+ if (changed || side.pendingAppearance) {
668
+ side.pendingAppearance = selected;
669
+ side.definition.label = selected.label;
670
+ scheduleMapControls();
671
+ void loadMapControls();
672
+ }
673
+ syncWorkbenchPickers();
674
+ writeUrlState();
675
+ if (side.id === "left") {
676
+ const url = new URL(location.href);
677
+ url.searchParams.set("view", selected.id);
678
+ const moment = wbTimeOfDay;
679
+ if (moment) url.searchParams.set("time-of-day", moment); else url.searchParams.delete("time-of-day");
680
+ history.replaceState(null, "", url);
681
+ }
682
+ }
683
+
684
+ function syncWorkbenchPickers() {
685
+ const side = states[activeSideId];
686
+ const owner = wbCatalog?.sides[side.serviceId || side.id];
687
+ const selections = owner?.selections.filter((entry) => entry.map === side.workbenchMap) || [];
688
+ const selectedTheme = side.workbenchTheme;
689
+ if (!side.camera || !selections.length || !selectedTheme) {
690
+ wbPickerKey = "";
691
+ wb("theme").replaceChildren(new Option("Waiting for map…", ""));
692
+ wb("theme").disabled = true;
693
+ wb("time-of-day-field").hidden = true;
694
+ wbTimeOfDay = "";
695
+ return;
696
+ }
697
+ const variants = owner?.timeOfDay?.[side.workbenchMap] || {};
698
+ const key = JSON.stringify([activeSideId, selectedTheme, selections, variants, side.requestedTimeOfDay]);
699
+ if (key === wbPickerKey) return;
700
+ wbPickerKey = key;
701
+ const allThemes = [...new Set(selections.map((entry) => entry.theme))];
702
+ const family = Object.keys(variants).find((name) => Object.values(variants[name]).includes(selectedTheme));
703
+ const members = new Set(Object.values(variants).flatMap((moments) => Object.values(moments)));
704
+ const themes = allThemes.filter((name) => !members.has(name) || Object.hasOwn(variants, name));
705
+ const moments = family ? variants[family] : {};
706
+ wbTimeOfDay = moments[side.requestedTimeOfDay] === selectedTheme ? side.requestedTimeOfDay : Object.keys(moments).find((moment) => moments[moment] === selectedTheme) || "";
707
+ for (const segment of wb("time-of-day").querySelectorAll("button")) {
708
+ const selected = segment.value === wbTimeOfDay;
709
+ segment.setAttribute("aria-checked", String(selected));
710
+ segment.tabIndex = selected ? 0 : -1;
711
+ segment.disabled = !moments[segment.value];
712
+ }
713
+ wb("time-of-day-field").hidden = !family;
714
+ wb("theme").replaceChildren(...themes.map((theme) => new Option(theme, theme)));
715
+ wb("theme").value = family || selectedTheme;
716
+ wb("theme").disabled = themes.length < 2;
717
+ wb("palettes").replaceChildren(...themes.map(theme => {
718
+ const button=document.createElement("button");button.type="button";button.setAttribute("role","radio");button.setAttribute("aria-checked",String(theme===(family||selectedTheme)));
719
+ const swatches=document.createElement("span");swatches.className="dt-palette-swatches";
720
+ const label=document.createElement("span");label.textContent=theme.replace(/[-_]/g," ");button.append(swatches,label);
721
+ button.onclick=()=>{wb("theme").value=theme;selectWorkbenchAppearance();};
722
+ const concrete=variants[theme]?.[wbTimeOfDay] || variants[theme]?.day || theme;
723
+ const fill=colors=>{if(!button.isConnected)return;swatches.replaceChildren(...colors.map(color=>{const swatch=document.createElement("i");swatch.style.background=color;return swatch;}));};
724
+ if(wbPaletteColors.has(concrete))queueMicrotask(()=>fill(wbPaletteColors.get(concrete)));
725
+ else {
726
+ const url=new URL(side.definition.basePath+"/styles/"+side.workbenchMap+"/"+concrete+".json",location.href);
727
+ fetch(url).then(response=>{if(!response.ok)throw new Error("Palette unavailable");return response.json();}).then(style=>{
728
+ const choices=[/background/,/water/,/land.*(?:park|vegetation)|vegetation/,/building/];
729
+ const colors=choices.map(pattern=>style.layers.flatMap(layer=>pattern.test(layer.id)||pattern.test(layer.type)?Object.entries(layer.paint||{}).filter(([key,value])=>key.endsWith("-color")&&typeof value==="string").map(([,value])=>value):[])[0]).filter(Boolean);
730
+ wbPaletteColors.set(concrete,colors);fill(colors);
731
+ }).catch(()=>{swatches.textContent="—";});
732
+ }
733
+ return button;
734
+ }));
735
+ }
736
+
737
+ function selectWorkbenchAppearance(requested = wbTimeOfDay) {
738
+ const side = states[activeSideId];
739
+ const owner = wbCatalog?.sides[side.serviceId || side.id];
740
+ const moments = owner?.timeOfDay?.[side.workbenchMap]?.[wb("theme").value];
741
+ const moment = moments?.[requested] ? requested : moments ? Object.keys(moments)[0] : undefined;
742
+ const theme = moments?.[moment] || wb("theme").value;
743
+ const selected = owner?.selections.find((entry) => !entry.scene && entry.map === side.workbenchMap && entry.theme === theme);
744
+ if (!selected) { wbPickerKey = ""; syncWorkbenchPickers(); return; }
745
+ side.requestedTimeOfDay = moment;
746
+ applyWorkbenchAppearance(side, selected);
747
+ }
748
+
749
+ function currentWorkbenchDefinition() {
750
+ const side = states[activeSideId];
751
+ const camera = side.camera || canonicalCamera;
752
+ const metadata = side.map?.getStyle?.().metadata;
753
+ if (!camera || !validPortableName(metadata?.["tileflow:map"]) || !validConcreteThemeName(metadata?.["tileflow:theme"]))
754
+ throw new Error("Wait for the current map to finish loading.");
755
+ const container = side.map?.getContainer?.();
756
+ const width = boundedInteger(container?.clientWidth, 64, 4096, 1280);
757
+ const height = boundedInteger(container?.clientHeight, 64, 4096, 800);
758
+ return {map:metadata["tileflow:map"], theme:metadata["tileflow:theme"],
759
+ camera:{type:"center", center:[roundNumber(camera.center[0]),roundNumber(camera.center[1])], zoom:roundNumber(camera.zoom), bearing:roundNumber(camera.bearing), pitch:roundNumber(camera.pitch)},
760
+ viewport:{width,height,dpr:1}, target:{kind:"map"}};
761
+ }
762
+
763
+ function workbenchContext() {
764
+ const side = states[activeSideId];
765
+ const layerObservation = globalThis.__tileflowDevtoolsUI?.layerContext?.();
766
+ const selected = !layerObservation && selectedInspection?.side === side ? selectedInspection : undefined;
767
+ const feature = selected?.features[selected.featureIndex];
768
+ const layer = feature && side.map?.getStyle?.().layers?.find((entry) => entry.id === feature.layerId);
769
+ const provenance = feature && findSidecarLayer(side.sidecar, feature.layerId);
770
+ let definition;
771
+ try { definition = currentWorkbenchDefinition(); } catch { /* Loading previews have no camera yet. */ }
772
+ const configuration = side.definition.captureConfig ? " --config " + quoteCliArgument(side.definition.captureConfig) : "";
773
+ const semanticOwner = provenance && sidecarLayerWithSemanticAttribution(provenance).semanticAttribution[0]?.semanticOwner;
774
+ const captureCommand = definition ? "tileflow capture" + configuration + " --map " + quoteCliArgument(definition.map) + " --theme " + quoteCliArgument(definition.theme) +
775
+ " --center=" + definition.camera.center.join(",") + " --zoom=" + definition.camera.zoom + " --bearing=" + definition.camera.bearing + " --pitch=" + definition.camera.pitch +
776
+ " --width=" + definition.viewport.width + " --height=" + definition.viewport.height + " --dpr=" + definition.viewport.dpr + " --json" : undefined;
777
+ return {schemaVersion:1, source:"browser-observation", config:side.definition.captureConfig || "tileflow.config.ts",
778
+ status:side.artifactStatus, generation:side.artifactGeneration, inspectionEnabled,
779
+ displayedGeneration:Number(side.frame.contentDocument?.documentElement.dataset.tileflowArtifactGeneration), definition,
780
+ captureSemantics:"Compiled map at the observed camera and viewport; temporary preview edits are excluded.",
781
+ review:{referenceId:null, before:null, after:null, liveMode:stage.dataset.mode, source:"map"},
782
+ commands:{inspect:"tileflow inspect" + configuration + (semanticOwner ? " --domain " + quoteCliArgument(semanticOwner) : " --limit 80") + " --json", capture:captureCommand,
783
+ session:"tileflow inspect session --url " + quoteCliArgument(location.origin) + " --json"},
784
+ selection:layerObservation || (feature ? {coordinate:selected.coordinate,
785
+ provenance:provenance ? sidecarLayerWithSemanticAttribution(provenance).semanticAttribution : [],
786
+ provenanceScope:"Semantic ownership only; exact file, line and token are not inferred.",
787
+ feature, layer:layer ? {id:layer.id,type:layer.type,source:layer.source,sourceLayer:layer["source-layer"],minzoom:layer.minzoom,maxzoom:layer.maxzoom,filter:layer.filter,paint:layer.paint,layout:layer.layout} : undefined} : null)};
788
+ }
789
+
790
+ function boundedWorkbenchContext() {
791
+ const observed = workbenchContext();
792
+ const nodeLimit = observed.selection?.kind === "layer" ? 2400 : 200;
793
+ let entries = 0;
794
+ const omitted = [];
795
+ function visit(value, path, depth) {
796
+ if (++entries > nodeLimit || depth > 16) { omitted.push(path); return undefined; }
797
+ if (typeof value === "string") { if (value.length > 512) omitted.push(path); return value.slice(0,512); }
798
+ if (!value || typeof value !== "object") return value;
799
+ if (Array.isArray(value)) {
800
+ if (value.length > 32) omitted.push(path);
801
+ return value.slice(0,32).map((entry,index) => visit(entry,path + "/" + index,depth + 1));
802
+ }
803
+ return Object.fromEntries(Object.entries(value).flatMap(([key,entry]) => {
804
+ const result = visit(entry,path + "/" + key.replaceAll("~","~0").replaceAll("/","~1"),depth + 1);
805
+ return result === undefined ? [] : [[key,result]];
806
+ }));
807
+ }
808
+ const context = visit(observed,"",0);
809
+ context.projection = {truncated:omitted.length > 0, omitted:omitted.slice(0,32)};
810
+ return context;
811
+ }
812
+
813
+ async function publishWorkbenchContext() {
814
+ const side = states[activeSideId];
815
+ if (!side.bridgeReady) return;
816
+ const context = boundedWorkbenchContext();
817
+ const key = JSON.stringify(context);
818
+ if (key === wbLastContext && Date.now() - wbLastPublished < 60_000) return;
819
+ await workbenchRequest("/context", {client:wbClient, side:side.serviceId || side.id, generation:side.artifactGeneration, context});
820
+ wbLastContext = key;
821
+ wbLastPublished = Date.now();
822
+ }
823
+
824
+ function featureDisplayName(feature) {
825
+ return String(feature.properties?.name || feature.properties?.class || feature.layerId.replace(/^tileflow-/, "").replace(/-render-/, " · ") || "Map element")
826
+ .replace(/([a-z])([A-Z])/g, "$1 $2").replaceAll("_", " ").replaceAll("-", " ").replace(/^./, (letter) => letter.toUpperCase());
827
+ }
828
+
829
+ function renderFeatureSummary(side, feature, layer, picker, raw) {
830
+ const section = document.createElement("section"); section.className = "feature-summary";
831
+ const heading = document.createElement("h3");
832
+ heading.textContent = featureDisplayName(feature);
833
+ const kind = document.createElement("div"); kind.className = "compact-note";
834
+ const kinds = {fill:"Area", line:"Line", symbol:"Label or icon", "fill-extrusion":"3D building", circle:"Point", raster:"Imagery", hillshade:"Terrain shading", background:"Background"};
835
+ kind.textContent = (kinds[layer?.type] || layer?.type || feature.geometryType) + " · Zoom " + (side.camera?.zoom ?? 0).toFixed(2);
836
+ const headingStyle = document.createElement("h4"); headingStyle.textContent = "Appearance here";
837
+ section.append(heading, kind, headingStyle);
838
+ const values = document.createElement("dl"); values.className = "property-list";
839
+ const labels = {"fill-color":"Fill color", "fill-opacity":"Opacity", "fill-outline-color":"Outline", "line-color":"Line color", "line-width":"Width", "line-opacity":"Opacity", "line-gap-width":"Gap", "text-color":"Text color", "text-halo-color":"Text outline", "text-halo-width":"Outline width", "text-opacity":"Text opacity", "text-size":"Text size", "icon-color":"Icon color", "icon-opacity":"Icon opacity", "fill-extrusion-color":"Building color", "fill-extrusion-height":"Height", "fill-extrusion-opacity":"Opacity"};
840
+ for (const [name, rawValue] of Object.entries({...layer?.paint, ...(layer?.layout?.["text-size"] !== undefined ? {"text-size":layer.layout["text-size"]} : {})}).slice(0,8)) {
841
+ const value = evaluateBasicZoomValue(rawValue, side.camera?.zoom ?? 0);
842
+ const label = document.createElement("dt"); label.textContent = labels[name] || name.replaceAll("-", " "); label.title = name;
843
+ const detail = document.createElement("dd"); detail.textContent = value === unsupportedValue ? "Varies by feature" : formatCurveValue(value);
844
+ if (typeof value === "string" && /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\()/i.test(value)) {
845
+ const swatch = document.createElement("span"); swatch.className = "color-swatch"; swatch.style.backgroundColor = value; swatch.setAttribute("aria-hidden", "true"); detail.append(swatch);
846
+ }
847
+ values.append(label,detail);
848
+ }
849
+ section.append(values);
850
+ const provenance = findSidecarLayer(side.sidecar, feature.layerId);
851
+ const owners = provenance ? sidecarLayerWithSemanticAttribution(provenance).semanticAttribution : [];
852
+ const label = document.createElement("h4"); label.textContent = "Where to edit";
853
+ section.append(label);
854
+ const paths = [...new Set(owners.flatMap((owner) => owner.authoringPaths))];
855
+ for (const path of paths.slice(0,8)) { const code = document.createElement("code"); code.className = "authoring-path"; code.textContent = path; section.append(code); }
856
+ const note = document.createElement("p"); note.className = "compact-note";
857
+ note.textContent = paths.length ? "Edit these configuration sections in your editor or ask your coding agent. The map updates here." : "No semantic configuration path is available for this layer.";
858
+ section.append(note);
859
+ const details = document.createElement("details"); const summary = document.createElement("summary"); summary.textContent = "Feature data";
860
+ const provenanceNote = document.createElement("p"); provenanceNote.className = "compact-note"; provenanceNote.textContent = "Configuration paths describe semantic ownership. Exact source lines and tokens are not reported.";
861
+ details.append(summary,provenanceNote,raw);
862
+ inspectionOutput.append(section,picker,details);
863
+ }
864
+
865
+ function showWorkbenchError(error) {
866
+ const message = safeErrorMessage(error);
867
+ wbError = message;
868
+ setFeedback(message);
869
+ wb("notice").textContent = message; wb("notice").hidden = false;
870
+ }
871
+ `;
872
+
873
+ // src/workbench-controls.ts
874
+ var controlsStyles = `
875
+ .map-controls { flex:0 0 360px; width:360px; max-width:45vw; min-height:0; overflow:auto; border-left:1px solid var(--border); background:var(--paper); padding:0 18px 18px; }
876
+ .map-controls[hidden] { display:none; }
877
+ .controls-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:8px 0; border-bottom:1px solid var(--border); }
878
+ .controls-heading h2 { margin:0; font:inherit; }
879
+ .controls-segments { display:flex; min-width:0; width:max-content; max-width:100%; border:1px solid var(--border); border-radius:4px; overflow:hidden; }
880
+ .controls-segments button { flex:1; min-width:0; padding:4px 10px; border:0; border-radius:0; background:var(--paper); color:var(--muted); cursor:pointer; }
881
+ .controls-segments button + button { border-left:1px solid var(--border); }
882
+ .controls-segments button[aria-checked=true] { color:var(--accent); background:var(--dt-selected,var(--surface)); box-shadow:inset 0 -2px var(--accent); }
883
+ .controls-segments button:disabled { opacity:.4; cursor:not-allowed; }
884
+ .controls-selection { border-bottom:1px solid var(--border); padding-bottom:8px; }
885
+ .controls-selection .control-field select { width:190px; max-width:70%; }
886
+ .controls-selection [hidden] { display:none; }
887
+ .controls-section-title { font-size:10px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin:20px 0 9px; }
888
+ .control-module { border:1px solid var(--border); border-radius:8px; margin:7px 0; background:#fffdf7; overflow:hidden; }
889
+ .control-module-header { display:flex; align-items:center; justify-content:space-between; padding:10px 12px; gap:8px; }
890
+ .control-module-header label { display:flex; align-items:center; justify-content:space-between; flex:1; flex-direction:row; gap:10px; font-size:13px; font-weight:600; cursor:pointer; }
891
+ .control-module-header input { appearance:none; width:30px; height:18px; min-height:0; padding:0; border:0; border-radius:12px; background:#d5cec1; cursor:pointer; position:relative; flex:none; }
892
+ .control-module-header input::before { content:''; position:absolute; width:12px; height:12px; top:3px; left:3px; border-radius:50%; background:#fff; }
893
+ .control-module-header input:checked { background:var(--accent); }
894
+ .control-module-header input:checked::before { transform:translateX(12px); }
895
+ .control-module-header input:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
896
+ .control-module summary { cursor:pointer; padding:0 12px 10px; font-size:11px; color:var(--muted); }
897
+ .control-module fieldset { border:0; padding:0 12px 12px; margin:0; }
898
+ .control-module fieldset:disabled { opacity:.45; }
899
+ .control-field { display:flex; align-items:center; justify-content:space-between; flex-direction:row; gap:12px; font-size:12px; margin:9px 0; }
900
+ .control-field select,.control-field input[type=number] { width:132px; min-width:0; padding:5px 7px; font:inherit; }
901
+ .control-field input[type=checkbox] { accent-color:var(--accent); }
902
+ .controls-runtime { border:0; border-bottom:1px solid var(--border); padding:0 0 12px; margin:0; min-width:0; }
903
+ #controls-modules { border:0; padding:0; margin:0; min-width:0; }
904
+ .controls-runtime:disabled,#controls-modules:disabled { opacity:.6; }
905
+ #controls-status { font-size:12px; line-height:1.5; color:var(--muted); margin:8px 0; overflow-wrap:anywhere; }
906
+ #controls-status[data-error=true] { color:#a32e36; }
907
+ @media(max-width:760px) { .workspace:has(.map-controls:not([hidden])) { display:flex; flex-direction:column; } .map-controls { flex:0 1 55vh; width:auto; max-width:none; border-left:0; border-top:1px solid var(--border); } .workspace:has(.map-controls:not([hidden])) .stage { flex:1 0 35vh; } }
908
+ `;
909
+ var controlsPanel = `
910
+ <aside class="map-controls" id="map-controls" aria-label="Scene settings" tabindex="-1" hidden>
911
+ <div class="controls-heading"><h2>Styles</h2><button id="controls-reset">Reset</button></div>
912
+ <div class="controls-grid">
913
+ <div class="controls-selection workbench-only" id="controls-selection" hidden>
914
+ <div class="controls-section-title">Appearance</div>
915
+ <div class="control-field" id="wb-time-of-day-field" hidden><span>Time of day</span><div class="controls-segments" id="wb-time-of-day" role="radiogroup" aria-label="Time of day"></div></div>
916
+ <div class="control-field dt-palette-field" id="wb-theme-field"><span>Theme</span><select id="wb-theme" aria-label="Palette" disabled hidden><option value="">Waiting for map\u2026</option></select><div id="wb-palettes" class="dt-palettes" role="radiogroup" aria-label="Theme palette"></div></div>
917
+ </div>
918
+ <fieldset class="controls-runtime" id="controls-runtime">
919
+ <div class="controls-section-title">Preview rendering</div>
920
+ <label class="control-field">3D view<input id="controls-3d" type="checkbox" role="switch"></label>
921
+ <label class="control-field">Building animation<select id="controls-animation" hidden><option value="0">Off</option><option value="800">Grow</option><option value="fade">Fade</option></select><span class="controls-segments" id="controls-animation-segments" role="radiogroup" aria-label="Building animation"></span></label>
922
+ </fieldset>
923
+ </div>
924
+ <div class="controls-section-title">Map modules</div>
925
+ <fieldset id="controls-modules"></fieldset>
926
+ <p id="controls-status" role="status" hidden></p>
927
+ </aside>
928
+ `;
929
+ var controlsClient = String.raw`
930
+ const ctl = (id) => document.getElementById("controls-" + id);
931
+ const controlsPanel = document.getElementById("map-controls");
932
+ const controlsButton = document.getElementById("controls-toggle");
933
+ let controlsKey = "", controlsSequence = 0, controlsModel, controlsDraft = {}, controlsBusy = false;
934
+ let controlsApplyTimer, controlsRevision = 0, controlsPending = false, controlsResetPending = false;
935
+ let controlsLoadingKey = "", controlsBindings = [], controlsLastError = "";
936
+ let controlsHistoryCurrent, controlsInitial, controlsHistoryQuiet = 0;
937
+ const controlsUndo = [], controlsRedo = [];
938
+ ctl("selection").hidden = !options.workbenchUrl;
939
+ controlsButton.addEventListener("click", () => toggleMapControls(controlsPanel.hidden, true));
940
+ ctl("reset").addEventListener("click", () => {
941
+ if (!controlsModel) return;
942
+ controlsDraft = {};
943
+ ctl("3d").checked = false; ctl("animation").value = "0";
944
+ renderMapControls();
945
+ scheduleMapControls(true);
946
+ });
947
+ for (const name of ["3d", "animation"]) ctl(name).addEventListener("change", () => { syncAnimationSegments(); scheduleMapControls(); });
948
+ ctl("3d").addEventListener("change", () => {
949
+ const map = states[activeSideId].map;
950
+ if (map) map.easeTo({pitch:ctl("3d").checked ? Math.min(60,map.getMaxPitch()) : 0, duration:matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 350}, {tileflowDevtools:true});
951
+ });
952
+ for (const [value,label] of [["0","Off"],["800","Grow"],["fade","Fade"]]) {
953
+ const button=document.createElement("button"); button.type="button";button.value=value;button.textContent=label;button.setAttribute("role","radio");
954
+ button.addEventListener("click",()=>{ctl("animation").value=value;ctl("animation").dispatchEvent(new Event("change"));});ctl("animation-segments").append(button);
955
+ }
956
+ function syncAnimationSegments(){for(const button of ctl("animation-segments").children)button.setAttribute("aria-checked",String(button.value===ctl("animation").value));}
957
+ syncAnimationSegments();
958
+ addEventListener("keydown", (event) => { if (event.key === "Escape" && !controlsPanel.hidden) toggleMapControls(false, true); });
959
+ const controlsTimer = setInterval(() => { if (!controlsPanel.hidden) void loadMapControls(); }, 1000);
960
+ addEventListener("beforeunload", () => { clearInterval(controlsTimer); cancelMapControlsApply(); });
961
+
962
+ function cancelMapControlsApply() {
963
+ clearTimeout(controlsApplyTimer); controlsApplyTimer = undefined;
964
+ controlsPending = false; controlsResetPending = false; controlsRevision++;
965
+ }
966
+ function setMapControlsDisabled(disabled) {
967
+ for (const name of ["modules", "runtime", "reset"]) ctl(name).disabled = disabled;
968
+ }
969
+ function scheduleMapControls(reset = false) {
970
+ clearTimeout(controlsApplyTimer);
971
+ controlsRevision++; controlsPending = true; controlsResetPending = reset;
972
+ controlsNotice(reset ? "Restoring the original map…" : "Updating preview…");
973
+ controlsApplyTimer = setTimeout(() => {
974
+ controlsApplyTimer = undefined;
975
+ void applyMapControls();
976
+ }, reset ? 0 : 300);
977
+ }
978
+
979
+ function toggleMapControls(open, focus = false) {
980
+ if (globalThis.__tileflowDevtoolsUI) { globalThis.__tileflowDevtoolsUI.controls(open); return; }
981
+ if (open) {
982
+ setInspectionEnabled(false);
983
+ }
984
+ controlsPanel.hidden = !open;
985
+ controlsButton.setAttribute("aria-expanded", String(open));
986
+ for (const side of Object.values(states)) side.map?.resize?.();
987
+ if (open) void loadMapControls();
988
+ if (focus) (open ? controlsPanel : controlsButton).focus();
989
+ }
990
+ function controlsNotice(text, error = false) {
991
+ ctl("status").hidden = !error;
992
+ ctl("status").textContent = error ? text : "";
993
+ ctl("status").dataset.error = String(error);
994
+ }
995
+ function controlsIdentity(side) {
996
+ const meta = side.map?.getStyle?.()?.metadata;
997
+ return meta ? {map:meta["tileflow:map"], theme:meta["tileflow:theme"]} : undefined;
998
+ }
999
+ function livePreviewUrl(side) {
1000
+ return new URL(side.frame.contentWindow.location.href, location.href);
1001
+ }
1002
+ async function loadMapControls() {
1003
+ const side = states[activeSideId], identity = controlsIdentity(side);
1004
+ if (!side.bridgeConnected || !identity?.map || !identity.theme) {
1005
+ setMapControlsDisabled(!side.bridgeConnected || !controlsModel);
1006
+ controlsNotice("Waiting for the map to finish loading…");
1007
+ return;
1008
+ }
1009
+ identity.theme = side.workbenchTheme || identity.theme;
1010
+ const key = JSON.stringify([side.id, side.definition.basePath, identity.map, side.loadEpoch]);
1011
+ const epoch = side.loadEpoch;
1012
+ const loadingKey = key + identity.theme;
1013
+ if (key === controlsKey && controlsModel?.theme === identity.theme) {
1014
+ setMapControlsDisabled(false);
1015
+ return;
1016
+ }
1017
+ if (loadingKey === controlsLoadingKey) return;
1018
+ const preserve = key === controlsKey && controlsModel;
1019
+ if (!preserve) {
1020
+ const pendingAppearance = side.pendingAppearance;
1021
+ cancelMapControlsApply();
1022
+ controlsPending = Boolean(pendingAppearance);
1023
+ controlsKey = key;
1024
+ controlsModel = undefined;
1025
+ ctl("modules").replaceChildren(); setMapControlsDisabled(true);
1026
+ }
1027
+ controlsLoadingKey = loadingKey;
1028
+ const sequence = ++controlsSequence;
1029
+ controlsNotice("Reading this map’s modules…");
1030
+ try {
1031
+ const url = new URL(side.definition.basePath + "/__controls", location.href);
1032
+ url.search = new URLSearchParams(identity).toString();
1033
+ const response = await fetch(url, {cache:"no-store"});
1034
+ const model = await response.json();
1035
+ if (!response.ok) throw new Error(model.error || "Controls are unavailable for this preview.");
1036
+ if (sequence !== controlsSequence || activeSideId !== side.id || key !== controlsKey || epoch !== side.loadEpoch || identity.theme !== (side.workbenchTheme || controlsIdentity(side)?.theme)) return;
1037
+ if (preserve) {
1038
+ // Keep the actual inputs and their handlers mounted, including focus/selection.
1039
+ for (const module of controlsModel.modules) {
1040
+ const updated = model.modules.find((item) => item.id === module.id);
1041
+ if (!updated) continue;
1042
+ module.enabled = updated.enabled;
1043
+ for (const field of module.fields) Object.assign(field, updated.fields.find((item) => item.path === field.path));
1044
+ }
1045
+ controlsModel.theme = model.theme;
1046
+ for (const sync of controlsBindings) sync();
1047
+ } else {
1048
+ controlsModel = {...model, side, key, epoch};
1049
+ const query = livePreviewUrl(side).searchParams;
1050
+ controlsDraft = JSON.parse(query.get("controls") || "{}");
1051
+ side.appliedSceneStyle ||= JSON.stringify([identity.map, controlsIdentity(side).theme, controlsDraft]);
1052
+ ctl("3d").checked = query.get("buildings3d") === "on";
1053
+ ctl("animation").value = query.get("animation") === "fade" ? "fade" : Number(query.get("animation")) > 0 ? "800" : "0"; syncAnimationSegments();
1054
+ renderMapControls();
1055
+ controlsUndo.length = controlsRedo.length = 0;
1056
+ controlsHistoryCurrent = globalThis.__tileflowScene.snapshot();
1057
+ controlsInitial = structuredClone(controlsHistoryCurrent);
1058
+ }
1059
+ setMapControlsDisabled(false);
1060
+ controlsNotice(Object.keys(controlsDraft).length ? "Preview overrides applied." : "Using the map’s original settings.");
1061
+ } catch (error) {
1062
+ if (sequence === controlsSequence) { if (!preserve) controlsKey = ""; controlsNotice(safeErrorMessage(error), true); }
1063
+ } finally {
1064
+ if (sequence === controlsSequence) {
1065
+ controlsLoadingKey = "";
1066
+ if (controlsPending && controlsApplyTimer === undefined) void applyMapControls();
1067
+ }
1068
+ }
1069
+ }
1070
+ function controlValue(object, path) { return path.split(".").reduce((value, key) => value?.[key], object); }
1071
+ function changeControl(module, path, value, original) {
1072
+ const patch = controlsDraft[module] ||= {};
1073
+ const parts = path.split("."); const leaf = parts.pop();
1074
+ let parent = patch;
1075
+ for (const part of parts) parent = parent[part] ||= {};
1076
+ if (value === original) delete parent[leaf]; else parent[leaf] = value;
1077
+ for (const [key, item] of Object.entries(patch)) if (item && typeof item === "object" && !Object.keys(item).length) delete patch[key];
1078
+ if (!Object.keys(patch).length) delete controlsDraft[module];
1079
+ scheduleMapControls();
1080
+ }
1081
+ function renderMapControls(expanded = [...ctl("modules").querySelectorAll("details[open]")].map((item) => item.dataset.module)) {
1082
+ ctl("modules").replaceChildren();
1083
+ controlsBindings = [];
1084
+ const names = {poi:"Points of interest", land:"Land", water:"Water", nautical:"Nautical", roads:"Roads", buildings:"Buildings", boundaries:"Boundaries", labels:"Labels", aeroways:"Airports", transit:"Transit", vegetation:"Vegetation", addresses:"Addresses", landforms:"Landforms"};
1085
+ const modules = [...controlsModel.modules].sort((a,b) => {
1086
+ const priority = {vegetation:0, buildings:1};
1087
+ return (priority[a.id] ?? 2) - (priority[b.id] ?? 2);
1088
+ });
1089
+ for (const module of modules) {
1090
+ const card = document.createElement("section"); card.className = "control-module";
1091
+ const row = document.createElement("div"); row.className = "control-module-header";
1092
+ const label = document.createElement("label"); label.textContent = names[module.id] || module.id;
1093
+ const toggle = document.createElement("input"); toggle.type = "checkbox"; toggle.checked = controlsDraft[module.id]?.enabled ?? module.enabled;
1094
+ toggle.setAttribute("role", "switch");
1095
+ toggle.setAttribute("aria-label", "Enable " + (names[module.id] || module.id));
1096
+ label.append(toggle); row.append(label); card.append(row);
1097
+ const fieldset = document.createElement("fieldset"); fieldset.disabled = !toggle.checked;
1098
+ controlsBindings.push(() => {
1099
+ if (document.activeElement !== toggle) toggle.checked = controlsDraft[module.id]?.enabled ?? module.enabled;
1100
+ fieldset.disabled = !toggle.checked;
1101
+ });
1102
+ toggle.addEventListener("change", () => { changeControl(module.id, "enabled", toggle.checked, module.enabled); fieldset.disabled = !toggle.checked; });
1103
+ if (module.fields.length) {
1104
+ const details = document.createElement("details");
1105
+ details.dataset.module = module.id; details.open = expanded.includes(module.id);
1106
+ const summary = document.createElement("summary"); summary.textContent = "Appearance & behavior";
1107
+ details.append(summary, fieldset); card.append(details);
1108
+ for (const field of module.fields) {
1109
+ const line = document.createElement("label"); line.className = "control-field";
1110
+ const title = document.createElement("span"); title.textContent = field.label;
1111
+ const input = document.createElement(field.choices ? "select" : "input");
1112
+ const override = controlValue(controlsDraft[module.id], field.path);
1113
+ const value = field.inherit && override === undefined ? "inherit" : override ?? field.value;
1114
+ const labels = {"3d":"Always 3D",auto:"Follow 2D / 3D view",flat:"Flat (2D)",none:"Hidden",style:"Map light",sun:"Scene sun",faceted:"Faceted",smooth:"Smooth",toon:"Toon"};
1115
+ if (field.choices) {
1116
+ const choices = field.choices.map((choice) => new Option(labels[choice] || choice, choice));
1117
+ if (field.inherit) choices.unshift(new Option("Auto · " + (labels[field.value] || field.value), "inherit"));
1118
+ input.replaceChildren(...choices); input.value = String(value);
1119
+ } else if (typeof field.value === "boolean") { input.type = "checkbox"; input.setAttribute("role", "switch"); input.checked = value; }
1120
+ else { input.type = "number"; input.required = true; input.min = String(field.min); input.max = String(field.max); input.step = "any"; input.value = String(value); }
1121
+ controlsBindings.push(() => {
1122
+ if (document.activeElement === input || !input.checkValidity()) return;
1123
+ const override = controlValue(controlsDraft[module.id], field.path);
1124
+ const value = field.inherit && override === undefined ? "inherit" : override ?? field.value;
1125
+ if (input.type === "checkbox") input.checked = value;
1126
+ else input.value = String(value);
1127
+ if (field.inherit) input.options[0].textContent = "Auto · " + (labels[field.value] || field.value);
1128
+ });
1129
+ input.addEventListener(input.type === "number" ? "input" : "change", () => {
1130
+ if (!input.checkValidity()) { cancelMapControlsApply(); controlsNotice("Enter a valid value to update the preview.", true); return; }
1131
+ const value = field.inherit && input.value === "inherit" ? undefined : input.type === "checkbox" ? input.checked : input.type === "number" ? Number(input.value) : input.value;
1132
+ changeControl(module.id, field.path, value, field.inherit ? undefined : field.value);
1133
+ });
1134
+ line.append(title, input); fieldset.append(line);
1135
+ }
1136
+ }
1137
+ ctl("modules").append(card);
1138
+ }
1139
+ }
1140
+ async function applyMapControls() {
1141
+ if (!controlsModel || controlsBusy || !controlsPending) return;
1142
+ const revision = controlsRevision, reset = controlsResetPending;
1143
+ const {side, map, key} = controlsModel;
1144
+ const theme = side.workbenchTheme || controlsModel.theme;
1145
+ const epoch = side.loadEpoch, source = side.frame.src;
1146
+ if (!side.bridgeConnected || side.id !== activeSideId || side.loadEpoch !== controlsModel.epoch) return;
1147
+ if (!reset && [...ctl("modules").querySelectorAll("input")].some((input) => !input.checkValidity())) {
1148
+ controlsPending = false;
1149
+ controlsNotice("Enter valid field values to update the preview.", true); return;
1150
+ }
1151
+ const draft = reset ? {} : structuredClone(controlsDraft);
1152
+ const next = {map, theme, controls:draft, buildings3d:!reset && ctl("3d").checked, animation:reset ? 0 : ctl("animation").value === "fade" ? "fade" : Number(ctl("animation").value)};
1153
+ const styleKey = JSON.stringify([map, theme, draft]);
1154
+ const isCurrent = () => revision === controlsRevision && controlsKey === key && side.id === activeSideId && side.bridgeConnected && epoch === side.loadEpoch && source === side.frame.src;
1155
+ const before = controlsHistoryCurrent && {...controlsHistoryCurrent, style:side.map.getStyle()};
1156
+ controlsBusy = true; controlsPending = false; controlsLastError = "";
1157
+ controlsNotice("Compiling preview…");
1158
+ try {
1159
+ if (styleKey !== side.appliedSceneStyle) {
1160
+ const style = new URL(side.definition.basePath + "/styles/" + map + "/" + theme + ".json", location.href);
1161
+ if (Object.keys(draft).length) style.searchParams.set("controls", JSON.stringify(draft));
1162
+ const response = await fetch(style, {cache:"no-store"});
1163
+ const document = await response.json();
1164
+ if (!response.ok) throw new Error(document.error || "Unable to compile these settings.");
1165
+ next.style = document;
1166
+ }
1167
+ if (!isCurrent()) return;
1168
+ const apply = side.frame.contentWindow.__tileflowApplyScene;
1169
+ if (!apply) throw new Error("Reload DevTools once to enable live scene updates.");
1170
+ if (!await apply(JSON.stringify(next), isCurrent)) return;
1171
+ side.appliedSceneStyle = styleKey;
1172
+ const after = globalThis.__tileflowScene.snapshot();
1173
+ if (!controlsHistoryQuiet && before && JSON.stringify([before.theme,before.controls,before.buildings3d,before.animation]) !== JSON.stringify([after.theme,after.controls,after.buildings3d,after.animation])) {
1174
+ controlsUndo.push({before,after,time:performance.now()}); if(controlsUndo.length>100)controlsUndo.shift(); controlsRedo.length=0;
1175
+ }
1176
+ controlsHistoryCurrent=after;
1177
+ side.pendingAppearance = undefined;
1178
+ side.definition.previewUrl = livePreviewUrl(side).href;
1179
+ side.sidecarAbort?.abort(); side.sidecar = undefined; side.sidecarPromise = undefined;
1180
+ if (selectedInspection?.side === side) void loadSidecar(side);
1181
+ if (isCurrent()) controlsNotice("");
1182
+ } catch (error) {
1183
+ if (revision === controlsRevision && controlsKey === key) { controlsLastError = safeErrorMessage(error); controlsNotice(controlsLastError + " The previous map is still shown.", true); }
1184
+ } finally {
1185
+ controlsBusy = false;
1186
+ if (controlsPending && controlsApplyTimer === undefined) void applyMapControls();
1187
+ }
1188
+ }
1189
+ globalThis.__tileflowScene = {
1190
+ get history() {return {undo:controlsUndo.at(-1)?.time || 0,redo:controlsRedo.at(-1)?.redoTime || 0,changed:controlsUndo.length>0||controlsRedo.length>0};},
1191
+ async undo() {const entry=controlsUndo.at(-1);if(!entry)return;await this.restore(entry.before);controlsUndo.pop();entry.redoTime=performance.now();controlsRedo.push(entry);},
1192
+ async redo() {const entry=controlsRedo.at(-1);if(!entry)return;await this.restore(entry.after);controlsRedo.pop();entry.time=performance.now();controlsUndo.push(entry);},
1193
+ async reset() {if(controlsInitial)await this.restore(controlsInitial);controlsUndo.length=controlsRedo.length=0;},
1194
+ audit(active) {controlsHistoryQuiet=Math.max(0,controlsHistoryQuiet+(active?1:-1));},
1195
+ variants() {
1196
+ const side = states[activeSideId], owner = wbCatalog?.sides[side.serviceId || side.id];
1197
+ const variants = owner?.timeOfDay?.[side.workbenchMap] || {};
1198
+ return (owner?.selections || []).filter(entry => entry.map === side.workbenchMap && !entry.scene).map(entry => {
1199
+ const family = Object.keys(variants).find(name => Object.values(variants[name]).includes(entry.theme));
1200
+ return {theme:entry.theme, palette:family || entry.theme, timeOfDay:family ? Object.keys(variants[family]).find(moment => variants[family][moment] === entry.theme) : undefined};
1201
+ });
1202
+ },
1203
+ snapshot() {
1204
+ const side = states[activeSideId];
1205
+ return {side:side.id, theme:side.workbenchTheme || controlsIdentity(side)?.theme, controls:structuredClone(controlsDraft), buildings3d:ctl("3d").checked, animation:ctl("animation").value, style:side.map?.getStyle()};
1206
+ },
1207
+ async style(theme, signal) {
1208
+ const side = states[activeSideId], identity = controlsIdentity(side);
1209
+ const url = new URL(side.definition.basePath + "/styles/" + identity.map + "/" + theme + ".json", location.href);
1210
+ if (Object.keys(controlsDraft).length) url.searchParams.set("controls", JSON.stringify(controlsDraft));
1211
+ const response = await fetch(url, {signal});
1212
+ if (!response.ok) throw new Error("Could not read theme " + theme);
1213
+ return response.json();
1214
+ },
1215
+ async apply(theme, signal) {
1216
+ if (signal?.aborted) throw new Error("Audit cancelled.");
1217
+ const side = states[activeSideId], owner = wbCatalog?.sides[side.serviceId || side.id];
1218
+ const entry = owner?.selections.find(item => item.map === side.workbenchMap && item.theme === theme && !item.scene);
1219
+ if (!entry) throw new Error("Theme is not available: " + theme);
1220
+ applyWorkbenchAppearance(side, entry);
1221
+ const started = performance.now();
1222
+ do {
1223
+ if (signal?.aborted) throw new Error("Audit cancelled.");
1224
+ if (states[activeSideId] !== side) throw new Error("The active map changed.");
1225
+ if (performance.now() - started > 30000) throw new Error("Theme update timed out.");
1226
+ await new Promise(resolve => setTimeout(resolve,40));
1227
+ } while (controlsBusy || controlsPending || controlsLoadingKey || controlsApplyTimer !== undefined);
1228
+ if (controlsLastError) throw new Error(controlsLastError);
1229
+ if (controlsIdentity(side)?.theme !== theme) throw new Error("Theme could not be applied.");
1230
+ },
1231
+ async restore(saved) {
1232
+ if (states[activeSideId].id !== saved.side) return;
1233
+ controlsHistoryQuiet++;
1234
+ try {
1235
+ controlsDraft = structuredClone(saved.controls);
1236
+ ctl("3d").checked = saved.buildings3d; ctl("animation").value = saved.animation;
1237
+ scheduleMapControls();
1238
+ await this.apply(saved.theme);
1239
+ const side = states[activeSideId];
1240
+ if (saved.style) await side.frame.contentWindow.__tileflowApplyScene(JSON.stringify({map:controlsIdentity(side).map,theme:saved.theme,controls:saved.controls,buildings3d:saved.buildings3d,animation:saved.animation === "fade" ? "fade" : Number(saved.animation),style:saved.style}), () => states[activeSideId] === side);
1241
+ renderMapControls();syncAnimationSegments();controlsHistoryCurrent=this.snapshot();
1242
+ } finally {controlsHistoryQuiet--;}
1243
+ }
1244
+ };
1245
+ `;
1246
+
1247
+ // src/workbench-ui.ts
1248
+ function workbenchIcon(name) {
1249
+ const paths = {
1250
+ inspect: '<path d="M9 3H4a1 1 0 0 0-1 1v5m12-6h5a1 1 0 0 1 1 1v5M3 15v5a1 1 0 0 0 1 1h5"/><path d="m11 11 10 4-5 1-1 5z"/>',
1251
+ settings: '<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="3"/><circle cx="15" cy="17" r="3"/>',
1252
+ close: '<path d="m6 6 12 12M6 18 18 6"/>'
1253
+ };
1254
+ return `<svg class="tool-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${paths[name]}</svg>`;
1255
+ }
1256
+ var workbenchStyles = `
1257
+ body { display:flex; flex-direction:column; }
1258
+ .workspace { flex:1; min-height:0; }
1259
+ input[type=range] { accent-color:var(--accent); }
1260
+ button:disabled { opacity:.5; cursor:not-allowed; }
1261
+ select,input[type=text],input[type=number] { background:var(--paper); color:var(--ink); border:1px solid var(--border); padding:5px 8px; }
1262
+ summary { cursor:pointer; }
1263
+ .tool-icon { width:17px; height:17px; flex:none; vertical-align:middle; }
1264
+ button:has(.tool-icon) { display:inline-flex; align-items:center; justify-content:center; gap:7px; }
1265
+ .inspect-hint { position:absolute; z-index:4; top:8px; left:8px; pointer-events:none; }
1266
+ .compact-note { font-size:12px; color:var(--muted); line-height:1.5; }
1267
+ .property-list { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
1268
+ .property-list dd { margin:0; }
1269
+ .authoring-path { display:block; overflow-wrap:anywhere; }
1270
+ .color-swatch { width:16px; height:16px; border:1px solid var(--border); border-radius:2px; display:inline-block; }
1271
+ `;
1272
+ var workbenchPanels = `<div id="wb-notice" role="status" hidden></div>`;
39
1273
 
40
1274
  // src/comparison-html.ts
41
1275
  function renderTileflowComparisonHtml(options) {
42
1276
  const documentOptions = serializeInlineJson(options);
1277
+ const fontBasePath = options.basePath.split("/").map((segment) => encodeURIComponent(decodeURIComponent(segment)).replaceAll("'", "%27")).join("/");
43
1278
  const nonceAttribute = options.cspNonce ? ` nonce="${escapeHtml(options.cspNonce)}"` : "";
44
1279
  return `<!doctype html>
45
1280
  <html lang="en" data-tileflow-state="loading">
@@ -48,6 +1283,10 @@ function renderTileflowComparisonHtml(options) {
48
1283
  <meta name="viewport" content="width=device-width, initial-scale=1" />
49
1284
  <title>${escapeHtml(options.title)}</title>
50
1285
  <style${nonceAttribute}>
1286
+ @font-face { font-family:'Inter'; font-weight:100 900; font-display:swap; src:url('${fontBasePath}/__workbench-fonts/inter.woff2') format('woff2'); }
1287
+ @font-face { font-family:'JetBrains Mono'; font-weight:400; font-display:swap; src:url('${fontBasePath}/__workbench-fonts/jetbrains-mono.woff2') format('woff2'); }
1288
+ @font-face { font-family:'Mona Sans'; font-weight:200 900; font-display:swap; src:url('${fontBasePath}/__workbench-fonts/mona-sans.woff2') format('woff2'); }
1289
+ @font-face { font-family:'Hubot Sans'; font-weight:200 900; font-display:swap; src:url('${fontBasePath}/__workbench-fonts/hubot-sans.woff2') format('woff2'); }
51
1290
  :root {
52
1291
  color-scheme: light;
53
1292
  --accent: #3157d5;
@@ -59,6 +1298,8 @@ function renderTileflowComparisonHtml(options) {
59
1298
  --overlay-alpha: .5;
60
1299
  }
61
1300
  * { box-sizing: border-box; }
1301
+ [hidden] { display: none !important; }
1302
+ .stage[data-mode="single"] .pane-right { display: none; }
62
1303
  html, body { height: 100%; margin: 0; }
63
1304
  body {
64
1305
  display: grid;
@@ -83,49 +1324,10 @@ function renderTileflowComparisonHtml(options) {
83
1324
  outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent);
84
1325
  outline-offset: 2px;
85
1326
  }
86
- .toolbar {
87
- z-index: 10;
88
- display: flex;
89
- min-height: 48px;
90
- align-items: center;
91
- gap: 8px;
92
- padding: 7px 10px;
93
- border-bottom: 1px solid var(--border);
94
- background: rgba(255, 255, 255, .97);
95
- flex-wrap: wrap;
96
- }
97
- .toolbar-title { font-weight: 750; margin-right: 4px; }
98
- .toolbar-group { display: inline-flex; min-width: 0; align-items: center; gap: 8px; flex-wrap: wrap; }
99
- .toolbar-separator { width: 1px; height: 25px; flex: 0 0 auto; background: var(--border); }
100
- .toolbar label { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); }
101
- .toolbar input[type="range"] { width: 96px; }
102
- .toolbar input[type="text"] { width: 150px; padding: 5px 7px; border: 1px solid var(--border); border-radius: 6px; }
103
1327
  .workspace { position: relative; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; }
104
1328
  .stage { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #d8dadd; }
105
1329
  .pane { position: absolute; inset: 0; min-width: 0; min-height: 0; overflow: hidden; background: #d8dadd; }
106
1330
  .pane iframe { width: 100%; height: 100%; display: block; border: 0; background: #d8dadd; }
107
- .pane-label {
108
- position: absolute;
109
- z-index: 3;
110
- top: 10px;
111
- left: 10px;
112
- display: flex;
113
- max-width: calc(100% - 20px);
114
- align-items: center;
115
- gap: 7px;
116
- border: 1px solid rgba(0, 0, 0, .12);
117
- border-radius: 999px;
118
- background: rgba(255, 255, 255, .93);
119
- box-shadow: 0 3px 14px rgba(0, 0, 0, .12);
120
- padding: 5px 9px;
121
- pointer-events: none;
122
- }
123
- .pane-label-text { overflow: hidden; text-overflow: ellipsis; }
124
- .side-state-text { flex: 0 0 auto; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .03em; }
125
- .side-state { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: #8d929b; }
126
- .side-state[data-status="ready"] { background: #248a4a; }
127
- .side-state[data-status="building"] { background: #cf8114; }
128
- .side-state[data-status="invalid"], .side-state[data-status="error"] { background: #ba3838; }
129
1331
  .stage[data-mode="side-by-side"] { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1px; }
130
1332
  .stage[data-mode="side-by-side"] .pane { position: relative; inset: auto; }
131
1333
  .stage[data-mode="side-by-side"] .pane-right { grid-column: 2; }
@@ -165,74 +1367,46 @@ function renderTileflowComparisonHtml(options) {
165
1367
  .sprite-card { min-width: 0; border: 1px solid var(--border); border-radius: 7px; padding: 7px; background: var(--paper); text-align: center; }
166
1368
  .sprite-card canvas { display: block; margin: auto; max-width: 64px; max-height: 64px; image-rendering: auto; }
167
1369
  .sprite-name { display: block; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; font-size: 10px; }
168
- .feedback { color: var(--muted); min-width: 64px; }
169
1370
  .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
170
1371
  @media (max-width: 760px) {
171
- .toolbar { align-content: center; gap: 6px; padding: 6px; }
172
- .toolbar-title { flex: 1 0 calc(100% - 12px); }
173
- .toolbar-separator { display: none; }
174
- .toolbar-group { flex: 1 1 100%; gap: 5px; }
175
- .toolbar-group button { padding-inline: 7px; }
176
- .toolbar input[type="range"] { width: min(24vw, 86px); }
177
- .toolbar input[type="text"] { width: min(34vw, 130px); }
178
1372
  .workspace { grid-template-columns: minmax(0, 1fr); }
179
1373
  .inspector { position: absolute; z-index: 20; right: 0; inset-block: 0; width: min(92vw, 390px); box-shadow: -8px 0 28px rgba(0,0,0,.18); }
180
1374
  }
181
1375
  @media (prefers-reduced-motion: reduce) {
182
1376
  *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
183
1377
  }
1378
+ ${workbenchStyles}
1379
+ ${controlsStyles}
1380
+ ${devtoolsStyles}
184
1381
  </style>
185
1382
  </head>
186
1383
  <body>
187
- <header class="toolbar" aria-label="Comparison controls">
188
- <span class="toolbar-title" id="title"></span>
189
- <div class="toolbar-group" role="group" aria-label="Comparison mode">
190
- <button type="button" data-mode="side-by-side">Side by side</button>
191
- <button type="button" data-mode="split">Split</button>
192
- <button type="button" data-mode="overlay">Overlay</button>
193
- <button type="button" data-mode="blink">Blink</button>
194
- </div>
195
- <span class="toolbar-separator"></span>
196
- <div class="toolbar-group">
197
- <label id="split-control">Split <input id="split" type="range" min="5" max="95" value="50" aria-label="Split position" /></label>
198
- <label id="alpha-control">Opacity <input id="alpha" type="range" min="0" max="100" value="50" aria-label="Overlay opacity" /></label>
199
- <button type="button" id="driver">Interact: left</button>
200
- <button type="button" id="blink-pause">Pause blink</button>
201
- <button type="button" id="inspect-toggle" aria-controls="inspector" aria-expanded="false" aria-pressed="false">Inspect</button>
202
- </div>
203
- <span class="toolbar-separator"></span>
204
- <div class="toolbar-group">
205
- <input id="scene-name" type="text" value="comparison-view" aria-label="Scene name" />
206
- <label>DPR <select id="scene-dpr"><option value="1">1</option><option value="2">2</option></select></label>
207
- <button type="button" id="copy-scene">Copy scene</button>
208
- <button type="button" id="copy-command">Copy command</button>
209
- </div>
210
- <span class="feedback" id="feedback" role="status"></span>
211
- </header>
212
1384
  <div class="workspace">
213
1385
  <main class="stage" id="stage" data-driver="left" data-tileflow-capture-id="tileflow-comparison" data-tileflow-state="loading">
214
1386
  <section class="pane pane-left" data-side="left">
215
- <div class="pane-label"><span class="side-state" id="left-state" role="status" aria-live="polite"></span><span class="pane-label-text" id="left-label"></span><span class="side-state-text" id="left-status-text">Building</span></div>
216
- <iframe id="left-frame" title="Left Tileflow preview" referrerpolicy="no-referrer"></iframe>
1387
+ <iframe id="left-frame" title="${options.right ? "Left Tileflow preview" : "Tileflow map preview"}" referrerpolicy="no-referrer"></iframe>
217
1388
  </section>
218
1389
  <section class="pane pane-right" data-side="right">
219
- <div class="pane-label"><span class="side-state" id="right-state" role="status" aria-live="polite"></span><span class="pane-label-text" id="right-label"></span><span class="side-state-text" id="right-status-text">Building</span></div>
220
1390
  <iframe id="right-frame" title="Right Tileflow preview" referrerpolicy="no-referrer"></iframe>
221
1391
  </section>
222
1392
  <div class="split-line" aria-hidden="true"></div>
1393
+ <div class="inspect-hint" id="wb-inspect-hint" hidden>Click an element to inspect \xB7 Esc to exit</div>
223
1394
  </main>
224
1395
  <aside class="inspector" id="inspector" tabindex="-1" aria-labelledby="inspector-title" hidden>
225
- <div class="inspector-heading"><h2 id="inspector-title">Cartographic inspector</h2><button type="button" id="inspector-close" aria-label="Close inspector">Close</button></div>
226
- <div id="inspection" class="empty">Enable Inspect, then click either map.</div>
227
- <h3>Zoom curve</h3>
1396
+ <button type="button" id="inspect-toggle" aria-controls="inspector" aria-expanded="false" aria-pressed="false" aria-keyshortcuts="i" title="Select a map element (I). Escape to stop.">${workbenchIcon("inspect")}Inspect</button>
1397
+ <button type="button" id="controls-toggle" aria-controls="map-controls" aria-expanded="false">Scene</button>
1398
+ <div class="inspector-heading"><h2 id="inspector-title">${workbenchIcon("inspect")} Inspect the map</h2><button type="button" id="inspector-close" aria-label="Close inspector">${workbenchIcon("close")}</button></div>
1399
+ <div id="inspection" class="empty">Click a feature to understand how it is drawn and find its configuration.</div>
1400
+ <details class="zoom-details"><summary>Appearance at different zoom levels</summary>
228
1401
  <select id="curve-property" disabled><option>Select a rendered layer first</option></select>
229
- <div id="curve-output" class="empty">Paint and layout values appear here.</div>
230
- <h3>Compiler sidecar</h3>
231
- <pre id="sidecar-output">No layer selected.</pre>
232
- <h3>Sprite atlas</h3>
1402
+ <div id="curve-output" class="empty">Select an element to explore its appearance.</div></details>
1403
+ <details><summary>Compiler details</summary><h3>Compiler sidecar</h3><pre id="sidecar-output">No layer selected.</pre></details>
1404
+ <details><summary>Sprite atlas</summary>
233
1405
  <button type="button" id="load-sprites">Load active side sprites</button>
234
- <div id="sprite-output" class="sprite-grid"></div>
1406
+ <div id="sprite-output" class="sprite-grid"></div></details>
235
1407
  </aside>
1408
+ ${workbenchPanels}
1409
+ ${controlsPanel}
236
1410
  </div>
237
1411
  <script type="module"${nonceAttribute}>
238
1412
  const options = ${documentOptions};
@@ -250,24 +1424,16 @@ function renderTileflowComparisonHtml(options) {
250
1424
  const curveOutput = document.getElementById("curve-output");
251
1425
  const sidecarOutput = document.getElementById("sidecar-output");
252
1426
  const spriteOutput = document.getElementById("sprite-output");
253
- const feedback = document.getElementById("feedback");
254
- const splitInput = document.getElementById("split");
255
- const alphaInput = document.getElementById("alpha");
256
- const driverButton = document.getElementById("driver");
257
- const blinkPauseButton = document.getElementById("blink-pause");
258
1427
  const inspectButton = document.getElementById("inspect-toggle");
259
1428
  const inspectorCloseButton = document.getElementById("inspector-close");
260
- const sceneNameInput = document.getElementById("scene-name");
261
- const sceneDprInput = document.getElementById("scene-dpr");
262
1429
  const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
263
- const states = Object.fromEntries(["left", "right"].map((id) => {
1430
+ const sideIds = options.right ? ["left", "right"] : ["left"];
1431
+ const states = Object.fromEntries(sideIds.map((id) => {
264
1432
  const definition = options[id];
265
1433
  return [id, {
266
1434
  id,
267
1435
  definition,
268
1436
  frame: document.getElementById(id + "-frame"),
269
- stateElement: document.getElementById(id + "-state"),
270
- statusTextElement: document.getElementById(id + "-status-text"),
271
1437
  map: undefined,
272
1438
  camera: undefined,
273
1439
  bridgeReady: false,
@@ -296,45 +1462,25 @@ function renderTileflowComparisonHtml(options) {
296
1462
  let spriteLoadSequence = 0;
297
1463
  let spriteOwnerId;
298
1464
 
299
- document.getElementById("title").textContent = options.title;
300
1465
  const initialUi = readUiFromUrl();
301
- splitInput.value = String(initialUi.split);
302
- alphaInput.value = String(initialUi.alpha);
1466
+
303
1467
  applySliders();
304
1468
  setMode(initialUi.mode || options.initialMode);
305
- updateBlinkMotionPreference();
306
- for (const id of ["left", "right"]) {
1469
+ for (const id of sideIds) {
307
1470
  const side = states[id];
308
- document.getElementById(id + "-label").textContent = side.definition.label;
309
1471
  side.frame.src = side.definition.previewUrl;
310
1472
  side.frame.addEventListener("load", () => connectSide(side));
311
1473
  side.frame.addEventListener("focus", () => selectSide(side.id));
312
1474
  startStatus(side);
313
1475
  }
314
1476
 
315
- for (const button of document.querySelectorAll("[data-mode]")) {
316
- button.addEventListener("click", () => setMode(button.dataset.mode));
317
- }
318
- splitInput.addEventListener("input", () => { applySliders(); writeUrlState(); });
319
- alphaInput.addEventListener("input", () => { applySliders(); writeUrlState(); });
320
- driverButton.addEventListener("click", () => {
321
- selectSide(stage.dataset.driver === "left" ? "right" : "left");
322
- });
323
- blinkPauseButton.addEventListener("click", () => {
324
- blinkPaused = !blinkPaused;
325
- updateBlinkMotionPreference();
326
- restartBlink();
327
- });
328
1477
  inspectButton.addEventListener("click", () => setInspectionEnabled(!inspectionEnabled, true));
329
1478
  inspectorCloseButton.addEventListener("click", () => setInspectionEnabled(false, true));
330
- document.getElementById("copy-scene").addEventListener("click", () => copyScene(false));
331
- document.getElementById("copy-command").addEventListener("click", () => copyScene(true));
332
1479
  document.getElementById("load-sprites").addEventListener("click", () => loadSprites(states[activeSideId]));
333
1480
  curveProperty.addEventListener("change", renderCurve);
334
1481
  reducedMotion.addEventListener?.("change", () => {
335
1482
  blinkPaused = reducedMotion.matches;
336
- updateBlinkMotionPreference();
337
- restartBlink();
1483
+ restartBlink();
338
1484
  });
339
1485
  addEventListener("keydown", (event) => {
340
1486
  if (event.key === "Escape" && inspectionEnabled) setInspectionEnabled(false, true);
@@ -346,18 +1492,16 @@ function renderTileflowComparisonHtml(options) {
346
1492
  side.eventSource?.close();
347
1493
  side.sidecarAbort?.abort();
348
1494
  side.spriteAbort?.abort();
349
- side.cleanupBridge?.();
1495
+ cleanupSideBridge(side);
350
1496
  }
351
1497
  });
352
1498
 
353
1499
  function selectSide(id, writeState = true) {
354
- if (id !== "left" && id !== "right") return;
1500
+ if (!states[id]) return;
355
1501
  const changed = activeSideId !== id || stage.dataset.driver !== id;
356
1502
  const previousSpriteOwner = spriteOwnerId;
357
1503
  activeSideId = id;
358
1504
  stage.dataset.driver = id;
359
- driverButton.textContent = "Interact: " + id;
360
- driverButton.setAttribute("aria-label", "Interact with " + states[id].definition.label);
361
1505
  if (changed && previousSpriteOwner && previousSpriteOwner !== id) {
362
1506
  states[previousSpriteOwner].spriteAbort?.abort();
363
1507
  spriteLoadSequence += 1;
@@ -370,33 +1514,31 @@ function renderTileflowComparisonHtml(options) {
370
1514
 
371
1515
  function setInspectionEnabled(enabled, manageFocus = false) {
372
1516
  inspectionEnabled = enabled;
1517
+ if (globalThis.__tileflowDevtoolsUI) {
1518
+ inspectButton.setAttribute("aria-pressed", String(enabled));
1519
+ document.getElementById("wb-inspect-hint").hidden = !enabled;
1520
+ for (const side of Object.values(states)) sendInspectionState(side);
1521
+ globalThis.__tileflowDevtoolsUI.inspectionChanged(enabled);
1522
+ return;
1523
+ }
1524
+ if (enabled) toggleMapControls(false);
373
1525
  inspectButton.setAttribute("aria-pressed", String(enabled));
374
1526
  inspectButton.setAttribute("aria-expanded", String(enabled));
375
1527
  inspector.hidden = !enabled;
376
- for (const side of Object.values(states)) sendInspectionState(side);
1528
+ document.getElementById("wb-inspect-hint").hidden = !enabled;
1529
+ for (const side of Object.values(states)) { sendInspectionState(side); side.map?.resize?.(); }
377
1530
  if (manageFocus) {
378
1531
  if (enabled) inspector.focus();
379
1532
  else inspectButton.focus();
380
1533
  }
381
1534
  }
382
1535
 
383
- function updateBlinkMotionPreference() {
384
- const motionBlocked = reducedMotion.matches;
385
- blinkPauseButton.disabled = motionBlocked;
386
- blinkPauseButton.textContent = motionBlocked
387
- ? "Blink paused (reduced motion)"
388
- : blinkPaused ? "Resume blink" : "Pause blink";
389
- }
390
1536
 
391
1537
  function setMode(mode) {
392
- if (!["side-by-side", "split", "overlay", "blink"].includes(mode)) return;
1538
+ if (!options.right) mode = "single";
1539
+ if (!["single", "side-by-side", "split", "overlay", "blink"].includes(mode)) return;
1540
+ if (mode === "single" && activeSideId !== "left") selectSide("left", false);
393
1541
  stage.dataset.mode = mode;
394
- for (const button of document.querySelectorAll("[data-mode]")) {
395
- button.setAttribute("aria-pressed", String(button.dataset.mode === mode));
396
- }
397
- document.getElementById("split-control").hidden = mode !== "split";
398
- document.getElementById("alpha-control").hidden = mode !== "overlay";
399
- blinkPauseButton.hidden = mode !== "blink";
400
1542
  restartBlink();
401
1543
  writeUrlState();
402
1544
  setTimeout(() => {
@@ -405,8 +1547,8 @@ function renderTileflowComparisonHtml(options) {
405
1547
  }
406
1548
 
407
1549
  function applySliders() {
408
- const split = boundedNumber(Number(splitInput.value), 5, 95, 50);
409
- const alpha = boundedNumber(Number(alphaInput.value), 0, 100, 50);
1550
+ const split = boundedNumber(initialUi.split, 5, 95, 50);
1551
+ const alpha = boundedNumber(initialUi.alpha, 0, 100, 50);
410
1552
  stage.style.setProperty("--split", split + "%");
411
1553
  stage.style.setProperty("--overlay-alpha", String(alpha / 100));
412
1554
  }
@@ -423,16 +1565,27 @@ function renderTileflowComparisonHtml(options) {
423
1565
  }, 700);
424
1566
  }
425
1567
 
1568
+ function cleanupSideBridge(side) {
1569
+ try { side.cleanupBridge?.(); }
1570
+ catch (error) {
1571
+ // A stopped dev server can replace the iframe with a cross-origin browser error page.
1572
+ if (error?.name !== "SecurityError") throw error;
1573
+ } finally { side.cleanupBridge = undefined; }
1574
+ }
1575
+
426
1576
  function connectSide(side) {
427
1577
  side.loadEpoch += 1;
1578
+ side.appliedSceneStyle = undefined;
1579
+ side.pendingAppearance = undefined;
428
1580
  const epoch = side.loadEpoch;
429
- side.cleanupBridge?.();
1581
+ cleanupSideBridge(side);
430
1582
  side.cleanupBridge = undefined;
431
1583
  side.sidecarAbort?.abort();
432
1584
  side.spriteAbort?.abort();
433
1585
  side.sidecarAbort = undefined;
434
1586
  side.spriteAbort = undefined;
435
1587
  side.map = undefined;
1588
+ side.bridgeConnected = false;
436
1589
  side.bridgeReady = false;
437
1590
  side.camera = undefined;
438
1591
  side.sidecar = undefined;
@@ -456,7 +1609,6 @@ function renderTileflowComparisonHtml(options) {
456
1609
  curveOutput.textContent = "Paint and layout values appear here.";
457
1610
  sidecarOutput.textContent = "No layer selected.";
458
1611
  }
459
- renderSideStatus(side);
460
1612
  updateDocumentReadiness();
461
1613
  const started = performance.now();
462
1614
  const findMap = () => {
@@ -467,7 +1619,13 @@ function renderTileflowComparisonHtml(options) {
467
1619
  const map = childWindow?.__tileflowPreviewMap;
468
1620
  if (map) {
469
1621
  side.map = map;
1622
+ // DevTools can edit a connected map while its tiles are still loading.
1623
+ // bridgeReady remains the separate renderer-readiness signal.
1624
+ side.bridgeConnected = true;
1625
+ side.camera = readMapCamera(map);
470
1626
  side.cleanupBridge = installChildBridge(side, childWindow, map, epoch);
1627
+ if (options.workbenchUrl) void refreshWorkbench();
1628
+ if (!controlsPanel.hidden) void loadMapControls();
471
1629
  return;
472
1630
  }
473
1631
  } catch (error) {
@@ -493,6 +1651,68 @@ function renderTileflowComparisonHtml(options) {
493
1651
  let releaseFrameAfterPaint;
494
1652
  let readyTimer;
495
1653
  let readyAnnounced = false;
1654
+ let inspectedFeatures = [];
1655
+ let highlightedIndex = -1;
1656
+ let hoveredFeature, hoveredCoordinate, hoverFrame, hoverHint;
1657
+ let inspectedCoordinate;
1658
+ let highlight;
1659
+ const drawHighlight = () => {
1660
+ const geometry = hoveredFeature?.geometry || inspectedFeatures[highlightedIndex]?.geometry;
1661
+ if ((!inspectionActive && !globalThis.__tileflowDevtoolsUI) || !geometry || !map.getContainer || !map.project) { if (highlight) highlight.style.display = "none"; return; }
1662
+ if (!highlight) {
1663
+ highlight = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1664
+ highlight.setAttribute("aria-hidden", "true");
1665
+ highlight.style.cssText = "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:3;overflow:hidden";
1666
+ map.getContainer().append(highlight);
1667
+ }
1668
+ highlight.style.display = "";
1669
+ highlight.replaceChildren();
1670
+ let remaining = 3000;
1671
+ const project = (coordinate) => {
1672
+ if (--remaining < 0 || !Array.isArray(coordinate) || !coordinate.slice(0,2).every(Number.isFinite)) return;
1673
+ const point = map.project(coordinate);
1674
+ return Number.isFinite(point.x) && Number.isFinite(point.y) ? point : undefined;
1675
+ };
1676
+ const draw = (coordinates, closed = false) => {
1677
+ if (!Array.isArray(coordinates)) return;
1678
+ const points = coordinates.slice(0, Math.max(0,remaining)).map(project).filter(Boolean);
1679
+ if (!points.length) return;
1680
+ const shape = document.createElementNS("http://www.w3.org/2000/svg", "path");
1681
+ shape.setAttribute("d", points.map((point,index) => (index ? "L" : "M") + point.x + " " + point.y).join(" ") + (closed ? "Z" : ""));
1682
+ shape.setAttribute("fill", closed ? "#1a73e822" : "none");
1683
+ shape.setAttribute("stroke", "#1a73e8"); shape.setAttribute("stroke-width", "3");
1684
+ shape.setAttribute("stroke-linejoin", "round"); highlight.append(shape);
1685
+ };
1686
+ const coordinates = geometry.coordinates;
1687
+ const containsPoint = (ring) => {
1688
+ const coordinate = hoveredCoordinate || inspectedCoordinate;
1689
+ if (!coordinate) return false;
1690
+ const [x,y] = coordinate;
1691
+ let inside = false;
1692
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
1693
+ const [xi,yi] = ring[i], [xj,yj] = ring[j];
1694
+ if ((yi > y) !== (yj > y) && x < (xj-xi)*(y-yi)/(yj-yi)+xi) inside = !inside;
1695
+ }
1696
+ return inside;
1697
+ };
1698
+ if (geometry.type === "Polygon") coordinates.forEach((ring) => draw(ring,true));
1699
+ else if (geometry.type === "MultiPolygon") coordinates.filter((polygon) => containsPoint(polygon[0]) && !polygon.slice(1).some(containsPoint)).forEach((polygon) => polygon.forEach((ring) => draw(ring,true)));
1700
+ else if (geometry.type === "LineString") draw(coordinates);
1701
+ else if (geometry.type === "MultiLineString") coordinates.forEach((line) => draw(line));
1702
+ else for (const coordinate of geometry.type === "MultiPoint" ? coordinates : [coordinates]) {
1703
+ const point = project(coordinate); if (!point) continue;
1704
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
1705
+ circle.setAttribute("cx", point.x); circle.setAttribute("cy", point.y); circle.setAttribute("r", "10");
1706
+ circle.setAttribute("fill", "#1a73e822"); circle.setAttribute("stroke", "#1a73e8"); circle.setAttribute("stroke-width", "3"); highlight.append(circle);
1707
+ }
1708
+ };
1709
+ side.highlightFeature = (index) => { highlightedIndex = index; drawHighlight(); };
1710
+ side.getInspectedFeature = () => inspectedFeatures[highlightedIndex];
1711
+ const handleKey = (event) => {
1712
+ if (event.target.closest?.("input,select,textarea,[contenteditable]")) return;
1713
+ if (event.key === "Escape" && inspectionEnabled) { event.preventDefault(); setInspectionEnabled(false, true); }
1714
+ if (event.key.toLowerCase() === "i" && !event.metaKey && !event.ctrlKey && !event.altKey) { event.preventDefault(); setInspectionEnabled(!inspectionEnabled, true); }
1715
+ };
496
1716
  const targetOrigin = childWindow.location.origin;
497
1717
  // The bridge is installed by the same-origin parent, so callbacks created here
498
1718
  // belong to the parent realm. Dispatch directly with an explicit child source;
@@ -515,11 +1735,11 @@ function renderTileflowComparisonHtml(options) {
515
1735
  });
516
1736
  };
517
1737
  const handleMoveStart = (event) => {
518
- if (applyingRequestId === undefined && event?.originalEvent) userMovementActive = true;
1738
+ if (applyingRequestId === undefined && (event?.originalEvent || event?.tileflowDevtools === true)) userMovementActive = true;
519
1739
  };
520
1740
  const handleMove = (event) => {
521
1741
  if (applyingRequestId !== undefined) return;
522
- if (event?.originalEvent) userMovementActive = true;
1742
+ if (event?.originalEvent || event?.tileflowDevtools === true) userMovementActive = true;
523
1743
  queuedUserMovement = queuedUserMovement || userMovementActive;
524
1744
  if (movementFrame !== undefined) return;
525
1745
  movementFrame = childWindow.requestAnimationFrame(() => {
@@ -535,7 +1755,7 @@ function renderTileflowComparisonHtml(options) {
535
1755
  scheduleApplyingRelease(applyingRequestId);
536
1756
  return;
537
1757
  }
538
- if (event?.originalEvent) userMovementActive = true;
1758
+ if (event?.originalEvent || event?.tileflowDevtools === true) userMovementActive = true;
539
1759
  if (movementFrame !== undefined) {
540
1760
  childWindow.cancelAnimationFrame(movementFrame);
541
1761
  movementFrame = undefined;
@@ -544,9 +1764,45 @@ function renderTileflowComparisonHtml(options) {
544
1764
  postCamera("end", undefined, userMovementActive);
545
1765
  userMovementActive = false;
546
1766
  };
1767
+ const clearHover = () => {
1768
+ if (hoverFrame !== undefined) childWindow.cancelAnimationFrame(hoverFrame);
1769
+ hoverFrame = undefined; hoveredFeature = undefined; hoveredCoordinate = undefined;
1770
+ if (hoverHint) hoverHint.hidden = true;
1771
+ drawHighlight();
1772
+ };
1773
+ side.hoverFeature = (index) => {
1774
+ clearHover();
1775
+ if (!inspectedFeatures[index]) return;
1776
+ hoveredFeature = inspectedFeatures[index];
1777
+ hoveredCoordinate = inspectedCoordinate;
1778
+ drawHighlight();
1779
+ };
1780
+ const handleHover = (event) => {
1781
+ if (!inspectionActive || !globalThis.__tileflowDevtoolsUI || hoverFrame !== undefined) return;
1782
+ hoverFrame = childWindow.requestAnimationFrame(() => {
1783
+ hoverFrame = undefined;
1784
+ hoveredFeature = map.queryRenderedFeatures(event.point).find((feature) => !feature.layer.id.startsWith("__tileflow-devtools-"));
1785
+ hoveredCoordinate = [event.lngLat.lng, event.lngLat.lat];
1786
+ if (!hoverHint) {
1787
+ hoverHint = document.createElement("div");
1788
+ hoverHint.style.cssText = "position:absolute;z-index:6;pointer-events:none;padding:4px 7px;background:#fff;color:#202124;border:1px solid #dadce0;border-radius:3px;box-shadow:0 2px 5px #0003;font:11px/1.5 monospace;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
1789
+ map.getContainer().append(hoverHint);
1790
+ }
1791
+ hoverHint.hidden = !hoveredFeature;
1792
+ if (hoveredFeature) {
1793
+ hoverHint.textContent = hoveredFeature.layer.id + " \xB7 " + hoveredFeature.geometry.type + (hoveredFeature.id === undefined ? "" : " #" + hoveredFeature.id);
1794
+ hoverHint.style.left = Math.max(4,Math.min(event.point.x + 14,map.getContainer().clientWidth - 310)) + "px";
1795
+ hoverHint.style.top = Math.max(4,Math.min(event.point.y + 14,map.getContainer().clientHeight - 32)) + "px";
1796
+ }
1797
+ drawHighlight();
1798
+ });
1799
+ };
547
1800
  const handleClick = (event) => {
548
1801
  if (!inspectionActive) return;
549
- const features = map.queryRenderedFeatures(event.point).slice(0, 16).map(projectFeature);
1802
+ clearHover();
1803
+ inspectedCoordinate = [event.lngLat.lng, event.lngLat.lat];
1804
+ inspectedFeatures = map.queryRenderedFeatures(event.point).filter((feature) => !feature.layer.id.startsWith("__tileflow-devtools-")).slice(0, 16);
1805
+ const features = inspectedFeatures.map(projectFeature);
550
1806
  postToParent({
551
1807
  type: bridgeInspectionType,
552
1808
  schemaVersion: bridgeSchemaVersion,
@@ -555,6 +1811,13 @@ function renderTileflowComparisonHtml(options) {
555
1811
  features
556
1812
  });
557
1813
  };
1814
+ side.inspectRenderedFeature = (feature, coordinate) => {
1815
+ clearHover();
1816
+ inspectedCoordinate = coordinate;
1817
+ inspectedFeatures = [feature];
1818
+ postToParent({type: bridgeInspectionType, schemaVersion: bridgeSchemaVersion,
1819
+ side: side.id, coordinate, features: [projectFeature(feature)]});
1820
+ };
558
1821
  const handleError = (event) => {
559
1822
  postToParent({
560
1823
  type: bridgeRuntimeErrorType,
@@ -569,7 +1832,9 @@ function renderTileflowComparisonHtml(options) {
569
1832
  if (!message || message.schemaVersion !== bridgeSchemaVersion) return;
570
1833
  if (message.type === bridgeSetInspectionType && typeof message.enabled === "boolean") {
571
1834
  inspectionActive = message.enabled;
1835
+ if (!inspectionActive) clearHover();
572
1836
  map.getCanvas().style.cursor = inspectionActive ? "crosshair" : "";
1837
+ drawHighlight();
573
1838
  return;
574
1839
  }
575
1840
  if (message.type !== bridgeSetCameraType || !validCamera(message.camera)) return;
@@ -622,10 +1887,15 @@ function renderTileflowComparisonHtml(options) {
622
1887
  });
623
1888
  });
624
1889
  };
1890
+ map.on("move", drawHighlight);
1891
+ map.on("resize", drawHighlight);
1892
+ childWindow.addEventListener("keydown", handleKey);
625
1893
  map.on("movestart", handleMoveStart);
626
1894
  map.on("move", handleMove);
627
1895
  map.on("moveend", handleMoveEnd);
628
1896
  map.on("click", handleClick);
1897
+ map.on("mousemove", handleHover);
1898
+ map.on("mouseout", clearHover);
629
1899
  map.on("error", handleError);
630
1900
  childWindow.addEventListener("message", handleCommand);
631
1901
  if (map.loaded?.()) announceReady();
@@ -638,10 +1908,17 @@ function renderTileflowComparisonHtml(options) {
638
1908
  if (movementFrame !== undefined) childWindow.cancelAnimationFrame(movementFrame);
639
1909
  cancelApplyingRelease();
640
1910
  clearTimeout(readyTimer);
1911
+ clearHover(); hoverHint?.remove();
1912
+ highlight?.remove(); side.highlightFeature = undefined; side.hoverFeature = undefined;
1913
+ side.getInspectedFeature = undefined; side.inspectRenderedFeature = undefined;
1914
+ map.off("move", drawHighlight); map.off("resize", drawHighlight);
1915
+ childWindow.removeEventListener("keydown", handleKey);
641
1916
  map.off("movestart", handleMoveStart);
642
1917
  map.off("move", handleMove);
643
1918
  map.off("moveend", handleMoveEnd);
644
1919
  map.off("click", handleClick);
1920
+ map.off("mousemove", handleHover);
1921
+ map.off("mouseout", clearHover);
645
1922
  map.off("error", handleError);
646
1923
  map.off("load", announceReady);
647
1924
  map.off("idle", announceReady);
@@ -657,10 +1934,11 @@ function renderTileflowComparisonHtml(options) {
657
1934
  if (message.type === bridgeReadyType && validCamera(message.camera)) {
658
1935
  side.bridgeReady = true;
659
1936
  side.camera = message.camera;
660
- renderSideStatus(side);
661
1937
  maybeEstablishInitialCamera();
662
1938
  sendInspectionState(side);
663
1939
  void loadSidecar(side);
1940
+ if (options.workbenchUrl) void refreshWorkbench();
1941
+ if (!controlsPanel.hidden) void loadMapControls();
664
1942
  updateDocumentReadiness();
665
1943
  return;
666
1944
  }
@@ -709,7 +1987,7 @@ function renderTileflowComparisonHtml(options) {
709
1987
  }
710
1988
 
711
1989
  function sendCamera(side, camera) {
712
- if (!side.bridgeReady || !camera || !side.frame.contentWindow) return;
1990
+ if (!side?.bridgeReady || !camera || !side.frame.contentWindow) return;
713
1991
  if (side.camera && camerasEqual(side.camera, camera)) return;
714
1992
  const requestId = ++requestSequence;
715
1993
  side.lastCameraRequestId = requestId;
@@ -759,6 +2037,16 @@ function renderTileflowComparisonHtml(options) {
759
2037
  .catch(() => undefined);
760
2038
  const events = new EventSource(side.definition.eventsUrl);
761
2039
  side.eventSource = events;
2040
+ let connected = false, disconnected = false;
2041
+ events.addEventListener("open", () => {
2042
+ if (connected && disconnected) {
2043
+ side.artifactGeneration = -1;
2044
+ side.latestArtifactStatus = undefined;
2045
+ side.frame.contentWindow?.location.reload();
2046
+ }
2047
+ connected = true; disconnected = false;
2048
+ });
2049
+ events.addEventListener("error", () => { if (connected) disconnected = true; });
762
2050
  for (const name of ["building", "ready", "invalid"]) {
763
2051
  events.addEventListener(name, (event) => {
764
2052
  try { applyArtifactStatus(side, JSON.parse(event.data)); } catch {}
@@ -781,7 +2069,8 @@ function renderTileflowComparisonHtml(options) {
781
2069
  ? status.lastGoodGeneration
782
2070
  : undefined;
783
2071
  side.artifactDiagnostic = status.diagnostics?.[0]?.message;
784
- renderSideStatus(side);
2072
+ side.latestArtifactStatus = status;
2073
+ side.previewStatusReceiver?.(status);
785
2074
  maybeEstablishInitialCamera();
786
2075
  updateDocumentReadiness();
787
2076
  }
@@ -800,31 +2089,26 @@ function renderTileflowComparisonHtml(options) {
800
2089
  stage.dataset.tileflowState = value;
801
2090
  }
802
2091
 
803
- function renderSideStatus(side) {
804
- const diagnostic = side.artifactDiagnostic;
805
- const message = side.runtimeFailed
806
- ? "Runtime error: " + (side.runtimeErrors.at(-1) || "Preview runtime failed")
807
- : side.artifactStatus === "invalid"
808
- ? "Invalid generation; showing last valid preview" + (diagnostic ? ": " + diagnostic : "")
809
- : "Generation " + String(Math.max(0, side.artifactGeneration)) + " \xB7 " + side.artifactStatus;
810
- side.stateElement.dataset.status = side.runtimeFailed ? "error" : side.artifactStatus;
811
- side.stateElement.title = message;
812
- side.stateElement.setAttribute("aria-label", side.definition.label + ": " + message);
813
- side.statusTextElement.textContent = side.runtimeFailed ? "Runtime error" : side.artifactStatus;
814
- }
815
2092
 
816
2093
  async function loadSidecar(side) {
817
2094
  if (!side.definition.sidecarUrl) return undefined;
2095
+ const key = currentSidecarUrl(side);
2096
+ if (side.sidecarKey !== key) {
2097
+ side.sidecarAbort?.abort();
2098
+ side.sidecar = undefined;
2099
+ side.sidecarPromise = undefined;
2100
+ side.sidecarKey = key;
2101
+ }
818
2102
  if (side.sidecar !== undefined) return side.sidecar;
819
2103
  if (side.sidecarPromise) return side.sidecarPromise;
820
2104
  const epoch = side.loadEpoch;
821
2105
  const controller = new AbortController();
822
2106
  side.sidecarAbort?.abort();
823
2107
  side.sidecarAbort = controller;
824
- const promise = fetch(currentSidecarUrl(side), {cache: "no-store", signal: controller.signal})
2108
+ const promise = fetch(key, {cache: "no-store", signal: controller.signal})
825
2109
  .then((response) => response.ok ? response.json() : Promise.reject(new Error("Compiler sidecar unavailable")))
826
2110
  .then((document) => {
827
- if (side.loadEpoch !== epoch || controller.signal.aborted) return undefined;
2111
+ if (side.loadEpoch !== epoch || side.sidecarKey !== key || controller.signal.aborted) return undefined;
828
2112
  side.sidecar = document;
829
2113
  if (selectedInspection?.side === side) renderInspection();
830
2114
  return document;
@@ -862,6 +2146,9 @@ function renderTileflowComparisonHtml(options) {
862
2146
  return configured.href;
863
2147
  }
864
2148
  configured.pathname = configured.pathname.slice(0, markerIndex) + marker + map + "/" + theme + ".json";
2149
+ const controls = livePreviewUrl(side).searchParams.get("controls");
2150
+ if (controls) configured.searchParams.set("controls", controls);
2151
+ else configured.searchParams.delete("controls");
865
2152
  return configured.href;
866
2153
  }
867
2154
 
@@ -870,6 +2157,7 @@ function renderTileflowComparisonHtml(options) {
870
2157
  const side = selectedInspection.side;
871
2158
  const feature = selectedInspection.features[selectedInspection.featureIndex];
872
2159
  inspectionOutput.replaceChildren();
2160
+ for (const entry of Object.values(states)) entry.highlightFeature?.(entry === side ? selectedInspection.featureIndex : -1);
873
2161
  if (!feature) {
874
2162
  inspectionOutput.className = "empty";
875
2163
  inspectionOutput.textContent = side.definition.label + ": no rendered feature at this point.";
@@ -877,6 +2165,7 @@ function renderTileflowComparisonHtml(options) {
877
2165
  curveOutput.className = "empty";
878
2166
  curveOutput.textContent = "Paint and layout values appear here.";
879
2167
  sidecarOutput.textContent = "No layer selected.";
2168
+ globalThis.__tileflowDevtoolsUI?.selectionChanged();
880
2169
  return;
881
2170
  }
882
2171
  inspectionOutput.className = "";
@@ -885,14 +2174,14 @@ function renderTileflowComparisonHtml(options) {
885
2174
  title.textContent = side.definition.label + " \xB7 " + feature.layerId;
886
2175
  const pickerLabel = document.createElement("label");
887
2176
  pickerLabel.className = "feature-picker";
888
- pickerLabel.append(document.createTextNode("Rendered feature (" + selectedInspection.features.length + ")"));
2177
+ pickerLabel.append(document.createTextNode("Elements at this point (" + selectedInspection.features.length + ")"));
889
2178
  const picker = document.createElement("select");
890
2179
  picker.setAttribute("aria-label", "Rendered feature at inspected point");
891
2180
  selectedInspection.features.forEach((candidate, index) => {
892
2181
  const option = document.createElement("option");
893
2182
  option.value = String(index);
894
- option.textContent = String(index + 1) + ". " + candidate.layerId + " \xB7 " + candidate.geometryType +
895
- (candidate.id === null ? "" : " \xB7 " + String(candidate.id));
2183
+ option.textContent = options.workbenchUrl ? featureDisplayName(candidate) + " \xB7 " + candidate.geometryType : String(index + 1) + ". " + candidate.layerId + " \xB7 " + candidate.geometryType + (candidate.id === null ? "" : " \xB7 " + String(candidate.id));
2184
+ option.title = candidate.layerId;
896
2185
  picker.append(option);
897
2186
  });
898
2187
  picker.value = String(selectedInspection.featureIndex);
@@ -915,13 +2204,15 @@ function renderTileflowComparisonHtml(options) {
915
2204
  filter: layer.filter
916
2205
  } : undefined
917
2206
  });
918
- inspectionOutput.append(title, pickerLabel, summary);
2207
+ if (options.workbenchUrl) renderFeatureSummary(side, feature, layer, pickerLabel, summary);
2208
+ else inspectionOutput.append(title, pickerLabel, summary);
919
2209
  populateCurveProperties(layer);
920
2210
  const sidecarLayer = findSidecarLayer(side.sidecar, feature.layerId);
921
2211
  sidecarOutput.textContent = sidecarLayer
922
2212
  ? boundedJson(sidecarLayerWithSemanticAttribution(sidecarLayer))
923
2213
  : "No compiler sidecar entry for this layer.";
924
2214
  if (side.sidecar === undefined && !side.sidecarPromise) void loadSidecar(side);
2215
+ globalThis.__tileflowDevtoolsUI?.selectionChanged();
925
2216
  }
926
2217
 
927
2218
  function populateCurveProperties(layer) {
@@ -1199,103 +2490,14 @@ function renderTileflowComparisonHtml(options) {
1199
2490
  value.x + value.width <= image.naturalWidth && value.y + value.height <= image.naturalHeight;
1200
2491
  }
1201
2492
 
1202
- async function copyScene(commandOnly) {
1203
- const name = sceneNameInput.value;
1204
- if (!validPortableName(name)) {
1205
- setFeedback("Use a portable kebab-case scene name.");
1206
- return;
1207
- }
1208
- const side = states[activeSideId];
1209
- const camera = side.camera || canonicalCamera;
1210
- if (!camera) {
1211
- setFeedback("Wait for the maps to become ready.");
1212
- return;
1213
- }
1214
- const style = side.map?.getStyle?.();
1215
- const map = style?.metadata?.["tileflow:map"];
1216
- const theme = style?.metadata?.["tileflow:theme"];
1217
- if (!validPortableName(map) || !validConcreteThemeName(theme)) {
1218
- setFeedback("The active preview does not expose a concrete map and theme.");
1219
- return;
1220
- }
1221
- const dpr = Number(sceneDprInput.value) === 2 ? 2 : 1;
1222
- const viewport = readMapViewport(side, dpr);
1223
- const {width, height} = viewport;
1224
- if (commandOnly) {
1225
- const command = [
1226
- "tileflow capture",
1227
- ...(side.definition.captureConfig
1228
- ? ["--config " + quoteCliArgument(side.definition.captureConfig)]
1229
- : []),
1230
- "--map " + quoteCliArgument(map),
1231
- "--theme " + quoteCliArgument(theme),
1232
- "--center=" + formatNumber(camera.center[0]) + "," + formatNumber(camera.center[1]),
1233
- "--zoom=" + formatNumber(camera.zoom),
1234
- "--bearing=" + formatNumber(camera.bearing),
1235
- "--pitch=" + formatNumber(camera.pitch),
1236
- "--width=" + width,
1237
- "--height=" + height,
1238
- "--dpr=" + dpr,
1239
- "--out=" + quoteCliArgument(name + ".png")
1240
- ].join(" ");
1241
- if (!await copyText(command)) {
1242
- setFeedback("Clipboard access is unavailable.");
1243
- return;
1244
- }
1245
- setFeedback("Exploratory capture command copied.");
1246
- return;
1247
- }
1248
- const definition = {
1249
- theme,
1250
- camera: {
1251
- type: "center",
1252
- center: [roundNumber(camera.center[0]), roundNumber(camera.center[1])],
1253
- zoom: roundNumber(camera.zoom),
1254
- bearing: roundNumber(camera.bearing),
1255
- pitch: roundNumber(camera.pitch)
1256
- },
1257
- viewport: {width, height, dpr},
1258
- target: {kind: "map"}
1259
- };
1260
- const serialized = JSON.stringify(definition, null, 2).replace(/^/gm, " ");
1261
- if (!await copyText("'" + name + "': " + serialized.trimStart() + ",")) {
1262
- setFeedback("Clipboard access is unavailable.");
1263
- return;
1264
- }
1265
- setFeedback("Scene entry copied.");
1266
- }
1267
2493
 
1268
- function readMapViewport(side, dpr) {
1269
- const container = side.map?.getContainer?.();
1270
- const canvas = side.map?.getCanvas?.();
1271
- const maximum = dpr === 2 ? 2048 : 4096;
1272
- const width = boundedInteger(container?.clientWidth || canvas?.clientWidth, 64, maximum, 1280);
1273
- const height = boundedInteger(container?.clientHeight || canvas?.clientHeight, 64, maximum, 800);
1274
- return {width, height};
1275
- }
1276
2494
 
1277
- async function copyText(value) {
1278
- try {
1279
- await navigator.clipboard.writeText(value);
1280
- return true;
1281
- } catch {
1282
- const textarea = document.createElement("textarea");
1283
- textarea.value = value;
1284
- textarea.style.position = "fixed";
1285
- textarea.style.opacity = "0";
1286
- document.body.append(textarea);
1287
- textarea.select();
1288
- const copied = document.execCommand("copy");
1289
- textarea.remove();
1290
- return copied;
1291
- }
1292
- }
1293
2495
 
1294
2496
  function writeUrlState() {
1295
2497
  const url = new URL(location.href);
1296
2498
  url.searchParams.set("mode", stage.dataset.mode);
1297
- url.searchParams.set("split", String(Math.round(Number(splitInput.value))));
1298
- url.searchParams.set("alpha", String(Math.round(Number(alphaInput.value))));
2499
+ url.searchParams.set("split", String(Math.round(initialUi.split)));
2500
+ url.searchParams.set("alpha", String(Math.round(initialUi.alpha)));
1299
2501
  url.searchParams.set("driver", stage.dataset.driver);
1300
2502
  if (canonicalCamera) {
1301
2503
  const values = {
@@ -1310,9 +2512,9 @@ function renderTileflowComparisonHtml(options) {
1310
2512
  function readUiFromUrl() {
1311
2513
  const query = new URL(location.href).searchParams;
1312
2514
  const modeValue = singleQueryValue(query, "mode");
1313
- const mode = ["side-by-side", "split", "overlay", "blink"].includes(modeValue) ? modeValue : undefined;
2515
+ const mode = ["single", "side-by-side", "split", "overlay", "blink"].includes(modeValue) ? modeValue : undefined;
1314
2516
  const driver = singleQueryValue(query, "driver");
1315
- selectSide(driver === "right" ? "right" : "left", false);
2517
+ selectSide(driver === "right" && options.right ? "right" : "left", false);
1316
2518
  const split = singleQueryValue(query, "split");
1317
2519
  const alpha = singleQueryValue(query, "alpha");
1318
2520
  return {
@@ -1414,7 +2616,6 @@ function renderTileflowComparisonHtml(options) {
1414
2616
  if (!side.runtimeErrors.includes(message)) side.runtimeErrors.push(message);
1415
2617
  side.runtimeErrors = side.runtimeErrors.slice(-16);
1416
2618
  side.runtimeFailed = true;
1417
- renderSideStatus(side);
1418
2619
  updateDocumentReadiness();
1419
2620
  }
1420
2621
 
@@ -1482,8 +2683,32 @@ function renderTileflowComparisonHtml(options) {
1482
2683
  function roundNumber(value) { return Number(value.toFixed(6)); }
1483
2684
  function formatNumber(value) { return String(roundNumber(value)); }
1484
2685
  function wrapLongitude(value) { return ((value + 180) % 360 + 360) % 360 - 180; }
1485
- function setFeedback(value) { feedback.textContent = value; setTimeout(() => { if (feedback.textContent === value) feedback.textContent = ""; }, 2500); }
2686
+ function setFeedback(value) { globalThis.__tileflowDevtoolsUI?.feedback(value); }
2687
+ ${controlsClient}
2688
+ ${workbenchClient}
2689
+ globalThis.__tileflowDevtoolsHost = {
2690
+ schemaVersion: 1,
2691
+ subscribePreviewStatus: (child, receive) => {
2692
+ const side = Object.values(states).find((entry) => entry.frame.contentWindow === child);
2693
+ if (!side) return undefined;
2694
+ side.previewStatusReceiver = receive;
2695
+ if (side.latestArtifactStatus) receive(side.latestArtifactStatus);
2696
+ return () => { if (side.previewStatusReceiver === receive) side.previewStatusReceiver = undefined; };
2697
+ },
2698
+ sides: () => Object.values(states), active: () => states[activeSideId],
2699
+ selection: () => selectedInspection,
2700
+ clearFeatureSelection: () => {
2701
+ selectedInspection = undefined;
2702
+ for (const side of Object.values(states)) { side.highlightFeature?.(-1); side.hoverFeature?.(-1); }
2703
+ },
2704
+ loadSidecar: (side) => loadSidecar(side),
2705
+ selectFeature: (index) => { if (selectedInspection && selectedInspection.features[index]) { selectedInspection.featureIndex = index; renderInspection(); } },
2706
+ inspect: (enabled) => setInspectionEnabled(enabled), inspecting: () => inspectionEnabled,
2707
+ controls: () => loadMapControls(),
2708
+ sprites: () => loadSprites(states[activeSideId])
2709
+ };
1486
2710
  </script>
2711
+ <script type="module"${nonceAttribute} src="${fontBasePath}/__workbench-runtime/devtools.js"></script>
1487
2712
  </body>
1488
2713
  </html>`;
1489
2714
  }
@@ -1515,15 +2740,56 @@ var tileflowComparisonSchemaVersion = 1;
1515
2740
  function createTileflowComparisonRequestHandler(options) {
1516
2741
  const comparisonBasePath = normalizeTileflowBasePath(options.basePath);
1517
2742
  const left = normalizeSide(options.left, "left", comparisonBasePath);
1518
- const right = normalizeSide(options.right, "right", comparisonBasePath);
1519
- assertDisjointSideRoutes(left.basePath, right.basePath);
1520
- const title = normalizeLabel(options.title ?? "Tileflow comparison", "title");
1521
- const initialMode = options.initialMode ?? "side-by-side";
2743
+ const right = options.right ? normalizeSide(options.right, "right", comparisonBasePath) : void 0;
2744
+ if (right) assertDisjointSideRoutes(left.basePath, right.basePath);
2745
+ const workbenchUrl = options.workbench ? normalizeTileflowBasePath(options.workbench.url) : void 0;
2746
+ if (workbenchUrl !== void 0) {
2747
+ if (!workbenchUrl || comparisonBasePath && !matchesBasePath(workbenchUrl, comparisonBasePath))
2748
+ throw new TypeError(
2749
+ "Map DevTools API must own a non-root route inside the comparison base path."
2750
+ );
2751
+ assertDisjointSideRoutes(workbenchUrl, left.basePath);
2752
+ if (right) assertDisjointSideRoutes(workbenchUrl, right.basePath);
2753
+ assertDisjointSideRoutes(workbenchUrl, `${comparisonBasePath}/__workbench-fonts`);
2754
+ assertDisjointSideRoutes(workbenchUrl, `${comparisonBasePath}/__workbench-runtime`);
2755
+ }
2756
+ const title = normalizeLabel(options.title ?? "Tileflow Map DevTools", "title");
2757
+ const initialMode = right ? options.initialMode ?? "side-by-side" : "single";
1522
2758
  return async function handleTileflowComparisonRequest(request) {
1523
2759
  const url = new URL(request.url);
1524
2760
  const pathname = url.pathname;
2761
+ if (pathname === `${comparisonBasePath}/__workbench-runtime/devtools.js`) {
2762
+ if (request.method !== "GET" && request.method !== "HEAD")
2763
+ return jsonResponse({ error: "Method not allowed" }, 405);
2764
+ const source = await readFile(
2765
+ new URL("../dist/devtools-browser.js", import.meta.url),
2766
+ "utf8"
2767
+ );
2768
+ return new Response(request.method === "HEAD" ? null : source, {
2769
+ headers: {
2770
+ "Content-Type": "text/javascript; charset=utf-8",
2771
+ "Cache-Control": "no-store",
2772
+ "X-Content-Type-Options": "nosniff"
2773
+ }
2774
+ });
2775
+ }
2776
+ const font = pathname.slice(`${comparisonBasePath}/__workbench-fonts/`.length);
2777
+ if (pathname.startsWith(`${comparisonBasePath}/__workbench-fonts/`) && ["mona-sans.woff2", "hubot-sans.woff2", "inter.woff2", "jetbrains-mono.woff2"].includes(font)) {
2778
+ if (request.method !== "GET" && request.method !== "HEAD")
2779
+ return jsonResponse({ error: "Method not allowed" }, 405);
2780
+ const bytes = await readFile(new URL(`../ui-fonts/${font}`, import.meta.url));
2781
+ return new Response(request.method === "HEAD" ? null : bytes, {
2782
+ headers: {
2783
+ "Content-Type": "font/woff2",
2784
+ "Cache-Control": "public, max-age=3600",
2785
+ "X-Content-Type-Options": "nosniff"
2786
+ }
2787
+ });
2788
+ }
2789
+ if (options.workbench && workbenchUrl && matchesBasePath(pathname, workbenchUrl))
2790
+ return await options.workbench.handle(request);
1525
2791
  if (matchesBasePath(pathname, left.basePath)) return await left.handler(request);
1526
- if (matchesBasePath(pathname, right.basePath)) return await right.handler(request);
2792
+ if (right && matchesBasePath(pathname, right.basePath)) return await right.handler(request);
1527
2793
  if (isComparisonRoot(pathname, comparisonBasePath)) {
1528
2794
  if (request.method !== "GET" && request.method !== "HEAD") {
1529
2795
  return jsonResponse({ error: "Method not allowed" }, 405);
@@ -1534,9 +2800,10 @@ function createTileflowComparisonRequestHandler(options) {
1534
2800
  cspNonce,
1535
2801
  initialMode,
1536
2802
  left: publicSide(left),
1537
- right: publicSide(right),
2803
+ ...right ? { right: publicSide(right) } : {},
1538
2804
  schemaVersion: tileflowComparisonSchemaVersion,
1539
- title
2805
+ title,
2806
+ ...workbenchUrl ? { workbenchUrl } : {}
1540
2807
  });
1541
2808
  return new Response(request.method === "HEAD" ? null : html, {
1542
2809
  headers: {
@@ -1572,6 +2839,7 @@ function normalizeSide(side, name, comparisonBasePath) {
1572
2839
  if (!basePath) {
1573
2840
  throw new TypeError(`Tileflow comparison ${name} basePath must not own the server root.`);
1574
2841
  }
2842
+ assertDisjointSideRoutes(basePath, `${comparisonBasePath}/__workbench-runtime`);
1575
2843
  if (comparisonBasePath && !matchesBasePath(basePath, comparisonBasePath)) {
1576
2844
  throw new TypeError(
1577
2845
  `Tileflow comparison ${name} basePath must be inside ${comparisonBasePath}.`
@@ -1724,6 +2992,8 @@ function createTileflowDevRequestHandler(options = {}) {
1724
2992
  const lineBackgroundGlyphs = createTileflowLineBackgroundGlyphResponder({ cwd });
1725
2993
  let closed = false;
1726
2994
  let lastOrigin;
2995
+ let controlsGeneration;
2996
+ const controlsCache = /* @__PURE__ */ new Map();
1727
2997
  const loadOwnedSession = (origin) => {
1728
2998
  lastOrigin = origin;
1729
2999
  ownedSessionPromise ??= createTileflowArtifactSession({
@@ -1755,13 +3025,30 @@ function createTileflowDevRequestHandler(options = {}) {
1755
3025
  return options.session ? eventStreamResponse(options.session) : jsonResponse2({ error: "Live events require a development session." }, 404);
1756
3026
  }
1757
3027
  if (path.startsWith("/__runtime/")) {
1758
- const runtimeResponse = (await import("./preview-assets-MVUJJ5XR.js")).getTileflowPreviewRuntimeResponse(path);
3028
+ const runtimeResponse = (await import("./preview-assets-KGUICQHG.js")).getTileflowPreviewRuntimeResponse(path);
1759
3029
  if (runtimeResponse) return runtimeResponse;
1760
3030
  }
1761
3031
  const session = options.session ?? await loadOwnedSession(url.origin);
1762
3032
  acquisition = session.acquireArtifacts();
1763
3033
  const artifacts = acquisition?.artifacts;
1764
3034
  const state = session.getState();
3035
+ const controls = url.searchParams.get("controls");
3036
+ if (controls !== null) parsePreviewControls(controls);
3037
+ const controlled = (map, theme) => {
3038
+ if (!artifacts || controls === null) return void 0;
3039
+ if (controlsGeneration !== acquisition?.generation) {
3040
+ controlsCache.clear();
3041
+ controlsGeneration = acquisition?.generation;
3042
+ }
3043
+ const key = JSON.stringify([map, theme, controls]);
3044
+ let value = controlsCache.get(key);
3045
+ if (!value) {
3046
+ value = compilePreviewControls(artifacts, map, theme, controls, apiBaseUrl);
3047
+ if (controlsCache.size >= 8) controlsCache.delete(controlsCache.keys().next().value);
3048
+ controlsCache.set(key, value);
3049
+ }
3050
+ return value;
3051
+ };
1765
3052
  if (path === "/__status") {
1766
3053
  return jsonResponse2(createTileflowArtifactStatus(state));
1767
3054
  }
@@ -1769,20 +3056,24 @@ function createTileflowDevRequestHandler(options = {}) {
1769
3056
  const { renderTileflowPreviewHtml, resolveTileflowPreview } = await import("./preview.js");
1770
3057
  const requestedMaps = url.searchParams.getAll("map");
1771
3058
  const requestedThemes = url.searchParams.getAll("theme");
3059
+ const requestedScenes = url.searchParams.getAll("scene");
3060
+ if (requestedScenes.length > 1) {
3061
+ return jsonResponse2({ error: "Tileflow scene query must appear at most once." }, 400);
3062
+ }
1772
3063
  if (requestedMaps.length > 1) {
1773
3064
  return jsonResponse2({ error: "Tileflow map query must appear at most once." }, 400);
1774
3065
  }
1775
3066
  if (requestedThemes.length > 1) {
1776
3067
  return jsonResponse2({ error: "Tileflow theme query must appear at most once." }, 400);
1777
3068
  }
1778
- const requestedMap = requestedMaps.length === 1 ? requestedMaps[0] : options.map;
1779
- const requestedTheme = requestedThemes.length === 1 ? requestedThemes[0] : options.theme;
3069
+ const requestedMap = requestedMaps[0] ?? (requestedScenes.length ? void 0 : options.map);
3070
+ const requestedTheme = requestedThemes[0] ?? (requestedScenes.length ? void 0 : options.theme);
1780
3071
  const preview = artifacts ? resolveTileflowPreview(artifacts.project, {
1781
3072
  map: requestedMap,
1782
- scene: options.scene,
3073
+ scene: requestedScenes[0] ?? (requestedMaps.length ? void 0 : options.scene),
1783
3074
  theme: requestedTheme
1784
3075
  }) : void 0;
1785
- const previewStyle = preview ? artifacts?.styles[preview.mapName]?.[preview.themeName] : void 0;
3076
+ const previewStyle = preview ? controlled(preview.mapName, preview.themeName)?.style ?? artifacts?.styles[preview.mapName]?.[preview.themeName] : void 0;
1786
3077
  const isSemanticPreview = preview !== void 0 && previewStyle?.metadata?.["tileflow:compiler"] === "tileflow-semantic";
1787
3078
  const fontFaces = previewStyle ? getTileflowStyleFontFaces(previewStyle) : [];
1788
3079
  return htmlResponse(
@@ -1791,11 +3082,23 @@ function createTileflowDevRequestHandler(options = {}) {
1791
3082
  basePath,
1792
3083
  createTileflowArtifactStatus(state),
1793
3084
  isSemanticPreview,
1794
- fontFaces
3085
+ fontFaces,
3086
+ options.fitViewport,
3087
+ controls ?? void 0
1795
3088
  )
1796
3089
  );
1797
3090
  }
1798
3091
  if (!artifacts) return unavailableArtifactsResponse(state);
3092
+ if (path === "/__controls") {
3093
+ return compactJsonResponse({
3094
+ ...describePreviewControls(
3095
+ artifacts,
3096
+ url.searchParams.get("map") ?? "",
3097
+ url.searchParams.get("theme") ?? ""
3098
+ ),
3099
+ generation: acquisition?.generation
3100
+ });
3101
+ }
1799
3102
  if (path.startsWith(`/${tileflowLineBackgroundGlyphRoute}/`)) {
1800
3103
  const glyphRequest = parseTileflowLineBackgroundGlyphPath(path);
1801
3104
  const upstream = glyphRequest ? artifacts.lineBackgroundGlyphs?.[glyphRequest.mapName] : void 0;
@@ -1824,7 +3127,7 @@ function createTileflowDevRequestHandler(options = {}) {
1824
3127
  }
1825
3128
  if (path.startsWith("/__inspection/")) {
1826
3129
  const selection = getTileflowStyleInspectionSelection(path);
1827
- const inspection = selection ? artifacts.styleInspections?.[selection.mapName]?.[selection.themeName] : void 0;
3130
+ const inspection = selection ? controlled(selection.mapName, selection.themeName)?.inspection ?? artifacts.styleInspections?.[selection.mapName]?.[selection.themeName] : void 0;
1828
3131
  if (!selection || !inspection) {
1829
3132
  return jsonResponse2({ error: "Compiler inspection is unavailable." }, 404);
1830
3133
  }
@@ -1832,7 +3135,7 @@ function createTileflowDevRequestHandler(options = {}) {
1832
3135
  }
1833
3136
  if (path.startsWith("/styles/")) {
1834
3137
  const selection = getTileflowStyleSelection(path);
1835
- const style = selection ? artifacts.styles[selection.mapName]?.[selection.themeName] : void 0;
3138
+ const style = selection ? controlled(selection.mapName, selection.themeName)?.style ?? artifacts.styles[selection.mapName]?.[selection.themeName] : void 0;
1836
3139
  if (!selection || !style) {
1837
3140
  return jsonResponse2({ error: "Unknown map theme style." }, 404);
1838
3141
  }
@@ -1854,9 +3157,23 @@ function createTileflowDevRequestHandler(options = {}) {
1854
3157
  }
1855
3158
  return assetResponse(asset);
1856
3159
  }
3160
+ if (path.startsWith("/models/")) {
3161
+ const assetName = path.replace(/^\/+/, "");
3162
+ const asset = artifacts.assets.find((candidate) => candidate.fileName === assetName);
3163
+ if (!asset) {
3164
+ return jsonResponse2({ error: `Unknown model asset: ${assetName}` }, 404);
3165
+ }
3166
+ return assetResponse(asset);
3167
+ }
1857
3168
  return jsonResponse2({ error: "Not found" }, 404);
1858
3169
  } catch (error) {
1859
3170
  options.onError?.(error);
3171
+ if (url.searchParams.has("controls") || path === "/__controls") {
3172
+ return jsonResponse2(
3173
+ { error: error instanceof Error ? error.message : "Invalid map controls." },
3174
+ 400
3175
+ );
3176
+ }
1860
3177
  return tileflowErrorResponse(error);
1861
3178
  } finally {
1862
3179
  await acquisition?.release();
@@ -2004,7 +3321,7 @@ function getRequestPathname(url) {
2004
3321
  }
2005
3322
  }
2006
3323
  function isOwnedTileflowRequestPath(path) {
2007
- return path === "/" || path === "/build-manifest.json" || path === "/manifest.json" || path === "/__events" || path === "/__status" || getTileflowStyleInspectionSelection(path) !== void 0 || path.startsWith("/generations/") || path.startsWith("/icons/") || path.startsWith("/tilesets/") || getTileflowStyleSelection(path) !== void 0 || path.startsWith("/fonts/") || path.startsWith(`/${tileflowLineBackgroundGlyphRoute}/`) || path.startsWith("/__runtime/");
3324
+ return path === "/" || path === "/build-manifest.json" || path === "/manifest.json" || path === "/__events" || path === "/__status" || path === "/__controls" || getTileflowStyleInspectionSelection(path) !== void 0 || path.startsWith("/generations/") || path.startsWith("/icons/") || path.startsWith("/tilesets/") || getTileflowStyleSelection(path) !== void 0 || path.startsWith("/fonts/") || path.startsWith("/models/") || path.startsWith(`/${tileflowLineBackgroundGlyphRoute}/`) || path.startsWith("/__runtime/");
2008
3325
  }
2009
3326
  function tileflowErrorResponse(error) {
2010
3327
  if (error instanceof Error && "code" in error && error.code === "PREVIEW_SELECTION_INVALID") {