}
diff --git a/src/features/facilityWorkspace.css b/src/features/facilityWorkspace.css
index 8c39475..9edb297 100644
--- a/src/features/facilityWorkspace.css
+++ b/src/features/facilityWorkspace.css
@@ -45,4 +45,4 @@
.facility-workflow-steps>button>span{opacity:1;color:#526649}.facility-studio .quality-world-brief small{color:#546a49}
/* Canvas labels stay beneath tools; actionable asset warnings retain priority. */
.facility-canvas-tools{z-index:5}
-.facility-studio .studio-main>.ease-pending-banner{position:static;inset:auto;margin:0;flex:none;border:0;border-bottom:1px solid #dacda7;border-radius:0;box-shadow:none;padding:8px 16px;font-size:12px;line-height:1.5}
+.facility-studio .studio-main>.ease-pending-banner{position:absolute;inset:44px 12px auto;z-index:8;margin:0;border:1px solid #c5ab72;border-radius:6px;box-shadow:0 3px 12px #29331b17;padding:8px 16px;font-size:12px;line-height:1.5;pointer-events:none}
diff --git a/src/scene/SceneFallback.tsx b/src/scene/SceneFallback.tsx
new file mode 100644
index 0000000..6611af6
--- /dev/null
+++ b/src/scene/SceneFallback.tsx
@@ -0,0 +1,19 @@
+import { Component, type ReactNode } from 'react';
+import type { Project } from '../model/types';
+import type { SelectionIntent } from '../features/editorSelection';
+import { stepKindLabels } from '../ui/messages/labels';
+import { worldSceneMessages as m } from '../ui/messages/WorldScene';
+import './sceneFallback.css';
+
+export function SceneFallback({ project, selected, onSelect }: { project: Project; selected: string | null; onSelect: (id: string, intent?: SelectionIntent) => void }) {
+ return
;
+}
+
+// A failed graphics chunk must not take the editor, calculation or saves down.
+export class SceneBoundary extends Component<{ children: ReactNode; fallback: ReactNode }, { failed: boolean }> {
+ state = { failed: false };
+ static getDerivedStateFromError() { return { failed: true }; }
+ render() { return this.state.failed ? this.props.fallback : this.props.children; }
+}
diff --git a/src/scene/WorldScene.tsx b/src/scene/WorldScene.tsx
index 9a47799..6206b77 100644
--- a/src/scene/WorldScene.tsx
+++ b/src/scene/WorldScene.tsx
@@ -20,6 +20,7 @@ import { operatorTargets } from './operatorLayout';
import { WorldSurroundings } from './WorldSurroundings';
import { placeLabel, type LabelRect } from './labelLayout';
import { stepKindLabels } from '../ui/messages/labels';
+import { SceneFallback } from './SceneFallback';
import { worldSceneMessages as m } from '../ui/messages/WorldScene';
import { facilityMessages as fm } from '../ui/messages/facility';
import { findLibraryAsset } from '../catalog/registry';
@@ -81,7 +82,7 @@ export function WorldScene(props: Props) {
renderer.domElement.dataset.testid = 'world-canvas'; renderer.domElement.tabIndex = 0;
renderer.domElement.setAttribute('role', 'img');
renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.12;
- renderer.setClearColor('#dedacf'); renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+ renderer.setClearColor('#dedacf'); renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.shadowMap.autoUpdate = false;
const store = new CatalogStore(); let disposed = false;
const instance: Lifetime = { renderer, store, dispose() {
@@ -92,7 +93,7 @@ export function WorldScene(props: Props) {
lifetime.current = instance; host.prepend(renderer.domElement);
setContextFailed(false);
return () => instance.dispose();
- } catch { setContextFailed(true); setFailed(true); }
+ } catch { host.dataset.status = 'unavailable'; setContextFailed(true); setFailed(true); }
}, [attempt]);
useEffect(() => {
@@ -740,7 +741,7 @@ export function WorldScene(props: Props) {
canvas.addEventListener('pointerdown', pointerDown); canvas.addEventListener('pointermove', pointerMove); canvas.addEventListener('pointerup', pointerUp); canvas.addEventListener('pointercancel', cancel);
host.addEventListener('dragover', dragOver); host.addEventListener('dragleave', dragLeave); host.addEventListener('drop', drop);
const lostCapture = () => { if (down || facilityDown) cancel(); };
- const contextLost = () => { setContextFailed(true); setFailed(true); requestRender(); };
+ const contextLost = () => { host.dataset.status = 'unavailable'; setContextFailed(true); setFailed(true); requestRender(); };
const contextRestored = () => { setContextFailed(false); status(); requestRender(); };
const pointerLeave = () => { if (hoveredId) { hoveredId = null; requestRender(); } };
canvas.addEventListener('lostpointercapture', lostCapture); canvas.addEventListener('pointerleave', pointerLeave);
@@ -1045,8 +1046,9 @@ export function WorldScene(props: Props) {
useEffect(() => { fitProject.current(props.project.id); }, [props.project.id]);
useEffect(() => { invalidate.current(); }, [props.project, props.frame, props.selected, props.selectedIds, props.selectedFacilityId, props.moving, props.flow, props.reducedMotion, props.playing, props.seekEpoch, props.runKey]);
useEffect(() => { cancelGesture.current(); }, [props.project.id, props.project.revision, props.moving]);
- return
+ return
mount.current?.dispatchEvent(new CustomEvent('queuelab:zoom', { detail: 1.25 }))}> mount.current?.dispatchEvent(new CustomEvent('queuelab:zoom', { detail: .8 }))}> mount.current?.dispatchEvent(new Event('queuelab:fit'))}>
+ {contextFailed &&
}
{failed &&
{contextFailed ? m.contextUnavailable : m.assetsUnavailable} retryAssets.current()}> {m.retryWorld}
}
;
}
diff --git a/src/scene/sceneFallback.css b/src/scene/sceneFallback.css
new file mode 100644
index 0000000..05431c8
--- /dev/null
+++ b/src/scene/sceneFallback.css
@@ -0,0 +1,4 @@
+.scene-unavailable{position:relative;min-height:250px;height:100%;background:#dedacf}
+.scene-text-fallback{position:absolute;inset:100px 24px 16px;overflow:auto;padding:20px;background:#f9fbf6;border:1px solid #c5d3bc;border-radius:12px;color:#294a32;max-width:640px;margin-inline:auto;z-index:3}.scene-text-fallback h3{font-size:20px;margin:0 0 10px}.scene-text-fallback>p{font-size:13px;line-height:1.6;margin:0 0 16px}.scene-text-fallback ol{padding:0;margin:0;list-style:none;display:grid;gap:7px}.scene-text-fallback button{width:100%;min-height:44px;padding:10px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px;text-align:left;border:1px solid #c5d3bc;border-radius:7px;background:#fff;color:#294a32;font:inherit;font-size:13px}.scene-text-fallback button[aria-pressed=true]{background:#e7f1d9;border-color:#608b43}.scene-text-fallback button>span{font-size:11px;color:#52664e}.scene-text-fallback button:focus-visible{outline:3px solid #608b43;outline-offset:2px}.learning-focused .scene-text-fallback li:not(:has([data-station-id="station-b"])){display:none}@media(max-width:740px){.scene-text-fallback{inset:85px 12px 12px;padding:12px}.scene-text-fallback h3{font-size:16px;margin-bottom:7px}.scene-text-fallback>p{font-size:12px;line-height:1.4;margin-bottom:10px}.learning-focused .scene-text-fallback>p{display:none}.learning-focused .scene-text-fallback h3{display:none}}
+
+.scene-unavailable>.scene-asset-warning{position:absolute;top:12px;left:12px;right:12px;z-index:4;padding:12px;background:#fff3d5;color:#604415;border:1px solid #c5ab72;border-radius:8px;font-size:12px;line-height:1.5}
diff --git a/src/scene/workspaceScene.css b/src/scene/workspaceScene.css
index 1e43a02..13ed406 100644
--- a/src/scene/workspaceScene.css
+++ b/src/scene/workspaceScene.css
@@ -31,3 +31,5 @@
.scene-asset-warning button{display:flex;align-items:center;gap:7px;margin-top:11px;min-height:34px;padding:6px 10px;border:1px solid #ccaf7b;border-radius:6px;background:#fffaf0;color:#775322;font-size:12px}
@media(max-width:740px){.catalog-world .station-label{min-height:36px;max-width:min(205px,calc(100% - 20px));font-size:11px;padding:7px 9px}.catalog-world .station-label.scene-node-queue{font-size:10px}.catalog-world .station-count{font-size:10px}.catalog-world .scene-camera-tools{right:10px;bottom:10px}.catalog-world .scene-camera-tools button{width:40px;height:40px}.scene-move-readout{font-size:11px;max-width:calc(100% - 24px)}}
@media(prefers-reduced-motion:reduce){.catalog-world .station-label{transition:none}}
+
+.is-context-unavailable :is(canvas,.scene-labels,.scene-camera-tools){display:none}
diff --git a/src/styles.css b/src/styles.css
index 19f4dd8..d9ee7f8 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1,4 +1,3 @@
-@import url('/fonts/fonts.css');
:root{font-family:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#293b33;background:#f6f5f0;font-synthesis:none;font-optical-sizing:auto;-webkit-font-smoothing:antialiased;--accent:#d96b42;--accent-dark:#bb512e;--ink:#293b33;--quiet:#7b847b;--line:#e1e4da;--paper:#faf9f5;--green:#5e7b66}
*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,a,input,select{touch-action:manipulation}button{cursor:pointer;color:inherit}button:disabled{cursor:not-allowed;opacity:.38}button,a{ -webkit-tap-highlight-color:transparent }button{border:0;background:none}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #ad5f38;outline-offset:3px}a{color:inherit}button svg{flex-shrink:0}input[type=range]{accent-color:var(--accent)}input[type=number]{appearance:textfield;-moz-appearance:textfield}input::-webkit-inner-spin-button,input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.app-shell{display:flex;min-height:100vh;overflow:hidden}.sidebar{width:208px;flex:0 0 208px;background:#f0f1e9;border-right:1px solid var(--line);padding:31px 18px 20px;display:flex;flex-direction:column;z-index:3}.brand{display:flex;align-items:center;text-decoration:none;letter-spacing:-1.4px;font-size:26px;font-weight:780;margin:0 9px 51px}.brand-mark{display:grid;place-items:center;color:var(--accent);margin-right:7px;transform:rotate(-5deg)}.brand-dot{color:var(--accent)}.sidebar-section-label{font-size:9px;font-weight:650;letter-spacing:1.7px;color:#899184;margin:0 12px 16px}.nav-item{width:100%;display:flex;align-items:center;gap:11px;text-align:left;padding:12px 11px;border-radius:6px;font-size:12px;font-weight:550;color:#737d71;margin-bottom:5px;transition:background .15s,color .15s}.nav-item:hover{background:#e9ece2;color:var(--ink)}.nav-item.active{background:#e1e8d9;color:#38573f}.nav-item.active svg{color:#557954}.nav-shortcut{margin-left:auto;font-size:9px;color:#8e9b85}.sidebar-rule{height:1px;background:var(--line);margin:25px 12px 28px}.process-list{display:flex;flex-direction:column;gap:3px}.process-item{display:flex;align-items:center;gap:10px;text-align:left;padding:12px 11px;font-size:11px;color:#818778;white-space:nowrap;border-radius:4px}.process-index{font-size:9px;color:#96a08d;font-variant-numeric:tabular-nums;letter-spacing:.5px}.process-item.selected{color:#334b38;background:#e9ede2}.process-item:hover{color:#334b38}.process-dot{width:5px;height:5px;background:#a5b89c;border-radius:50%;margin-left:auto}.process-item.selected .process-dot{background:var(--accent)}.side-add{display:flex;gap:9px;align-items:center;padding:16px 11px;font-size:11px;color:#88937e}.sidebar-bottom{margin-top:auto;padding-top:70px}.local-note{font-size:9px;line-height:1.6;display:flex;gap:6px;align-items:center;margin:0 7px 17px;color:#88937f}.local-note>svg{display:none}.sidebar-bottom .nav-item{font-size:11px;gap:8px}.sidebar-bottom .nav-item>svg:last-child{margin-left:auto}.sidebar-foot{font-size:7px;letter-spacing:1px;margin:24px 10px 0;color:#909986;display:flex;justify-content:space-between}.sidebar-foot span{color:#a4ad9a;font-size:6px;letter-spacing:.8px}.main-shell{flex:1;min-width:0;display:flex;flex-direction:column}.topbar{height:68px;flex-shrink:0;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);padding:0 33px;background:var(--paper)}.breadcrumb{display:flex;align-items:center;gap:13px;color:#909789;font-size:10px}.breadcrumb>span:first-of-type{color:#4f604f;font-weight:550}.synthetic-label{font-size:6.5px;letter-spacing:1px;border:1px solid #dfe4d7;padding:4px 6px;color:#95a088!important;font-weight:500!important;margin-left:2px}.top-actions{display:flex;align-items:center;gap:13px}.icon-button{width:29px;height:29px;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;color:#778573;transition:background .15s,color .15s}.icon-button:hover,.icon-button.selected{background:#e7ecdf;color:#344e37}.text-button{display:flex;gap:7px;align-items:center;font-size:10px;color:#536450;padding:8px 0}.version-tag{font-size:7px;letter-spacing:1.5px;color:#939e8a;padding-left:13px;border-left:1px solid var(--line)}.workspace-heading{padding:30px 33px 25px;display:flex;justify-content:space-between;align-items:center;gap:20px;background:var(--paper)}.eyebrow{font-size:8px;font-weight:600;letter-spacing:1.65px;display:flex;gap:6px;align-items:center;color:#89937f}.eyebrow>span{width:4px;height:4px;background:var(--accent);border-radius:50%}h1{font-size:39px;line-height:1.2;letter-spacing:-1.7px;font-weight:550;margin:9px 0 7px}.title-period{color:var(--accent)}.workspace-heading p{font-size:11px;color:#8a9382;margin:0}.heading-actions{display:flex;align-items:center;gap:10px;margin-top:16px}.primary-button,.secondary-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:37px;padding:9px 14px;border-radius:5px;font-size:10px;font-weight:550;white-space:nowrap;transition:background .15s,transform .15s}.primary-button{background:var(--accent);color:#fff5e9;box-shadow:0 2px 3px #9b532c12}.primary-button:hover{background:var(--accent-dark);transform:translateY(-1px)}.secondary-button{border:1px solid #dbe1d2;background:#f8f9f2;color:#5d6d56}.secondary-button:hover{background:#eef1e6}.key-hint{font-size:10px;border:1px solid #fff4;padding:1px 4px;border-radius:3px;margin-left:10px;color:#ffe0c6}.workspace-body{display:flex;flex:1;min-height:0;background:#f3f4ed;border-top:1px solid var(--line)}.world-workspace{min-width:0;flex:1;display:flex;flex-direction:column}.world-toolbar{height:54px;display:flex;align-items:center;justify-content:space-between;padding:0 28px;border-bottom:1px solid #e5e8dc;background:#f5f6ef;z-index:2}.world-tabs{display:flex;align-self:stretch;gap:21px}.world-tabs>button{display:flex;gap:6px;align-items:center;position:relative;padding:0 0 1px;font-size:10px;color:#909986}.world-tabs>button.selected{color:#4f694a}.world-tabs>button.selected:after{content:'';position:absolute;bottom:-1px;height:2px;left:0;right:0;background:#6e8c60}.world-tools{display:flex;align-items:center;gap:3px}.tool-divider{height:15px;width:1px;background:#dce1d1;margin:0 6px}.scene-area{position:relative;min-height:430px;flex:1;isolation:isolate;overflow:hidden;background:radial-gradient(ellipse at 50% 52%,#eef0e5 0,#f0f2e8 55%,#f3f4ed 100%)}.world-scene{position:absolute;inset:0}.world-scene canvas{display:block;width:100%;height:100%;cursor:grab;touch-action:none}.world-scene canvas:active{cursor:grabbing}.scene-topline{position:absolute;top:21px;left:28px;right:28px;display:flex;align-items:center;justify-content:space-between;z-index:2;pointer-events:none}.live-label{font-size:8px;letter-spacing:1.4px;color:#779068;display:flex;align-items:center;gap:6px}.status-dot{display:inline-block;height:5px;width:5px;background:#80a477;border-radius:50%;flex-shrink:0}.status-dot.orange{background:var(--accent)}.scene-unit{font-size:7px;letter-spacing:1.6px;color:#a1aa96}.scene-label-layer{position:absolute;inset:0;pointer-events:none;overflow:hidden}.station-label{position:absolute;top:0;left:0;pointer-events:auto;white-space:nowrap;display:flex;align-items:center;gap:6px;background:#fafbf5ed;border:1px solid #d6dfcd;border-radius:5px;padding:6px 9px;color:#6d7f65;font-size:9px;line-height:1;box-shadow:0 2px 3px #435c3610;transition:border-color .2s,background .2s}.station-label:hover{background:#fff;border-color:#9cae8c}.station-label.is-selected{border-color:#c88a60;color:#b76b40;background:#fff9ec}.station-label .station-number{font-size:7px;opacity:.6}.station-queue{color:#a17a44;font-size:7px;display:none;border-left:1px solid #dfe2d2;padding-left:5px}.station-label.has-queue .station-queue{display:inline}.scene-caption{position:absolute;bottom:19px;left:28px;font-size:8px;color:#99a28c;display:flex;align-items:center;gap:6px;pointer-events:none}.scene-caption>span{width:2px;height:2px;border-radius:50%;background:#a6ae9a;margin:0 3px}.world-compass{position:absolute;bottom:16px;right:22px;display:flex;flex-direction:column;align-items:center;gap:0;pointer-events:none;opacity:.7}.world-compass>span{font-size:7px;letter-spacing:2px;color:#8a9b7f}.playback-bar{height:57px;flex-shrink:0;display:flex;align-items:center;gap:11px;padding:0 27px;background:#f8f9f2;border-top:1px solid #e0e6d6;border-bottom:1px solid var(--line)}.play-toggle{width:27px;height:27px;display:grid;place-items:center;background:#e5ebdc;border-radius:4px;color:#59764f}.playback-time{font-size:10px;font-variant-numeric:tabular-nums;min-width:34px;color:#61705a}.playback-end{font-size:9px;font-variant-numeric:tabular-nums;color:#a0aa92}.playback-bar input[type=range]{flex:1;min-width:60px;height:3px;background:#dce3d0}.playback-bar select{background:transparent;border:1px solid #e0e5d7;border-radius:3px;padding:4px;font-size:9px;color:#6c7c5f;margin-left:3px}.metrics-strip{display:grid;grid-template-columns:repeat(4,1fr);padding:25px 29px 22px;background:#fafbf5;gap:20px}.metrics-strip>div{display:flex;flex-direction:column;gap:9px}.metrics-strip>div+div{border-left:1px solid #e6eadf;padding-left:22px}.metric-label{display:flex;align-items:center;gap:6px;font-size:9px;color:#839174}.tiny-square{height:5px;width:5px;display:inline-block;border-radius:1px}.tiny-square.green{background:#80a472}.tiny-square.amber{background:#cead64}.tiny-square.orange{background:#d9875e}.tiny-square.grey{background:#a4ae9b}.metrics-strip strong{font-size:25px;letter-spacing:-.9px;font-weight:500;line-height:1.2;font-variant-numeric:tabular-nums;color:#3b563e}.metrics-strip small{font-size:9px;letter-spacing:0;color:#a1aa93;font-weight:400;margin-left:6px}.metric-foot{font-size:7px;color:#9da790;white-space:nowrap}.inspector{width:268px;flex:0 0 268px;border-left:1px solid var(--line);padding:27px 24px 20px;background:#f8f9f2;display:flex;flex-direction:column}.inspector-eyebrow{font-size:7px;color:#9ba58f;letter-spacing:1.6px;display:flex;justify-content:space-between;align-items:center}.station-title{display:flex;gap:12px;align-items:center;margin-top:25px}.large-station-number{font-size:27px;letter-spacing:-1px;color:#c69d70;font-weight:400}.station-title h2{font-size:17px;line-height:1.2;letter-spacing:-.4px;margin:0 0 5px;font-weight:550}.station-title div>span{font-size:8px;color:#9aa58e}.inspector-divider{height:1px;background:#e0e6d7;margin:23px 0}.field-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px;font-size:10px;color:#6f7f63}.field-help{border:1px solid #c9d3be;color:#a0ad93;border-radius:50%;font-size:7px;height:11px;width:11px;display:grid;place-items:center;cursor:help}.stepper{display:flex;height:37px;border:1px solid #dce3d1;border-radius:4px;background:#fcfdf7;overflow:hidden}.stepper>button{width:37px;display:grid;place-items:center;color:#8b9c7c}.stepper>button:hover{background:#edf2e4}.stepper output{flex:1;text-align:center;align-content:center;font-size:14px;font-variant-numeric:tabular-nums;color:#4d6647}.field-description{font-size:9px;line-height:1.7;color:#a0aa91;margin:10px 0 22px}.input-label{display:block;font-size:10px;color:#6f7f63;margin-bottom:10px}.input-with-unit{height:37px;border:1px solid #dce3d1;border-radius:4px;display:flex;align-items:center;background:#fcfdf7;padding:0 11px}.input-with-unit input{width:45px;flex:1;min-width:0;border:0;background:transparent;color:#4d6647;font-size:12px;outline:0;font-variant-numeric:tabular-nums}.input-with-unit span{font-size:8px;color:#9aa88d;white-space:nowrap}.distribution-note{font-size:8px;color:#a1ad91;margin-top:8px}.subtle-link{display:flex;gap:4px;align-items:center;font-size:9px;color:#7d916b;margin-top:15px;padding:0;align-self:flex-start}.subtle-link:hover{color:#3f6034}.inspector-section-title{font-size:7px;color:#8f9e80;letter-spacing:1.2px;display:flex;justify-content:space-between}.inspector-section-title span{font-size:6px;letter-spacing:1px;color:#b0b9a3}.station-stats{margin:19px 0 0}.station-stats>div{display:flex;justify-content:space-between;align-items:center;font-size:9px;margin:0 0 15px}.station-stats dt{color:#94a286}.station-stats dd{color:#546f4b;font-weight:500;font-variant-numeric:tabular-nums;margin:0}.inspector-tip{display:flex;gap:9px;background:#edf1e2;border:1px solid #e3e9d6;border-radius:5px;padding:13px 10px;margin-top:13px;color:#84966f}.inspector-tip p{font-size:8px;line-height:1.7;margin:0}.inspector-tip b{display:block;font-size:9px;font-weight:550;color:#6f8759;margin-bottom:4px}.inspector-tip>svg{margin-top:1px;flex-shrink:0;color:#a1b287}.inspector-compare{display:flex;justify-content:space-between;align-items:center;font-size:9px;color:#778e62;padding:17px 0}.inspector-bottom{font-size:7px;line-height:1.7;display:flex;align-items:flex-start;gap:5px;margin-top:auto;padding-top:34px;color:#a2ad94}.inspector-bottom>svg{flex-shrink:0}.workspace-footer{height:40px;flex-shrink:0;padding:0 29px;border-top:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;background:var(--paper);font-size:8px;color:#9da790}.workspace-footer>span{display:flex;align-items:center;gap:7px}.footer-separator{height:2px;width:2px;background:#b2bba4;border-radius:50%;margin:0 4px}.workspace-footer button{padding:0;font-size:8px;color:#8d9d7a}.draft-banner{position:absolute;bottom:50px;left:20px;right:20px;background:#fff8e9;border:1px solid #e4d6b7;border-radius:5px;padding:10px 12px;display:flex;align-items:center;gap:7px;font-size:10px;color:#a57b41;box-shadow:0 3px 10px #6b4e2510}.draft-banner button{margin-left:auto;display:flex;align-items:center;gap:5px;font-size:9px;color:#9d6a31;font-weight:600}.scene-fallback{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;text-align:center;color:#7b8b6c}.scene-fallback h3{font-size:18px}.scene-fallback p{font-size:12px;max-width:300px;line-height:1.7}.spin{animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:100;background:#304b3d;color:#f3f6eb;border-radius:6px;box-shadow:0 8px 24px #24392322;padding:14px 15px 14px 19px;font-size:12px;display:flex;align-items:center;gap:18px;max-width:min(620px,90vw);line-height:1.6;animation:toast-in .2s ease-out}.toast>button{display:grid;place-items:center;color:#c4d3b7}.cancel-run{position:fixed;bottom:20px;right:20px;z-index:101;background:#f8f4e8;border:1px solid #dfd1b2;padding:10px 13px;border-radius:4px;color:#a77a3f;font-size:11px;display:flex;gap:7px;align-items:center}.modal-backdrop{position:fixed;inset:0;z-index:50;background:#273b3250;backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;padding:28px;animation:fade-in .15s}.modal{background:#fafaf4;border:1px solid #e1e7d6;border-radius:9px;width:760px;max-width:100%;max-height:90vh;overflow-y:auto;padding:31px;box-shadow:0 24px 100px #20332730;animation:modal-in .2s ease-out}.wide-modal{width:940px}.modal-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:27px}.modal h2{font-size:29px;font-weight:500;letter-spacing:-1.1px;margin:9px 0 0}.modal h3{font-weight:550}.modal-intro{font-size:13px;color:#7e8c72;line-height:1.8;margin:0 0 25px}.world-gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:19px}.world-gallery>button{text-align:left;padding:0}.world-gallery>button:hover .world-mini{transform:translateY(-3px);background:#e5ecdc}.world-mini{height:140px;background:#ebefe2;display:flex;align-items:center;justify-content:center;border-radius:5px;color:#b48d64;transition:transform .2s,background .2s}.world-mini.festival{color:#779c9b;background:#e4ede6}.world-mini.service{color:#9b8ba5;background:#ede9ed}.world-gallery h3{font-size:14px;letter-spacing:-.3px;display:flex;justify-content:space-between;gap:5px;align-items:center;margin:17px 0 8px}.world-gallery p{font-size:10px;line-height:1.8;color:#8c9980;margin:0 0 16px}.gallery-foot{font-size:6px;letter-spacing:1px;color:#a2ad94}.comparison-table{border-top:1px solid #dbe2cf;margin:20px 0 23px}.comparison-row{display:grid;grid-template-columns:1.5fr 1fr 1fr;gap:15px;padding:18px 0;border-bottom:1px solid #e6ebdd;align-items:center;font-size:13px}.comparison-row strong{font-weight:500;font-variant-numeric:tabular-nums;color:#526d45}.comparison-row>span{color:#8a987a;font-size:11px}.comparison-row.table-header>span{font-size:8px;letter-spacing:1.5px;color:#a1ad91}.comparison-note{display:flex;gap:8px;align-items:center;font-size:10px;line-height:1.8;color:#94a083;background:#f1f4e9;padding:14px;margin-bottom:20px;border-radius:4px}.comparison-note>svg{flex-shrink:0}.model-stations{border-top:1px solid var(--line)}.model-station-row{display:grid;grid-template-columns:24px 1.1fr 1fr 1fr;gap:13px;align-items:center;padding:18px 0;border-bottom:1px solid var(--line);font-size:12px}.model-station-row>input{padding:9px;border:1px solid #dce4d0;border-radius:4px;background:#fdfef9;min-width:0;width:100%;font-size:12px;color:#546b49}.model-station-row>label{display:flex;gap:10px;align-items:center;color:#8c9c7a;font-size:10px}.model-station-row>label>input{width:45px;border:1px solid #dce4d0;background:#fdfef9;border-radius:3px;padding:6px;color:#546b49;font-size:12px;text-align:center}.model-station-row>span:last-child{font-size:10px;color:#8c9c7a}.model-stations+.comparison-note{margin-top:20px}.help-content{font-size:13px;line-height:1.9;color:#7d8e6c}.help-content ol{padding-left:20px}.help-content li{margin:15px 0;padding-left:7px}.help-content b{color:#4a6640;font-weight:550}.quiet-copy{font-size:11px;color:#9aa78b;line-height:1.8}.validation-list{font-size:12px;line-height:1.7;color:#b46e46}.validation-list li{margin:8px 0}@keyframes toast-in{from{opacity:0;translate:0 5px}to{opacity:1;translate:0 0}}@keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes modal-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
diff --git a/src/ui/interface.css b/src/ui/interface.css
index d28d532..400ef79 100644
--- a/src/ui/interface.css
+++ b/src/ui/interface.css
@@ -443,3 +443,5 @@
@media(min-width:741px) {
.facility-studio .world-gallery>button:not(.ease-new-model) { grid-template-columns: minmax(0,1.15fr) minmax(190px,1fr); }
}
+
+.workspace-skip-link{position:fixed;top:8px;left:12px;transform:translateY(-200%);z-index:1000;padding:12px 16px;border-radius:6px;background:#173a2c;color:#fff;font-size:14px;text-decoration:none}.workspace-skip-link:focus{transform:none;outline:3px solid #c5e995;outline-offset:2px}.studio-main:focus{outline:none}
diff --git a/src/ui/messages/App.ts b/src/ui/messages/App.ts
index 0055725..547f760 100644
--- a/src/ui/messages/App.ts
+++ b/src/ui/messages/App.ts
@@ -1,6 +1,7 @@
/** English product messages for App. Keep model data and canonical identifiers at the call site. */
import { BRAND } from '../../brand';
export const appMessages = {
+ skipToWorkspace: 'Skip to workspace',
revisionLimitReached: 'Revision limit reached. Duplicate this project to continue editing.',
duplicateToEdit: 'Duplicate to edit',
build: 'Asset library',
@@ -140,11 +141,11 @@ export const appMessages = {
watchDemos: "Watch the video demos",
watchDemosNewTab: "Watch the video demos (opens in a new tab)",
freeOpenSource: "FREE & OPEN SOURCE ",
- earlyBuild: "RC16",
+ earlyBuild: "RC17",
syntheticExample: "SYNTHETIC EXAMPLE",
openProject: "Open project",
saveProject: "Download project JSON",
- buildBadge: "V1 / RC16",
+ buildBadge: "V1 / RC17",
aPlaygroundForBetterDecisions: "A PLAYGROUND FOR BETTER DECISIONS",
calculationWorkLimits: "Calculation work & limits",
calculationProgress: (percent: number) => `${percent}% calculating`,
diff --git a/src/ui/messages/WorldScene.ts b/src/ui/messages/WorldScene.ts
index 1bb890f..6025249 100644
--- a/src/ui/messages/WorldScene.ts
+++ b/src/ui/messages/WorldScene.ts
@@ -1,7 +1,12 @@
/** English product messages for WorldScene. Keep model data and canonical identifiers at the call site. */
import { BRAND } from '../../brand';
export const worldSceneMessages = {
- contextUnavailable: '3D graphics could not start. Retry the world, or use Tools to edit the model.',
+ contextUnavailable: 'The 3D view is unavailable. Your project and calculations are kept.',
+ previewUnavailable: 'The 3D preview could not load. Your model still works. Save your work before reloading to try the preview again.',
+ fallbackTitle: 'Your process',
+ fallbackHelp: 'Choose a step below to inspect or change it. You can still run your model and save your work.',
+ fallbackSteps: 'Process steps without the 3D view',
+ fallbackLayout: 'Your layout is kept. Use Placed to inspect its objects, or retry the 3D view.',
assetsUnavailable: 'Some equipment could not load. Model editing remains available.',
retryWorld: 'Retry 3D world',
actionsFor: (label: string) => `Actions for ${label}`,
diff --git a/src/ui/messages/learning.ts b/src/ui/messages/learning.ts
index ca970eb..7e0ce16 100644
--- a/src/ui/messages/learning.ts
+++ b/src/ui/messages/learning.ts
@@ -1,4 +1,5 @@
export const learningMessages = {
+ skipToLesson: 'Skip to lesson',
title: 'Your first example', section: 'LEARN BY DOING', entry: 'Learn by doing',
welcome: 'Can one extra person reduce the wait?',
introduction: 'Loopset lets you try a change and see what happens. Let’s try it with a small help desk.',
diff --git a/tests/browser-runner.test.mjs b/tests/browser-runner.test.mjs
new file mode 100644
index 0000000..d78cf3d
--- /dev/null
+++ b/tests/browser-runner.test.mjs
@@ -0,0 +1,19 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { createServer } from 'node:http';
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import { once } from 'node:events';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+test('browser runner refuses an occupied port and leaves the existing server alive', async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'loopset-browser-'));
+ const server = createServer((_request, response) => response.end('existing-server'));
+ try {
+ await fs.writeFile(path.join(root, 'index.html'), '
');
+ server.listen(0, '127.0.0.1'); await once(server, 'listening'); const port = server.address().port;
+ await assert.rejects(promisify(execFile)(process.execPath, ['scripts/browser-checks.mjs'], { env: { ...process.env, QUEUELAB_BROWSER_PORT: String(port), QUEUELAB_BROWSER_DIST: root, QUEUELAB_EVIDENCE_DIR: path.join(root, 'evidence') }, timeout: 40000 }), error => error.code === 1 && /Port .* is already in use/.test(error.stderr));
+ assert.equal(await fetch(`http://127.0.0.1:${port}/`).then(r => r.text()), 'existing-server');
+ } finally { await new Promise(resolve => server.close(resolve)); await fs.rm(root, { recursive: true, force: true }); }
+});
diff --git a/tests/dev-boundaries.test.mjs b/tests/dev-boundaries.test.mjs
new file mode 100644
index 0000000..06ff904
--- /dev/null
+++ b/tests/dev-boundaries.test.mjs
@@ -0,0 +1,25 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { createServer } from 'vite';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { randomUUID } from 'node:crypto';
+test('dev HTTP blocks private folders and overrides, including raw and filesystem URLs', async () => {
+ const root = process.cwd(), name = `boundary-${randomUUID()}`, created = [];
+ let server;
+ try {
+ for (const dir of ['.private', '.cache']) {
+ await fs.mkdir(path.join(root, dir), { recursive: true });
+ const file = path.join(root, dir, `${name}.md`); await fs.writeFile(file, 'Synthetic private fixture', { flag: 'wx' }); created.push(file);
+ }
+ const override = path.join(root, `${name}.override.md`); await fs.writeFile(override, 'Synthetic override fixture', { flag: 'wx' }); created.push(override);
+ server = await createServer({ logLevel: 'silent', server: { host: '127.0.0.1', port: 0, strictPort: true } });
+ await server.listen(); const address = server.httpServer.address(); assert.equal(typeof address.port, 'number');
+ const origin = `http://127.0.0.1:${address.port}${server.config.base}`;
+ assert.equal((await fetch(new URL('README.md', origin), { method: 'HEAD' })).status, 200);
+ for (const file of created) {
+ const relative = path.relative(root, file).split(path.sep).join('/');
+ for (const url of [new URL(relative, origin), new URL(`${relative}?raw`, origin), new URL(`@fs/${file.split(path.sep).join('/')}`, origin)]) assert.equal((await fetch(url, { method: 'HEAD' })).status, 403);
+ }
+ } finally { await server?.close(); for (const file of created) await fs.unlink(file); }
+});
diff --git a/tests/docs.test.mjs b/tests/docs.test.mjs
new file mode 100644
index 0000000..2af7890
--- /dev/null
+++ b/tests/docs.test.mjs
@@ -0,0 +1,29 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { checkDocumentation, headingAnchors } from '../scripts/check-docs.mjs';
+test('heading anchors preserve real headings, duplicate IDs and explicit anchors without code examples', () => {
+ assert.deepEqual([...headingAnchors('# Start\n## Same\n## Same\n## `Use API` & files\n## Déjà vu\n```md\n# Hidden\n```\n
')], ['start', 'same', 'same-1', 'use-api--files', 'déjà-vu', 'example']);
+});
+test('local document and image links pass; missing files, anchors and paths outside the repo fail', () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'loopset-docs-'));
+ try {
+ fs.mkdirSync(path.join(root, 'docs')); fs.writeFileSync(path.join(root, 'asset.svg'), '
');
+ fs.writeFileSync(path.join(root, 'docs/guide.md'), '# Hello\n## Example\n');
+ fs.writeFileSync(path.join(root, 'README.md'), '# Demo\n[Guide](docs/guide.md#example)\n
\n[Web](https://example.com/)\n```md\n[Not a link](missing.md)\n```');
+ assert.equal(checkDocumentation(root, ['README.md', 'docs/guide.md']).errors.length, 0);
+ fs.writeFileSync(path.join(root, 'README.md'), '[Gone](gone.md) [Wrong heading](docs/guide.md#gone) [Outside](../outside.md)');
+ assert.equal(checkDocumentation(root, ['README.md']).errors.length, 3);
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
+});
+
+test('documentation links cannot read a symlink outside the repository', { skip: process.platform === 'win32' }, () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'loopset-docs-')), outside = fs.mkdtempSync(path.join(os.tmpdir(), 'loopset-outside-'));
+ try {
+ fs.writeFileSync(path.join(outside, 'note.md'), '# Outside'); fs.symlinkSync(path.join(outside, 'note.md'), path.join(root, 'linked.md'));
+ fs.writeFileSync(path.join(root, 'README.md'), '[Link](linked.md#outside)');
+ assert.equal(checkDocumentation(root, ['README.md']).errors.length, 1);
+ } finally { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(outside, { recursive: true, force: true }); }
+});
diff --git a/vite.config.ts b/vite.config.ts
index ed96d49..040af6e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -37,6 +37,11 @@ function socialMetadata(value: string | undefined): HtmlTagDescriptor[] {
export default defineConfig({
base: publicSiteURL(process.env.QUEUELAB_PUBLIC_URL)?.pathname ?? '/',
+ server: {
+ watch: { ignored: ['**/.private/**', '**/.cache/**', '**/dist-engine/**'] },
+ // Preserve Vite's secret defaults while excluding local-only material.
+ fs: { deny: ['.env', '.env.*', '*.{crt,pem,key,p12,pfx,cer,der}', '.npmrc', '.yarnrc.yml', '**/.git/**', '**/.private/**', '**/.cache/**', '**/*.override.md'] },
+ },
plugins: [react(), {
name: 'queuelab-distribution-notices',
generateBundle() {
From 0e563c696b16b6406dc8f7401a1898cc39ccb36c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Berke=20Ba=C5=9Fbay?=
<132104179+berkebasbay@users.noreply.github.com>
Date: Mon, 2 Nov 2026 03:27:17 +0300
Subject: [PATCH 2/8] Recognize colored preview output in CI
---
scripts/browser-checks.mjs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/scripts/browser-checks.mjs b/scripts/browser-checks.mjs
index b6d1a86..26537fa 100644
--- a/scripts/browser-checks.mjs
+++ b/scripts/browser-checks.mjs
@@ -2,6 +2,7 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
+import { stripVTControlCharacters } from 'node:util';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import fs from 'node:fs/promises';
@@ -26,11 +27,11 @@ async function close(child) {
try {
await new Promise((resolve, reject) => {
let output = '';
- const timer = setTimeout(() => reject(new Error('Preview did not start within 30 seconds.')), 30000);
+ const timer = setTimeout(() => reject(new Error(`Preview did not start within 30 seconds: ${stripVTControlCharacters(output)}`)), 30000);
const fail = error => { clearTimeout(timer); reject(error); };
server.once('error', fail);
server.once('exit', code => fail(new Error(`Preview exited (${code}): ${output}`)));
- server.stdout.on('data', data => { output = (output + data).slice(-4096); if (output.includes('Local:')) { clearTimeout(timer); resolve(); } });
+ server.stdout.on('data', data => { output = (output + data).slice(-4096); if (stripVTControlCharacters(output).includes('Local:')) { clearTimeout(timer); resolve(); } });
server.stderr.on('data', data => { output = (output + data).slice(-4096); });
});
const response = await fetch(origin, { signal: AbortSignal.timeout(10000) });
From f6b41a924b7ffa30611d6ca4d6821c3c0e0594dd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Berke=20Ba=C5=9Fbay?=
<132104179+berkebasbay@users.noreply.github.com>
Date: Mon, 2 Nov 2026 03:39:42 +0300
Subject: [PATCH 3/8] Keep deferred lesson focus within its current step
---
scripts/learning-smoke.mjs | 1 +
scripts/readiness-smoke.mjs | 6 ++++--
src/App.tsx | 7 ++++---
src/scene/sceneFallback.css | 1 +
4 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/scripts/learning-smoke.mjs b/scripts/learning-smoke.mjs
index 998c298..a7584db 100644
--- a/scripts/learning-smoke.mjs
+++ b/scripts/learning-smoke.mjs
@@ -182,5 +182,6 @@ try {
await fs.writeFile(path.join(out, 'receipt.json'), JSON.stringify(receipt, null, 2) + '\n'); console.log(JSON.stringify(receipt));
} catch (error) {
if (page && !page.isClosed()) { await page.screenshot({ path: path.join(out, 'failure.png'), fullPage: true }).catch(() => {}); await fs.writeFile(path.join(out, 'failure.txt'), `${phase}\n${error}\n${await page.locator('body').innerText()}`); }
+ if (page && !page.isClosed()) console.error('Focus state:', await page.evaluate(() => ({ stage: document.querySelector('.learning-coach')?.getAttribute('data-learning-stage'), active: document.activeElement?.outerHTML.slice(0, 500), running: JSON.parse(window.render_game_to_text?.() || '{}').running })).catch(() => null));
console.error(phase, error); process.exitCode = 1;
} finally { await browser.close(); }
diff --git a/scripts/readiness-smoke.mjs b/scripts/readiness-smoke.mjs
index a54fff8..596d1cf 100644
--- a/scripts/readiness-smoke.mjs
+++ b/scripts/readiness-smoke.mjs
@@ -18,14 +18,15 @@ async function download(p, name) {
}
try {
stage = 'without WebGL'; {
- const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'reduce', acceptDownloads: true });
+ const context = await browser.newContext({ viewport: { width: 390, height: 844 }, reducedMotion: 'reduce', acceptDownloads: true });
await context.addInitScript(() => { const get = HTMLCanvasElement.prototype.getContext; window.restoreTestContext = () => { HTMLCanvasElement.prototype.getContext = get; }; HTMLCanvasElement.prototype.getContext = function (kind, ...args) { return kind.startsWith('webgl') || kind === 'experimental-webgl' ? null : get.call(this, kind, ...args); }; });
page = await context.newPage(); page.on('pageerror', error => errors.push(String(error)));
await page.goto(new URL('?learn=1', origin).href); await page.getByRole('button', { name: 'Try it with me', exact: true }).click();
await page.locator('[data-learning-stage=select]').waitFor(); await page.locator('.scene-text-fallback').waitFor();
+ await page.screenshot({ path: path.join(out, 'no-webgl-lesson.png') });
await page.getByRole('link', { name: 'Skip to lesson', exact: true }).focus(); await page.keyboard.press('Enter');
assert(await page.locator('[data-learning-heading]').evaluate(e => e === document.activeElement));
- await page.locator('.scene-text-fallback [data-station-id=station-b]').press('Enter');
+ await page.locator('.scene-text-fallback [data-station-id=station-b]').click();
await page.getByRole('spinbutton', { name: 'People on this team', exact: true }).fill('3');
await page.getByRole('button', { name: 'Apply change', exact: true }).click();
await page.getByRole('button', { name: 'Try this change', exact: true }).click(); await page.locator('[data-learning-stage=review]').waitFor();
@@ -49,6 +50,7 @@ try {
await page.getByRole('button', { name: 'Try it with me', exact: true }).click();
await page.locator('[data-learning-stage=select]').waitFor();
await page.locator('.scene-unavailable .scene-text-fallback').waitFor();
+ await page.screenshot({ path: path.join(out, 'no-graphics-lesson.png') });
await page.locator('.scene-unavailable [data-station-id=station-b]').press('Enter');
await page.getByRole('spinbutton', { name: 'People on this team', exact: true }).fill('3');
await page.getByRole('button', { name: 'Apply change', exact: true }).click();
diff --git a/src/App.tsx b/src/App.tsx
index b557269..04972e5 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -240,12 +240,13 @@ export default function App() {
rememberLearningWelcome();
}
function toggleLearningGuide() {
- const next = !learningMinimized, origin = document.activeElement, id = live.current.project.id;
+ const next = !learningMinimized, origin = document.activeElement, id = live.current.project.id, task = lessonStage;
if (!next) setPanel(null);
const surface = panelGeneration.current;
setLearningMinimized(next);
requestAnimationFrame(() => {
if (live.current.project.id !== id || surface !== panelGeneration.current || focusChangedSince(origin)) return;
+ if (!next && document.querySelector
('.learning-coach')?.dataset.learningStage !== task) return;
document.querySelector(next ? '[data-learning-resume]' : '[data-learning-toggle]')?.focus();
});
}
@@ -498,11 +499,11 @@ export default function App() {
const fromGuide = active === document.body || (active instanceof HTMLElement && Boolean(active.closest('.learning-coach,.learning-intro,.station-label,[data-learning-target],[inert]') || active.matches('[data-panel-opener="learn"]')));
const focus = ['review', 'complete'].includes(lessonStage) ? document.querySelector('[data-learning-heading]') : target;
if (fromGuide && focus?.matches('input,button,select,[data-learning-heading]') && !focus.matches(':disabled') && !['preparing', 'off-track'].includes(lessonStage) && focusedLearningStep.current !== key) {
- focusedLearningStep.current = key;
const origin = document.activeElement, surface = panelGeneration.current;
handle = requestAnimationFrame(() => {
- if (learningRef.current?.projectId !== project.id || surface !== panelGeneration.current || focusChangedSince(origin) || !focus.isConnected || focus.closest('[inert],[hidden]')) return;
+ if (learningRef.current?.projectId !== project.id || surface !== panelGeneration.current || focusChangedSince(origin) || !focus.isConnected || focus.closest('[inert],[hidden]') || focus.matches(':disabled') || !focus.checkVisibility({ visibilityProperty: true })) return;
focus.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' }); focus.focus({ preventScroll: true });
+ if (document.activeElement === focus) focusedLearningStep.current = key;
});
}
return () => {
diff --git a/src/scene/sceneFallback.css b/src/scene/sceneFallback.css
index 05431c8..b42b84b 100644
--- a/src/scene/sceneFallback.css
+++ b/src/scene/sceneFallback.css
@@ -2,3 +2,4 @@
.scene-text-fallback{position:absolute;inset:100px 24px 16px;overflow:auto;padding:20px;background:#f9fbf6;border:1px solid #c5d3bc;border-radius:12px;color:#294a32;max-width:640px;margin-inline:auto;z-index:3}.scene-text-fallback h3{font-size:20px;margin:0 0 10px}.scene-text-fallback>p{font-size:13px;line-height:1.6;margin:0 0 16px}.scene-text-fallback ol{padding:0;margin:0;list-style:none;display:grid;gap:7px}.scene-text-fallback button{width:100%;min-height:44px;padding:10px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px;text-align:left;border:1px solid #c5d3bc;border-radius:7px;background:#fff;color:#294a32;font:inherit;font-size:13px}.scene-text-fallback button[aria-pressed=true]{background:#e7f1d9;border-color:#608b43}.scene-text-fallback button>span{font-size:11px;color:#52664e}.scene-text-fallback button:focus-visible{outline:3px solid #608b43;outline-offset:2px}.learning-focused .scene-text-fallback li:not(:has([data-station-id="station-b"])){display:none}@media(max-width:740px){.scene-text-fallback{inset:85px 12px 12px;padding:12px}.scene-text-fallback h3{font-size:16px;margin-bottom:7px}.scene-text-fallback>p{font-size:12px;line-height:1.4;margin-bottom:10px}.learning-focused .scene-text-fallback>p{display:none}.learning-focused .scene-text-fallback h3{display:none}}
.scene-unavailable>.scene-asset-warning{position:absolute;top:12px;left:12px;right:12px;z-index:4;padding:12px;background:#fff3d5;color:#604415;border:1px solid #c5ab72;border-radius:8px;font-size:12px;line-height:1.5}
+.is-context-unavailable .scene-asset-warning{top:12px;left:24px;right:24px;max-width:640px;margin-inline:auto;display:flex;align-items:center;justify-content:space-between;gap:12px}.is-context-unavailable .scene-asset-warning button{flex-shrink:0;white-space:nowrap}.scene-unavailable>.scene-asset-warning{max-width:640px;margin-inline:auto}.scene-text-fallback button>strong{min-width:0;overflow-wrap:anywhere}.scene-text-fallback button>span{flex-shrink:0}@media(max-width:740px){.learning-focused .scene-text-fallback{top:145px}.is-context-unavailable .scene-asset-warning{left:12px;right:12px}}
From 6ec4f6749397f292d4bac6583a562e00c4079c7d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Berke=20Ba=C5=9Fbay?=
<132104179+berkebasbay@users.noreply.github.com>
Date: Mon, 2 Nov 2026 03:48:13 +0300
Subject: [PATCH 4/8] Fit the first lesson action within narrow viewports
---
scripts/learning-smoke.mjs | 1 +
src/features/learning.css | 1 +
2 files changed, 2 insertions(+)
diff --git a/scripts/learning-smoke.mjs b/scripts/learning-smoke.mjs
index a7584db..09b24d6 100644
--- a/scripts/learning-smoke.mjs
+++ b/scripts/learning-smoke.mjs
@@ -66,6 +66,7 @@ try {
await page.getByRole('button', { name: 'Show the full workspace', exact: true }).click();
await page.waitForFunction(() => document.activeElement?.matches('[data-learning-resume]'));
await page.locator('[data-learning-resume]').press('Enter'); await stage(page, 'select');
+ if (width <= 740) { const bounds = await coach(page).getByRole('button', { name: 'Choose Specialists', exact: true }).boundingBox(); assert(bounds && bounds.y >= 0 && bounds.y + bounds.height <= page.viewportSize().height + 1, 'The first lesson action must fit the mobile viewport.'); }
// The visible scene label is a real keyboard selection alternative to the lesson button.
if (width === 1440) await page.locator('.world-scene [data-station-id="station-b"]').press('Enter');
else await page.getByRole('button', { name: 'Choose Specialists', exact: true }).click();
diff --git a/src/features/learning.css b/src/features/learning.css
index 06d3ed0..1514821 100644
--- a/src/features/learning.css
+++ b/src/features/learning.css
@@ -15,3 +15,4 @@
.learning-focused .studio-brand>span{display:inline!important}
.learning-coach [data-learning-heading]:focus{outline:none}
+@media(max-width:740px){.facility-studio.learning-focused .studio-main{min-height:0}.facility-studio.learning-focused .studio-world{min-height:0;flex:none}.facility-studio.learning-focused .scene-area{height:250px;min-height:250px;flex:none}.facility-studio.learning-focused .workspace-footer{display:none}}
From 6293b6490be0e2f0540e47df4d4b7a262bebca1f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Berke=20Ba=C5=9Fbay?=
<132104179+berkebasbay@users.noreply.github.com>
Date: Mon, 2 Nov 2026 04:21:02 +0300
Subject: [PATCH 5/8] Use the existing low-power view for software-rendered CI
---
docs/QUALITY.md | 2 +-
scripts/learning-smoke.mjs | 23 ++++++++++++-----------
scripts/readiness-smoke.mjs | 14 +++++++-------
scripts/templates-smoke.mjs | 8 ++++----
scripts/ui-actions.mjs | 18 ++++++++++++++++++
scripts/ux-smoke.mjs | 4 ++--
src/scene/sceneFallback.css | 1 +
7 files changed, 45 insertions(+), 25 deletions(-)
diff --git a/docs/QUALITY.md b/docs/QUALITY.md
index e8287e0..4b3877d 100644
--- a/docs/QUALITY.md
+++ b/docs/QUALITY.md
@@ -18,7 +18,7 @@ The unit suite covers numerical references, conservation/capacity invariants, ev
## Browser smoke checks
-The required PR check and Pages build run `npm run test:browser` against the built app. This starts its own loopback preview, checks first use, no-3D/module failures, safe guided entry, templates and the original worlds, then stops only its own processes. Linux CI uses software graphics in isolated Chromium contexts; these are correctness checks, not GPU performance measurements.
+The required PR check and Pages build run `npm run test:browser` against the built app. This starts its own loopback preview, checks first use, no-3D/module failures, safe guided entry, templates and the original worlds, then stops only its own processes. Linux CI uses software graphics and chooses the existing Low power view through the real UI in isolated Chromium contexts. The setup asserts that changing display detail leaves numerical identity unchanged. All interaction, result and persistence assertions remain active; these are correctness checks, not GPU performance measurements. Local detailed-renderer checks are separate.
Install the browser matching the pinned Playwright dependency once with `npx playwright install chromium` (CI uses `npx playwright install --with-deps chromium`, as in the [Playwright CI guide](https://playwright.dev/docs/ci-intro)). Then run:
diff --git a/scripts/learning-smoke.mjs b/scripts/learning-smoke.mjs
index 09b24d6..1725950 100644
--- a/scripts/learning-smoke.mjs
+++ b/scripts/learning-smoke.mjs
@@ -3,7 +3,7 @@ import { chromium } from 'playwright';
import fs from 'node:fs/promises';
import path from 'node:path';
import assert from 'node:assert/strict';
-import { browserArgs, dismissLearningWelcome, openPanel, runPlan } from './ui-actions.mjs';
+import { prepareCIView, browserArgs, dismissLearningWelcome, openPanel, runPlan } from './ui-actions.mjs';
const origin = new URL(process.env.QUEUELAB_URL || 'http://127.0.0.1:4173/');
assert(['127.0.0.1', 'localhost'].includes(origin.hostname), 'Use a separate loopback QA origin.');
const out = path.resolve(process.env.QUEUELAB_EVIDENCE_DIR || '.private/evidence/learning');
@@ -13,6 +13,7 @@ const browser = await chromium.launch({ headless: true, args: browserArgs, ...(p
const scope = process.env.QUEUELAB_LEARNING_SCOPE || 'all';
assert(['all', 'flows', 'edges'].includes(scope));
const checks = [], errors = []; let page, phase = 'first visit';
+function recordCheck(message) { checks.push(message); console.log(`PASS: ${message}`); }
const state = p => p.evaluate(() => JSON.parse(window.render_game_to_text()));
const ready = p => p.waitForFunction(() => { const s = JSON.parse(window.render_game_to_text?.() || '{}'); return !s.running && s.result?.status === 'completed'; });
const coach = p => p.locator('.learning-coach');
@@ -21,7 +22,7 @@ const cap = p => p.locator('[data-learning-team] input:visible,[data-learning-ta
const saved = p => p.locator('.workspace-footer').getByText(/^Saved in this browser/).first().waitFor({ state: 'attached' });
async function fresh(width = 1440, skip = false) {
const context = await browser.newContext({ viewport: { width, height: width > 740 ? 1000 : 844 }, reducedMotion: 'reduce', acceptDownloads: true });
- const p = await context.newPage(); p.on('pageerror', e => errors.push(String(e))); await p.goto(origin.href); await ready(p);
+ const p = await context.newPage(); p.on('pageerror', e => errors.push(String(e))); await p.goto(origin.href); await ready(p); await prepareCIView(p);
if (skip) await dismissLearningWelcome(p); return { context, page: p };
}
async function start(p) {
@@ -97,19 +98,19 @@ try {
await coach(page).scrollIntoViewIfNeeded(); await page.screenshot({ path: path.join(out, `complete-${width}.png`) });
assert.equal(await coach(page).evaluate(el => getComputedStyle(el).animationName), 'none');
await page.getByRole('button', { name: 'Open the full workspace', exact: true }).click(); await saved(page);
- await page.reload(); await ready(page); assert.equal(await page.locator('[data-panel=learn],.learning-coach').count(), 0);
+ await page.reload(); await ready(page); await prepareCIView(page); assert.equal(await page.locator('[data-panel=learn],.learning-coach').count(), 0);
assert.deepEqual((await download(page, `reopened-${width}`)).model, changed.model);
await openPanel(page, 'worlds'); await page.getByRole('button', { name: /^My templates/ }).click(); await page.locator('[data-template-id]').first().waitFor(); assert.equal(await page.locator('[data-template-id]').count(), 1);
- await c.context.close(); checks.push(`${width}px: five real steps, pending-field guard, keyboard/focus, native persisted template, reload and reduced motion`);
+ await c.context.close(); recordCheck(`${width}px: five real steps, pending-field guard, keyboard/focus, native persisted template, reload and reduced motion`);
}
if (scope !== 'flows') {
phase = 'resume and optional completion'; {
const c = await fresh(); page = c.page; await start(page); await change(page); await saved(page); const before = await download(page, 'before-refresh');
- await page.reload(); await stage(page, 'run'); assert.deepEqual((await download(page, 'after-refresh')).model, before.model);
+ await page.reload(); await stage(page, 'run'); await prepareCIView(page); assert.deepEqual((await download(page, 'after-refresh')).model, before.model);
assert.equal((await state(page)).result.stale, true); await compare(page); await keep(page);
await page.getByRole('button', { name: 'Finish without saving a template', exact: true }).click(); await stage(page, 'complete');
assert.match(await page.locator('.learning-saved').innerText(), /No template was saved/); await c.context.close();
- checks.push('Refresh resumes the preserved changed model and requires a real rerun; optional finish makes no saved-template claim');
+ recordCheck('Refresh resumes the preserved changed model and requires a real rerun; optional finish makes no saved-template claim');
}
phase = 'original project and history'; {
const c = await fresh(1440, true); page = c.page;
@@ -134,7 +135,7 @@ try {
await page.getByRole('button', { name: 'Back to my previous project', exact: true }).click(); await coach(page).waitFor({ state: 'detached' }); await ready(page);
assert.equal((await download(page, 'newer-return')).name, 'Newer synthetic project');
assert.equal(await page.getByRole('button', { name: /^Undo / }).count(), 0);
- await c.context.close(); checks.push('A failed preservation write blocks practice; original project, result and Undo survive practice; a newer externally saved original is reopened without stale history');
+ await c.context.close(); recordCheck('A failed preservation write blocks practice; original project, result and Undo survive practice; a newer externally saved original is reopened without stale history');
}
phase = 'save failure'; {
const c = await fresh(); page = c.page; await start(page); await change(page); await compare(page); await keep(page);
@@ -143,7 +144,7 @@ try {
await page.locator('.template-save button[type=submit]').click(); await page.locator('.templates-panel [role=alert]').waitFor();
await stage(page, 'save'); assert.equal(await page.locator('.learning-saved').count(), 0); assert.equal(await templateCount(page), 0);
await page.evaluate(() => window.restoreTemplateWrites()); await page.locator('.template-save button[type=submit]').click(); await stage(page, 'complete');
- await c.context.close(); checks.push('A failed IndexedDB template write cannot complete the lesson or claim a save; native retry succeeds');
+ await c.context.close(); recordCheck('A failed IndexedDB template write cannot complete the lesson or claim a save; native retry succeeds');
}
phase = 'cancelled and failed practice loading'; {
const c = await fresh(1440, true); page = c.page; const before = await download(page, 'before-load-failure');
@@ -157,7 +158,7 @@ try {
await page.locator('.studio-world-picker select').selectOption('blank'); await page.waitForFunction(() => JSON.parse(window.render_game_to_text()).workspaceMode === 'layout');
const blank = await download(page, 'after-cancelled-load'); assert.deepEqual(blank.model.nodes, []); assert.notEqual(blank.id, before.id);
assert.equal(await coach(page).count(), 0); await page.unroute('**/templates/service.json?*'); await c.context.close();
- checks.push('HTTP errors stay actionable; Escape cancels practice loading and a late response cannot replace the newer blank project');
+ recordCheck('HTTP errors stay actionable; Escape cancels practice loading and a late response cannot replace the newer blank project');
}
phase = 'independent exploration'; {
const c = await fresh(); page = c.page; await start(page); await change(page);
@@ -175,8 +176,8 @@ try {
const different = await download(page, 'independent-idea');
await page.getByRole('button', { name: 'Leave guide', exact: true }).click();
assert.deepEqual((await download(page, 'after-leave')).model, different.model);
- await saved(page); await page.reload(); await ready(page); assert.equal(await page.locator('[data-panel=learn],.learning-coach').count(), 0);
- await c.context.close(); checks.push('Native drafts block resume until applied/cancelled; resume closes advanced dialogs; Undo returns to the relevant task; unrelated model edits are preserved and leaving the guide never resets them');
+ await saved(page); await page.reload(); await ready(page); await prepareCIView(page); assert.equal(await page.locator('[data-panel=learn],.learning-coach').count(), 0);
+ await c.context.close(); recordCheck('Native drafts block resume until applied/cancelled; resume closes advanced dialogs; Undo returns to the relevant task; unrelated model edits are preserved and leaving the guide never resets them');
}
}
assert.deepEqual(errors, []); const receipt = { passed: true, at: new Date().toISOString(), origin: origin.href, browser: browser.version(), scope, checks, errors };
diff --git a/scripts/readiness-smoke.mjs b/scripts/readiness-smoke.mjs
index 596d1cf..9f68367 100644
--- a/scripts/readiness-smoke.mjs
+++ b/scripts/readiness-smoke.mjs
@@ -2,7 +2,7 @@ import { chromium } from 'playwright';
import fs from 'node:fs/promises';
import path from 'node:path';
import assert from 'node:assert/strict';
-import { browserArgs, dismissLearningWelcome, openPanel } from './ui-actions.mjs';
+import { prepareCIView, browserArgs, dismissLearningWelcome, openPanel } from './ui-actions.mjs';
const origin = new URL(process.env.QUEUELAB_URL || 'http://127.0.0.1:4173/');
assert(['127.0.0.1', 'localhost'].includes(origin.hostname), 'Use an isolated loopback origin.');
const out = path.resolve(process.env.QUEUELAB_EVIDENCE_DIR || '.private/evidence/readiness');
@@ -21,7 +21,7 @@ try {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, reducedMotion: 'reduce', acceptDownloads: true });
await context.addInitScript(() => { const get = HTMLCanvasElement.prototype.getContext; window.restoreTestContext = () => { HTMLCanvasElement.prototype.getContext = get; }; HTMLCanvasElement.prototype.getContext = function (kind, ...args) { return kind.startsWith('webgl') || kind === 'experimental-webgl' ? null : get.call(this, kind, ...args); }; });
page = await context.newPage(); page.on('pageerror', error => errors.push(String(error)));
- await page.goto(new URL('?learn=1', origin).href); await page.getByRole('button', { name: 'Try it with me', exact: true }).click();
+ await page.goto(new URL('?learn=1', origin).href); await ready(page); await prepareCIView(page); await page.getByRole('button', { name: 'Try it with me', exact: true }).click();
await page.locator('[data-learning-stage=select]').waitFor(); await page.locator('.scene-text-fallback').waitFor();
await page.screenshot({ path: path.join(out, 'no-webgl-lesson.png') });
await page.getByRole('link', { name: 'Skip to lesson', exact: true }).focus(); await page.keyboard.press('Enter');
@@ -46,7 +46,7 @@ try {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, reducedMotion: 'reduce' });
page = await context.newPage(); page.on('pageerror', error => errors.push(String(error)));
await page.route(url => /\/scene\/WorldScene\.tsx$|\/assets\/WorldScene-[^/]+\.js$/.test(url.pathname), route => route.abort('failed'));
- await page.goto(new URL('?learn=1', origin).href);
+ await page.goto(new URL('?learn=1', origin).href); await ready(page); await prepareCIView(page);
await page.getByRole('button', { name: 'Try it with me', exact: true }).click();
await page.locator('[data-learning-stage=select]').waitFor();
await page.locator('.scene-unavailable .scene-text-fallback').waitFor();
@@ -65,22 +65,22 @@ try {
}
stage = 'guided entry preserves saved work'; {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'reduce', acceptDownloads: true });
- page = await context.newPage(); page.on('pageerror', error => errors.push(String(error))); await page.goto(origin.href); await dismissLearningWelcome(page); await saved(page);
+ page = await context.newPage(); page.on('pageerror', error => errors.push(String(error))); await page.goto(origin.href); await dismissLearningWelcome(page); await prepareCIView(page); await saved(page);
await page.getByRole('link', { name: 'Skip to workspace', exact: true }).focus(); await page.keyboard.press('Enter');
assert(await page.locator('#workspace').evaluate(e => e === document.activeElement));
const original = await download(page, 'before-guided-entry');
- await page.goto(new URL('?learn=1&keep=example', origin).href); await page.locator('[data-panel=learn]').waitFor();
+ await page.goto(new URL('?learn=1&keep=example', origin).href); await ready(page); await prepareCIView(page); await page.locator('[data-panel=learn]').waitFor();
assert.equal(new URL(page.url()).searchParams.get('learn'), null); assert.equal(new URL(page.url()).searchParams.get('keep'), 'example');
await page.getByRole('button', { name: 'Explore on my own', exact: true }).click();
const after = await download(page, 'after-guided-entry');
for (const key of Object.keys(original).filter(key => key !== 'savedAt')) assert.deepEqual(after[key], original[key]);
- await page.reload(); await ready(page); assert.equal(await page.locator('[data-panel=learn]').count(), 0);
+ await page.reload(); await ready(page); await prepareCIView(page); assert.equal(await page.locator('[data-panel=learn]').count(), 0);
await context.close(); checks.push('Explicit ?learn=1 opens the welcome once, preserves the saved project and other query parameters, and does not reopen on reload');
}
for (const width of [1440, 390]) {
stage = `pending edit ${width}`;
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce', acceptDownloads: true });
- page = await context.newPage(); page.on('pageerror', error => errors.push(String(error))); await page.goto(origin.href); await dismissLearningWelcome(page);
+ page = await context.newPage(); page.on('pageerror', error => errors.push(String(error))); await page.goto(origin.href); await dismissLearningWelcome(page); await prepareCIView(page);
await page.locator('.facility-canvas-tools').getByRole('button', { name: 'Steps', exact: true }).click();
await page.getByRole('button', { name: 'Inspect Packing', exact: true }).click();
const field = page.getByRole('spinbutton', { name: 'Mean processing time', exact: true });
diff --git a/scripts/templates-smoke.mjs b/scripts/templates-smoke.mjs
index f051894..83f8e2d 100644
--- a/scripts/templates-smoke.mjs
+++ b/scripts/templates-smoke.mjs
@@ -3,7 +3,7 @@ import { chromium } from 'playwright';
import fs from 'node:fs/promises';
import path from 'node:path';
import assert from 'node:assert/strict';
-import { browserArgs, dismissLearningWelcome, openPanel, runPlan, headerProjectInput, canvasDropPoint } from './ui-actions.mjs';
+import { prepareCIView, browserArgs, dismissLearningWelcome, openPanel, runPlan, headerProjectInput, canvasDropPoint } from './ui-actions.mjs';
const origin = new URL(process.env.QUEUELAB_URL || 'http://127.0.0.1:4173/');
assert(['127.0.0.1', 'localhost'].includes(origin.hostname), 'Use a separate loopback QA origin.');
@@ -19,7 +19,7 @@ const close = p => p.getByRole('button', { name: 'Close dialog', exact: true }).
async function fresh(width = 1440) {
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce', acceptDownloads: true });
const p = await context.newPage(); p.on('pageerror', e => errors.push(String(e)));
- await p.goto(origin.href); await dismissLearningWelcome(p); await ready(p); return { context, page: p };
+ await p.goto(origin.href); await dismissLearningWelcome(p); await ready(p); await prepareCIView(p); return { context, page: p };
}
async function download(p, name) {
const next = p.waitForEvent('download'); await p.locator('.studio-save').click();
@@ -68,7 +68,7 @@ try {
await page.screenshot({ path: path.join(out, 'saved-template.png') });
const saved = await records(page); assert.equal(saved.templates.length, 1); assert.equal(saved.summaries.length, 1);
await close(page); const unchanged = await download(page, 'after-template-save.json'); assert.equal(unchanged.id, placed.id); assert.equal(unchanged.revision, placed.revision);
- await page.locator('.workspace-footer').getByText(/^Saved in this browser/).first().waitFor(); await page.reload(); await ready(page);
+ await page.locator('.workspace-footer').getByText(/^Saved in this browser/).first().waitFor(); await page.reload(); await ready(page); await prepareCIView(page);
const restored = await download(page, 'layout-reloaded.json'); assert.equal(restored.id, placed.id); assert.deepEqual(restored.facility, placed.facility); assert.deepEqual(restored.model.nodes, []);
await myTemplates(page); await page.locator('.templates-panel').getByRole('button', { name: 'Save as template', exact: true }).click(); await page.locator('.template-cancel-save').click(); assert.equal(await page.evaluate(() => document.activeElement?.textContent), 'Save as template'); await row(page, 'Synthetic workshop').getByRole('button', { name: 'Use template', exact: true }).click(); await ready(page);
const first = await download(page, 'template-instance.json'); assert.notEqual(first.id, placed.id); assert.deepEqual(first.facility, placed.facility); assert.deepEqual(first.model, placed.model);
@@ -122,7 +122,7 @@ try {
tx.oncomplete = () => { db.close(); resolve(); }; tx.onabort = () => { db.close(); reject(String(tx.error)); };
};
}), saved.templates[0]);
- const other = await desktop.context.newPage(); other.on('pageerror', e => errors.push(String(e))); await other.goto(origin.href); await dismissLearningWelcome(other); await ready(other);
+ const other = await desktop.context.newPage(); other.on('pageerror', e => errors.push(String(e))); await other.goto(origin.href); await dismissLearningWelcome(other); await ready(other); await prepareCIView(other);
await myTemplates(page); await page.locator('.templates-panel').getByRole('button', { name: 'Save as template', exact: true }).click(); await myTemplates(other); await other.locator('.templates-panel').getByRole('button', { name: 'Save as template', exact: true }).click();
for (const [p, name] of [[page, 'Concurrent A'], [other, 'Concurrent B']]) { await p.bringToFront(); await p.getByRole('textbox', { name: 'Template name', exact: true }).fill(name); }
// Activate the two real form handlers together; background-tab frame throttling must not serialize this transaction race.
diff --git a/scripts/ui-actions.mjs b/scripts/ui-actions.mjs
index cf78d57..1069e6a 100644
--- a/scripts/ui-actions.mjs
+++ b/scripts/ui-actions.mjs
@@ -3,6 +3,24 @@ import assert from 'node:assert/strict';
// Linux CI has no GPU; use deterministic software graphics in isolated test browsers.
export const browserArgs = process.env.CI && process.platform === 'linux' ? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] : [];
+/** Keep software-rendered CI about interaction correctness, with real UI preferences. */
+export async function prepareCIView(page) {
+ if (!process.env.CI) return;
+ const welcome = await page.locator('[data-panel="learn"]').count() > 0;
+ if (welcome) await page.getByRole('button', { name: 'Explore on my own', exact: true }).click();
+ const expanded = await page.locator('.learning-focused').count() > 0;
+ if (expanded) await page.getByRole('button', { name: 'Show the full workspace', exact: true }).click();
+ const snapshot = () => page.evaluate(() => { const s = JSON.parse(window.render_game_to_text()); return { world: s.world, revision: s.revision, hash: s.result?.hash }; });
+ const before = await snapshot();
+ await page.locator('[data-panel-opener="view"]:visible').click();
+ await page.getByRole('combobox', { name: 'World detail', exact: true }).selectOption('low');
+ await page.getByRole('button', { name: 'Close dialog', exact: true }).click();
+ assert.deepEqual(await snapshot(), before, 'Display detail must not change numerical identity.');
+ if (welcome) await page.getByRole('button', { name: 'Learn by doing', exact: true }).click();
+ else if (expanded) await page.locator('[data-learning-resume]').click();
+ console.log(`CI view: Low power at ${page.viewportSize().width}px; numerical identity unchanged.`);
+}
+
// Shared current-studio navigation for isolated browser regressions. These are
// explicit product paths: missing controls fail instead of trying older UIs.
const toolPanels = new Set(['model', 'experiments', 'budget', 'history', 'worlds', 'help']);
diff --git a/scripts/ux-smoke.mjs b/scripts/ux-smoke.mjs
index 6092263..a011062 100644
--- a/scripts/ux-smoke.mjs
+++ b/scripts/ux-smoke.mjs
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
-import { browserArgs, dismissLearningWelcome, openPanel, primaryRunButton, headerProjectInput } from './ui-actions.mjs';
+import { prepareCIView, browserArgs, dismissLearningWelcome, openPanel, primaryRunButton, headerProjectInput } from './ui-actions.mjs';
const origin = new URL(process.env.QUEUELAB_URL || 'http://127.0.0.1:4173/');
assert(['localhost', '127.0.0.1'].includes(origin.hostname), 'Use a loopback QA server.');
const kind = process.env.QUEUELAB_BROWSER || 'chromium', phase = process.env.QUEUELAB_UX_PHASE || 'core';
@@ -36,7 +36,7 @@ async function inspect(page, label, intent = 'single') {
async function field(page, name, value, scope = page) { const input = scope.getByRole('spinbutton', { name, exact: true }); await input.fill(String(value)); await input.press('Enter'); return input; }
async function fresh(world = 'factory', width = viewportWidth, height = viewportHeight) {
const context = await browser.newContext({ viewport: { width, height }, reducedMotion: 'reduce', acceptDownloads: true }); const page = await context.newPage();
- page.on('pageerror', error => receipt.errors.push(String(error))); await page.goto(origin.href); await dismissLearningWelcome(page); await ready(page);
+ page.on('pageerror', error => receipt.errors.push(String(error))); await page.goto(origin.href); await dismissLearningWelcome(page); await ready(page); await prepareCIView(page);
if (world !== 'factory') { await page.locator('.studio-world-picker select').selectOption(world); await ready(page); }
const pause = page.getByRole('button', { name: 'Pause replay', exact: true }); if (await pause.count()) await pause.click(); return { context, page };
}
diff --git a/src/scene/sceneFallback.css b/src/scene/sceneFallback.css
index b42b84b..bdd5ce5 100644
--- a/src/scene/sceneFallback.css
+++ b/src/scene/sceneFallback.css
@@ -3,3 +3,4 @@
.scene-unavailable>.scene-asset-warning{position:absolute;top:12px;left:12px;right:12px;z-index:4;padding:12px;background:#fff3d5;color:#604415;border:1px solid #c5ab72;border-radius:8px;font-size:12px;line-height:1.5}
.is-context-unavailable .scene-asset-warning{top:12px;left:24px;right:24px;max-width:640px;margin-inline:auto;display:flex;align-items:center;justify-content:space-between;gap:12px}.is-context-unavailable .scene-asset-warning button{flex-shrink:0;white-space:nowrap}.scene-unavailable>.scene-asset-warning{max-width:640px;margin-inline:auto}.scene-text-fallback button>strong{min-width:0;overflow-wrap:anywhere}.scene-text-fallback button>span{flex-shrink:0}@media(max-width:740px){.learning-focused .scene-text-fallback{top:145px}.is-context-unavailable .scene-asset-warning{left:12px;right:12px}}
+.facility-studio:not(.learning-focused) .scene-text-fallback{top:260px}.facility-studio:not(.learning-focused) :is(.is-context-unavailable,.scene-unavailable)>.scene-asset-warning{top:132px}
From 91926b928a9f00d609beef914da60ff55a80613b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Berke=20Ba=C5=9Fbay?=
<132104179+berkebasbay@users.noreply.github.com>
Date: Mon, 2 Nov 2026 04:54:35 +0300
Subject: [PATCH 6/8] Load graphics on demand and parallelize required browser
gates
---
.github/workflows/ci.yml | 42 ++++++++++++++++++++++++++++++-----
.github/workflows/pages.yml | 35 +++++++++++++++++++++++------
CHANGELOG.md | 1 +
docs/QUALITY.md | 4 ++--
scripts/browser-checks.mjs | 18 ++++++++-------
scripts/readiness-smoke.mjs | 12 ++++++++++
src/App.tsx | 5 ++++-
tests/browser-runner.test.mjs | 4 ++++
8 files changed, 97 insertions(+), 24 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c9cacea..f0036e1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,5 @@
name: Verify changes
-# Check every PR revision, including drafts; keep manual dispatch available.
on:
pull_request:
branches: [main]
@@ -15,11 +14,10 @@ concurrency:
cancel-in-progress: true
jobs:
- verify:
+ checks:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- # Official release refs resolved through api.github.com on 2026-10-20.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -60,7 +58,39 @@ jobs:
- run: node scripts/simulate.mjs --example factory --seed 4207
- run: node scripts/simulate.mjs examples/service.json --seed 4207
- run: node examples/verify.mjs
- - name: Install test browser
- run: npx playwright install --with-deps chromium
- - name: Check browser journeys
+ browser:
+ needs: checks
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ suite: [first-use, recovery-and-entry, templates, original-worlds]
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: '24'
+ package-manager-cache: false
+ - run: npm ci
+ - run: npm run build
+ - run: npx playwright install --with-deps chromium
+ - name: Check browser journey
+ env:
+ QUEUELAB_BROWSER_SUITE: ${{ matrix.suite }}
run: npm run test:browser
+ verify:
+ if: ${{ always() }}
+ needs: [checks, browser]
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Require source checks and every browser journey
+ env:
+ QUALITY_RESULT: ${{ needs.checks.result }}
+ BROWSER_RESULT: ${{ needs.browser.result }}
+ run: |
+ test "$QUALITY_RESULT" = success
+ test "$BROWSER_RESULT" = success
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index a78a138..d298ad9 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -14,6 +14,8 @@ concurrency:
jobs:
build:
+ outputs:
+ public_url: ${{ steps.pages.outputs.base_url }}/
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
@@ -57,19 +59,38 @@ jobs:
run: |
npm run build
node --input-type=module -e "import fs from 'node:fs'; const pkg=JSON.parse(fs.readFileSync('package.json')); fs.writeFileSync('dist/build-info.json', JSON.stringify({source:process.env.GITHUB_SHA,version:pkg.version})+'\n');"
- - name: Install test browser
- run: npx playwright install --with-deps chromium
- - name: Check built demo in the browser
- env:
- QUEUELAB_PUBLIC_URL: ${{ steps.pages.outputs.base_url }}/
- run: npm run test:browser
- name: Upload static application
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: dist
- deploy:
+ browser:
needs: build
runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ suite: [first-use, recovery-and-entry, templates, original-worlds]
+ env:
+ QUEUELAB_PUBLIC_URL: ${{ needs.build.outputs.public_url }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: '24'
+ package-manager-cache: false
+ - run: npm ci
+ - run: npm run build
+ - run: npx playwright install --with-deps chromium
+ - name: Check built browser journey
+ env:
+ QUEUELAB_BROWSER_SUITE: ${{ matrix.suite }}
+ run: npm run test:browser
+ deploy:
+ needs: [build, browser]
+ runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
pages: write
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a31b4f5..f48b76a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## 1.0.0-rc.17
- Add keyboard links that bypass navigation and reach the workspace or current lesson.
+- Defer the initial 3D world until the first-use welcome is dismissed and the current project is known.
- Add a direct guided-example entry that opens the welcome without replacing saved work.
- Keep process selection, calculations and template saves available when WebGL or the graphics module cannot load.
- Preserve the first digit typed immediately after opening a numeric field.
diff --git a/docs/QUALITY.md b/docs/QUALITY.md
index 4b3877d..0af3457 100644
--- a/docs/QUALITY.md
+++ b/docs/QUALITY.md
@@ -18,7 +18,7 @@ The unit suite covers numerical references, conservation/capacity invariants, ev
## Browser smoke checks
-The required PR check and Pages build run `npm run test:browser` against the built app. This starts its own loopback preview, checks first use, no-3D/module failures, safe guided entry, templates and the original worlds, then stops only its own processes. Linux CI uses software graphics and chooses the existing Low power view through the real UI in isolated Chromium contexts. The setup asserts that changing display detail leaves numerical identity unchanged. All interaction, result and persistence assertions remain active; these are correctness checks, not GPU performance measurements. Local detailed-renderer checks are separate.
+The required PR check and Pages workflow run `npm run test:browser` against the built app. Four isolated browser jobs run in parallel; the final required `verify` check needs the source checks and every browser job to succeed. Deployment also waits for all four jobs. Each browser job builds the same checked-out source with the same deployment base; no additional build-artifact storage is introduced. This starts its own loopback preview, checks first use, no-3D/module failures, safe guided entry, templates and the original worlds, then stops only its own processes. Linux CI uses software graphics and chooses the existing Low power view through the real UI in isolated Chromium contexts. The setup asserts that changing display detail leaves numerical identity unchanged. All interaction, result and persistence assertions remain active; these are correctness checks, not GPU performance measurements. Local detailed-renderer checks are separate.
Install the browser matching the pinned Playwright dependency once with `npx playwright install chromium` (CI uses `npx playwright install --with-deps chromium`, as in the [Playwright CI guide](https://playwright.dev/docs/ci-intro)). Then run:
@@ -27,7 +27,7 @@ npm run build
npm run test:browser
```
-The runner fails if its preview port is occupied. Set `QUEUELAB_BROWSER_PORT` to a free port; it never stops an existing server. `QUEUELAB_BROWSER_DIST` selects an alternative build folder. `QUEUELAB_PUBLIC_URL` supplies the built deployment subpath, so the Pages build is checked under the same `/loopset/` base. Generated evidence remains ignored; no browser profiles or customer files are used.
+The runner fails if its preview port is occupied. Set `QUEUELAB_BROWSER_PORT` to a free port; it never stops an existing server. `QUEUELAB_BROWSER_DIST` selects an alternative build folder. `QUEUELAB_BROWSER_SUITE` can select `first-use`, `recovery-and-entry`, `templates` or `original-worlds` for a focused run; unknown names fail. Without it, every suite runs. `QUEUELAB_PUBLIC_URL` supplies the built deployment subpath, so the Pages build is checked under the same `/loopset/` base. Generated evidence remains ignored; no browser profiles or customer files are used.
Documentation checks validate local file/image links and common Markdown heading anchors, excluding fenced code examples. They do not make external website availability a build requirement.
diff --git a/scripts/browser-checks.mjs b/scripts/browser-checks.mjs
index 26537fa..5d7e657 100644
--- a/scripts/browser-checks.mjs
+++ b/scripts/browser-checks.mjs
@@ -6,6 +6,14 @@ import { stripVTControlCharacters } from 'node:util';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import fs from 'node:fs/promises';
+const checks = [
+ ['first-use', 'scripts/learning-smoke.mjs', {}],
+ ['recovery-and-entry', 'scripts/readiness-smoke.mjs', {}],
+ ['templates', 'scripts/templates-smoke.mjs', {}],
+ ['original-worlds', 'scripts/ux-smoke.mjs', { QUEUELAB_UX_PHASE: 'core' }],
+];
+const selectedChecks = process.env.QUEUELAB_BROWSER_SUITE ? checks.filter(([name]) => name === process.env.QUEUELAB_BROWSER_SUITE) : checks;
+assert(selectedChecks.length > 0, 'Unknown browser suite.');
const root = fileURLToPath(new URL('..', import.meta.url));
const port = Number(process.env.QUEUELAB_BROWSER_PORT || 4173);
assert(Number.isInteger(port) && port >= 1024 && port <= 65535, 'Choose a loopback test port between 1024 and 65535.');
@@ -37,20 +45,14 @@ try {
const response = await fetch(origin, { signal: AbortSignal.timeout(10000) });
assert.equal(response.status, 200, 'The owned preview must serve the built application.');
assert.match(await response.text(), /id=["']root["']/);
- const checks = [
- ['first-use', 'scripts/learning-smoke.mjs', {}],
- ['recovery-and-entry', 'scripts/readiness-smoke.mjs', {}],
- ['templates', 'scripts/templates-smoke.mjs', {}],
- ['original-worlds', 'scripts/ux-smoke.mjs', { QUEUELAB_UX_PHASE: 'core' }],
- ];
- for (const [name, script, extra] of checks) {
+ for (const [name, script, extra] of selectedChecks) {
assert(!interrupted, 'Browser checks interrupted.');
console.log(`Browser check: ${name}`);
current = spawn(process.execPath, [script], { cwd: root, env: { ...process.env, ...extra, QUEUELAB_URL: origin, QUEUELAB_EVIDENCE_DIR: path.join(out, name) }, stdio: 'inherit' });
const [code, signal] = await once(current, 'exit'); current = null;
assert.equal(code, 0, `${name} failed${signal ? ` (${signal})` : ''}.`);
}
- console.log('Browser checks passed: first use, recovery, templates and original worlds.');
+ console.log(`Browser checks passed: ${selectedChecks.map(([name]) => name).join(', ')}.`);
} finally {
await close(current); await close(server);
process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);
diff --git a/scripts/readiness-smoke.mjs b/scripts/readiness-smoke.mjs
index 9f68367..ce59882 100644
--- a/scripts/readiness-smoke.mjs
+++ b/scripts/readiness-smoke.mjs
@@ -17,6 +17,18 @@ async function download(p, name) {
const next = p.waitForEvent('download'); await p.locator('.studio-save').click(); const file = await next, target = path.join(out, `${name}.json`); await file.saveAs(target); return JSON.parse(await fs.readFile(target, 'utf8'));
}
try {
+ stage = 'graphics begin only after entry'; {
+ const context = await browser.newContext({ viewport: { width: 390, height: 844 }, reducedMotion: 'reduce' });
+ page = await context.newPage(); page.on('pageerror', error => errors.push(String(error)));
+ const graphics = url => /\/scene\/WorldScene\.tsx$|\/assets\/WorldScene-[^/]+\.js$/.test(new URL(url).pathname);
+ const requests = []; page.on('request', request => { if (graphics(request.url())) requests.push(request.url()); });
+ await page.goto(new URL('?learn=1', origin).href); await ready(page);
+ await page.locator('[data-panel=learn]').waitFor(); await page.locator('.learning-intro img').evaluate(img => img.decode());
+ assert.equal(requests.length, 0, 'The welcome must not fetch a temporary graphics module.');
+ await Promise.all([page.waitForRequest(request => graphics(request.url())), page.getByRole('button', { name: 'Try it with me', exact: true }).click()]);
+ assert.equal((await state(page)).world, 'service'); assert.equal(requests.length, 1);
+ await context.close(); checks.push('The welcome loads no temporary graphics module; choosing the example starts its real 3D module on demand');
+ }
stage = 'without WebGL'; {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, reducedMotion: 'reduce', acceptDownloads: true });
await context.addInitScript(() => { const get = HTMLCanvasElement.prototype.getContext; window.restoreTestContext = () => { HTMLCanvasElement.prototype.getContext = get; }; HTMLCanvasElement.prototype.getContext = function (kind, ...args) { return kind.startsWith('webgl') || kind === 'experimental-webgl' ? null : get.call(this, kind, ...args); }; });
diff --git a/src/App.tsx b/src/App.tsx
index 04972e5..c3d147a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -144,6 +144,9 @@ export default function App() {
const [flowState, setFlowState] = useState({ key: '', open: false, cursor: 0 });
const [toast, setToast] = useState(null); const [backupRequestedRevision, setBackupRequestedRevision] = useState(null);
const [storageReady, setStorageReady] = useState(false);
+ const [sceneStarted, setSceneStarted] = useState(false);
+ // Do not fetch a temporary 3D world behind the first-use welcome.
+ useEffect(() => { if (storageReady && panel !== 'learn') setSceneStarted(true); }, [storageReady, panel]);
const [localRevision, setLocalRevision] = useState(null);
const [storageError, setStorageError] = useState(null);
const [retainedDrafts, setRetainedDrafts] = useState([]);
@@ -1045,7 +1048,7 @@ export default function App() {
setPanel('view', event.currentTarget)}>
document.querySelector('.world-scene')?.dispatchEvent(new Event('queuelab:fit'))}>