@rig-c/review 0.0.0-stage → 0.2.1

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +376 -2
  3. package/docs/DESIGN.md +72 -0
  4. package/docs/REVIEW_PACKAGE.md +627 -0
  5. package/examples/demo/SOURCE.md +5 -0
  6. package/examples/demo/chime.wav +0 -0
  7. package/examples/demo/lantern.svg +10 -0
  8. package/examples/demo/pose.json +244 -0
  9. package/examples/demo/review.json +67 -0
  10. package/package.json +49 -4
  11. package/src/animation-selection.ts +29 -0
  12. package/src/build.ts +8 -0
  13. package/src/cli.ts +116 -0
  14. package/src/compare-playhead.ts +55 -0
  15. package/src/compare-presentation.ts +43 -0
  16. package/src/compare-server.ts +63 -0
  17. package/src/compare-session.ts +26 -0
  18. package/src/compare.ts +57 -0
  19. package/src/comparison-eligibility.ts +54 -0
  20. package/src/control-server.ts +83 -0
  21. package/src/control.ts +132 -0
  22. package/src/feedback-export.ts +197 -0
  23. package/src/feedback-validation.ts +38 -0
  24. package/src/frame-clock.ts +36 -0
  25. package/src/front-worker.ts +2 -0
  26. package/src/front.ts +127 -0
  27. package/src/manifest.ts +140 -0
  28. package/src/open-protocol.ts +31 -0
  29. package/src/open-selection.ts +11 -0
  30. package/src/open-shell.ts +2 -0
  31. package/src/open-worker.ts +2 -0
  32. package/src/open.ts +142 -0
  33. package/src/pose.ts +27 -0
  34. package/src/probe-reference.ts +16 -0
  35. package/src/reference-frame-index.ts +16 -0
  36. package/src/reference-frames.ts +62 -0
  37. package/src/reference.ts +63 -0
  38. package/src/report.ts +88 -0
  39. package/src/server.ts +62 -0
  40. package/src/session-history.ts +99 -0
  41. package/src/session.ts +58 -0
  42. package/src/strip-export.ts +69 -0
  43. package/src/strip-render.ts +171 -0
  44. package/src/strip.ts +51 -0
  45. package/src/versions.ts +20 -0
  46. package/web/camera-view.ts +44 -0
  47. package/web/compare.css +140 -0
  48. package/web/compare.html +7 -0
  49. package/web/compare.ts +546 -0
  50. package/web/control.ts +67 -0
  51. package/web/index.html +13 -0
  52. package/web/inspection-persistence.ts +219 -0
  53. package/web/instance-access.ts +23 -0
  54. package/web/main.ts +972 -0
  55. package/web/part-selection.ts +136 -0
  56. package/web/renderer-status.ts +31 -0
  57. package/web/report-view.ts +51 -0
  58. package/web/rig-overlays.ts +150 -0
  59. package/web/styles.css +139 -0
  60. package/web/view-controls.ts +69 -0
  61. package/web/workspace-layout.ts +84 -0
  62. package/web/workspace-preferences.ts +95 -0
  63. package/web/workspace-ui.ts +91 -0
@@ -0,0 +1,69 @@
1
+ /** Local inspection controls; these never write source or review state. */
2
+ export type ThemePreference = 'system' | 'dark' | 'light';
3
+ export interface ViewPoint { x:number; y:number }
4
+ export function stageOrientation(width:number,height:number):'horizontal'|'vertical' {return width>=height?'horizontal':'vertical';}
5
+ export function panCenter(center:ViewPoint,dx:number,dy:number,scale:number,space:'world'|'image'):ViewPoint {
6
+ if(![center.x,center.y,dx,dy,scale].every(Number.isFinite)||scale<=0)throw new Error('Pan requires finite coordinates and a positive scale');
7
+ return {x:center.x-dx/scale,y:center.y+(space==='world'?dy:-dy)/scale};
8
+ }
9
+ export function idleAvailable(names:readonly string[],selected:string):boolean {return selected==='idle'&&names.includes('idle');}
10
+ export function initialIdleAnimation(names:readonly string[],fallback:string|null,explicit:boolean):string|null {return !explicit&&names.includes('idle')?'idle':fallback;}
11
+ /** An automatic idle-at-start checkpoint is preview context, not a manual hold. */
12
+ export function automaticIdlePreview(names:readonly string[],selected:string,checkpoint:{animation?:string;time?:number;axis?:unknown}|undefined,explicitCheckpoint:boolean):boolean {
13
+ return idleAvailable(names,selected)&&!explicitCheckpoint&&(!checkpoint||(checkpoint.animation==='idle'&&(checkpoint.time??0)===0&&!checkpoint.axis));
14
+ }
15
+ export function themePreference(value:unknown):ThemePreference {return value==='dark'||value==='light'?value:'system';}
16
+
17
+ /** The inspection appearance is transient; workspace Settings owns the saved browser default. */
18
+ export function bindAppearance(control:HTMLSelectElement,initial:ThemePreference='system'):{get:()=>ThemePreference;set:(value:ThemePreference)=>void;dispose:()=>void} {
19
+ let current=themePreference(initial);
20
+ const system=matchMedia('(prefers-color-scheme: light)');
21
+ const render=()=>{document.documentElement.dataset.theme=current==='system'?(system.matches?'light':'dark'):current;control.value=current;};
22
+ const set=(value:ThemePreference)=>{current=themePreference(value);render();};
23
+ const change=()=>set(themePreference(control.value));control.addEventListener('change',change);system.addEventListener('change',render);render();
24
+ return {get:()=>current,set,dispose:()=>{control.removeEventListener('change',change);system.removeEventListener('change',render);}};
25
+ }
26
+ export function syncStageLayout(area:Pick<HTMLElement,'clientWidth'|'clientHeight'|'dataset'>):boolean {const next=stageOrientation(area.clientWidth,area.clientHeight);if(area.dataset.orientation===next)return false;area.dataset.orientation=next;return true;}
27
+ export function bindStageLayout(area:HTMLElement,changed:()=>void):()=>void {
28
+ const render=()=>{if(syncStageLayout(area))changed();};
29
+ const observer=new ResizeObserver(render);observer.observe(area);render();return ()=>observer.disconnect();
30
+ }
31
+ // Nested or separately disposed pan binders must not restore another binder's override.
32
+ const panVideoOverrides=new WeakMap<HTMLVideoElement,{owners:number;value:string;priority:string}>();
33
+ export function bindPan(viewport:HTMLElement,move:(dx:number,dy:number)=>void,enabled:()=>boolean=()=>true):()=>void {
34
+ let pointer:number|null=null,x=0,y=0,space=false;
35
+ const videos=new Set<HTMLVideoElement>();
36
+ const releaseVideo=(video:HTMLVideoElement)=>{
37
+ const previous=panVideoOverrides.get(video);if(!previous)return;
38
+ if(--previous.owners===0){if(previous.value)video.style.setProperty('pointer-events',previous.value,previous.priority);else video.style.removeProperty('pointer-events');panVideoOverrides.delete(video);}
39
+ };
40
+ const syncVideos=()=>{
41
+ const current=new Set(viewport.querySelectorAll<HTMLVideoElement>('video[controls]'));
42
+ for(const video of videos)if(!current.has(video)){releaseVideo(video);videos.delete(video);}
43
+ for(const video of current)if(!videos.has(video)){
44
+ const previous=panVideoOverrides.get(video);
45
+ if(previous)previous.owners++;else{panVideoOverrides.set(video,{owners:1,value:video.style.getPropertyValue('pointer-events'),priority:video.style.getPropertyPriority('pointer-events')});video.style.setProperty('pointer-events','none','important');}
46
+ videos.add(video);
47
+ }
48
+ };
49
+ const observer=new MutationObserver(()=>{if(space)syncVideos();});
50
+ const releaseSpace=()=>{space=false;observer.disconnect();for(const video of videos)releaseVideo(video);videos.clear();};
51
+ const releasePointer=()=>{const id=pointer;pointer=null;delete viewport.dataset.panning;if(id!==null&&viewport.hasPointerCapture(id))viewport.releasePointerCapture(id);};
52
+ const keyDown=(event:KeyboardEvent)=>{if(event.code==='Space'&&enabled()&&!(event.target instanceof Element&&event.target.closest('input,textarea,select,button'))){
53
+ if(!space){space=true;syncVideos();observer.observe(viewport,{childList:true,subtree:true,attributes:true,attributeFilter:['controls']});}event.preventDefault();
54
+ }};
55
+ const keyUp=(event:KeyboardEvent)=>{if(event.code==='Space')releaseSpace();};const blur=()=>{releaseSpace();releasePointer();};
56
+ const start=(event:PointerEvent)=>{if(!enabled()||!event.isPrimary||event.button!==0)return;const target=event.target;if(target instanceof Element&&(target.closest('button,input,select,textarea,a')||target.closest('video[controls]')&&!space))return;pointer=event.pointerId;x=event.clientX;y=event.clientY;viewport.setPointerCapture(pointer);viewport.dataset.panning='true';event.preventDefault();};
57
+ const drag=(event:PointerEvent)=>{if(event.pointerId!==pointer)return;const dx=event.clientX-x,dy=event.clientY-y;x=event.clientX;y=event.clientY;move(dx,dy);event.preventDefault();};
58
+ const end=(event:PointerEvent)=>{if(event.pointerId===pointer)releasePointer();};
59
+ const native=(event:DragEvent)=>event.preventDefault();
60
+ window.addEventListener('blur',blur);document.addEventListener('keydown',keyDown,true);window.addEventListener('keyup',keyUp,true);viewport.addEventListener('pointerdown',start);viewport.addEventListener('pointermove',drag);viewport.addEventListener('pointerup',end);viewport.addEventListener('pointercancel',end);viewport.addEventListener('lostpointercapture',end);viewport.addEventListener('dragstart',native);
61
+ return ()=>{blur();window.removeEventListener('blur',blur);document.removeEventListener('keydown',keyDown,true);window.removeEventListener('keyup',keyUp,true);viewport.removeEventListener('pointerdown',start);viewport.removeEventListener('pointermove',drag);viewport.removeEventListener('pointerup',end);viewport.removeEventListener('pointercancel',end);viewport.removeEventListener('lostpointercapture',end);viewport.removeEventListener('dragstart',native);};
62
+ }
63
+
64
+ /** beforeChange runs while the old panel is still visible; changed runs after the new one is shown. */
65
+ export function bindInspectorTabs(changed:()=>void,beforeChange:()=>void=()=>{}):void {
66
+ const tabs=[...document.querySelectorAll<HTMLButtonElement>('[data-inspector]')];
67
+ const activate=(tab:HTMLButtonElement)=>{beforeChange();for(const item of tabs){const active=item===tab;item.setAttribute('aria-selected',String(active));item.tabIndex=active?0:-1;}for(const panel of document.querySelectorAll<HTMLElement>('[data-inspector-panel]'))panel.hidden=panel.dataset.inspectorPanel!==tab.dataset.inspector;changed();};
68
+ for(const [index,tab] of tabs.entries()){tab.onclick=()=>activate(tab);tab.onkeydown=event=>{const next=event.key==='ArrowRight'?(index+1)%tabs.length:event.key==='ArrowLeft'?(index+tabs.length-1)%tabs.length:event.key==='Home'?0:event.key==='End'?tabs.length-1:null;if(next===null)return;event.preventDefault();const target=tabs[next];if(target){activate(target);target.focus();}};}
69
+ }
@@ -0,0 +1,84 @@
1
+ /** Effective workspace geometry and local DOM bindings. Never stores source or global preferences. */
2
+ import {parseWorkspacePanels,panelBounds,type WorkspacePanels} from './workspace-preferences.ts';
3
+ export type WorkspacePanel=keyof WorkspacePanels;
4
+ export const workspaceMinimums=Object.freeze({stageWidth:240,stageHeight:180,separator:8});
5
+ export interface SeparatorRange {min:number;max:number;value:number;enabled:boolean}
6
+ export interface EffectiveWorkspaceLayout {leftWidth:number;rightWidth:number;inspectorHeight:number;stageWidth:number;stageHeight:number;leftMode:'panel'|'drawer';rightMode:'panel'|'drawer';inspectorMode:'panel'|'drawer';stageMinimumSatisfied:boolean;separators:Record<WorkspacePanel,SeparatorRange>}
7
+ const clamp=(value:number,min:number,max:number)=>Math.max(min,Math.min(max,value));
8
+ /** Input rectangle excludes app chrome. Drawer triggers must remain outside collapsed panel content.
9
+ * Collapse left, then right when minima cannot coexist; Inspector becomes a drawer below its minimum.
10
+ * Returned clamps never replace the user's desired desktop sizes. */
11
+ export function allocateWorkspaceLayout(available:{width:number;height:number},desired:WorkspacePanels):EffectiveWorkspaceLayout {
12
+ const panels=parseWorkspacePanels(desired),{width,height}=available,{stageWidth:minWidth,stageHeight:minHeight,separator}=workspaceMinimums;
13
+ if(![width,height].every(n=>Number.isFinite(n)&&n>=0&&n<=65536))throw new Error('WORKSPACE_RECTANGLE: finite 0..65536 CSS pixels required');
14
+ let left=true,right=true;if(width<panelBounds.leftWidth.min+panelBounds.rightWidth.min+minWidth+2*separator)left=false;if(width<(left?panelBounds.leftWidth.min+separator:0)+panelBounds.rightWidth.min+minWidth+separator)right=false;
15
+ const count=Number(left)+Number(right),budget=Math.max(0,width-minWidth-count*separator),leftMin=left?panelBounds.leftWidth.min:0,rightMin=right?panelBounds.rightWidth.min:0,wantLeft=left?panels.leftWidth:0,wantRight=right?panels.rightWidth:0;
16
+ const slack=wantLeft+wantRight-leftMin-rightMin,ratio=slack>0?Math.min(1,Math.max(0,(budget-leftMin-rightMin)/slack)):1,leftWidth=leftMin+(wantLeft-leftMin)*ratio,rightWidth=rightMin+(wantRight-rightMin)*ratio;
17
+ const inspector=height>=minHeight+panelBounds.inspectorHeight.min+separator,inspectorHeight=inspector?Math.min(panels.inspectorHeight,height-minHeight-separator):0;
18
+ const stageWidth=Math.max(0,width-leftWidth-rightWidth-count*separator),stageHeight=Math.max(0,height-inspectorHeight-(inspector?separator:0));
19
+ const range=(visible:boolean,key:WorkspacePanel,value:number,max:number):SeparatorRange=>visible?{min:panelBounds[key].min,max:Math.max(panelBounds[key].min,Math.min(panelBounds[key].max,max)),value,enabled:true}:{min:0,max:0,value:0,enabled:false};
20
+ return {leftWidth,rightWidth,inspectorHeight,stageWidth,stageHeight,leftMode:left?'panel':'drawer',rightMode:right?'panel':'drawer',inspectorMode:inspector?'panel':'drawer',stageMinimumSatisfied:width>=minWidth&&height>=minHeight,separators:{leftWidth:range(left,'leftWidth',leftWidth,width-rightWidth-count*separator-minWidth),rightWidth:range(right,'rightWidth',rightWidth,width-leftWidth-count*separator-minWidth),inspectorHeight:range(inspector,'inspectorHeight',inspectorHeight,height-minHeight-separator)}};
21
+ }
22
+ export interface WorkspaceSeparatorOptions {element:HTMLElement;panel:WorkspacePanel;controls:string;label:string;range:()=>SeparatorRange;resize:(desired:number)=>void;desired?:()=>number;resolveDesired?:(effective:number,context:{range:SeparatorRange;desired:number})=>number;commit?:(desired:number)=>void;events?:Pick<EventTarget,'addEventListener'|'removeEventListener'>|null;step?:number;coarseStep?:number}
23
+ /** Arrow keys act only on the separator itself. Right/bottom boundaries grow inward.
24
+ * Pointer cancellation, capture loss, Escape and blur restore the prior desired size.
25
+ * Coupled allocators supply their reachable effective range and resolveDesired inverse;
26
+ * resize/commit receive desired sizes, including when effective geometry is clamped. */
27
+ export function bindWorkspaceSeparator(options:WorkspaceSeparatorOptions):{refresh():void;dispose():void} {
28
+ const {element,panel}=options,vertical=panel!=='inspectorHeight',direction=panel==='leftWidth'?1:-1,events=options.events===undefined?(typeof window==='undefined'?null:window):options.events;
29
+ if(!options.controls||!options.label||/[\x00-\x1f]/.test(options.controls+options.label))throw new Error('WORKSPACE_SEPARATOR: controls and accessible label required');
30
+ const step=options.step??8,coarse=options.coarseStep??40;if(![step,coarse].every(n=>Number.isFinite(n)&&n>0))throw new Error('WORKSPACE_SEPARATOR: positive finite keyboard steps required');
31
+ const attrs=['role','aria-orientation','aria-controls','aria-label','aria-valuemin','aria-valuemax','aria-valuenow','aria-valuetext','aria-disabled'],saved=new Map(attrs.map(key=>[key,element.getAttribute(key)])),tabIndex=element.tabIndex,touchAction=element.style.touchAction;
32
+ let drag:{id:number;coordinate:number;value:number;desired:number;last:number;changed:boolean}|null=null,disposed=false;
33
+ const current=()=>{const range=options.range();if(![range.min,range.max,range.value].every(Number.isFinite)||range.min>range.max)throw new Error('WORKSPACE_SEPARATOR_RANGE: finite ordered range required');return {...range,value:clamp(range.value,range.min,range.max)};};
34
+ const release=(cancel:boolean)=>{const previous=drag;if(!previous)return;drag=null;delete element.dataset.resizing;try{if(element.hasPointerCapture(previous.id))element.releasePointerCapture(previous.id);}finally{if(previous.changed){if(cancel)options.resize(previous.desired);else options.commit?.(previous.last);}}};
35
+ const refresh=()=>{if(disposed)return;let range=current(),enabled=range.enabled&&range.max>range.min;if(!enabled&&drag){release(true);range=current();enabled=range.enabled&&range.max>range.min;}element.setAttribute('role','separator');element.setAttribute('aria-orientation',vertical?'vertical':'horizontal');element.setAttribute('aria-controls',options.controls);element.setAttribute('aria-label',options.label);element.setAttribute('aria-valuemin',String(Math.round(range.min)));element.setAttribute('aria-valuemax',String(Math.round(range.max)));element.setAttribute('aria-valuenow',String(Math.round(range.value)));element.setAttribute('aria-valuetext',`${Math.round(range.value)} pixels`);element.setAttribute('aria-disabled',String(!enabled));element.tabIndex=enabled?0:-1;};
36
+ const apply=(value:number,beforeResize?:(desired:number)=>void)=>{const range=current(),next=clamp(value,range.min,range.max),desired=options.desired?.()??range.value;if(Math.abs(next-range.value)<=1e-7)return {desired,changed:false};const resolved=options.resolveDesired?.(next,{range,desired})??next;if(!Number.isFinite(resolved))throw new Error('WORKSPACE_SEPARATOR: finite resolved desired size required');beforeResize?.(resolved);options.resize(resolved);refresh();return {desired:resolved,changed:true};};
37
+ const down=(event:PointerEvent)=>{if(disposed||drag||!event.isPrimary||event.button!==0)return;const target=event.target as Element|null;if(target!==element&&target&&typeof target.closest==='function'&&target.closest('input,textarea,select,button,[contenteditable]'))return;const range=current();if(!range.enabled||range.min===range.max)return;const coordinate=vertical?event.clientX:event.clientY;if(!Number.isFinite(coordinate))return;const desired=options.desired?.()??range.value;if(!Number.isFinite(desired))throw new Error('WORKSPACE_SEPARATOR: finite desired start required');drag={id:event.pointerId,coordinate,value:range.value,desired,last:desired,changed:false};try{element.setPointerCapture(event.pointerId);}catch(error){drag=null;throw error;}element.dataset.resizing='true';element.focus({preventScroll:true});event.preventDefault();};
38
+ const move=(event:PointerEvent)=>{if(!drag||event.pointerId!==drag.id)return;const coordinate=vertical?event.clientX:event.clientY;if(!Number.isFinite(coordinate))return;const active=drag;apply(active.value+(coordinate-active.coordinate)*direction,desired=>{active.last=desired;active.changed=true;});event.preventDefault();};
39
+ const up=(event:PointerEvent)=>{if(drag?.id!==event.pointerId)return;release(false);refresh();};
40
+ const cancel=(event:PointerEvent)=>{if(drag?.id!==event.pointerId)return;release(true);refresh();};
41
+ const blur=()=>{release(true);refresh();};
42
+ const key=(event:KeyboardEvent)=>{if(event.target!==element||event.altKey||event.ctrlKey||event.metaKey)return;if(event.key==='Escape'&&drag){event.preventDefault();release(true);refresh();return;}const range=current();if(!range.enabled||range.min===range.max)return;const amount=event.shiftKey?coarse:step,delta=vertical?(event.key==='ArrowRight'?direction*amount:event.key==='ArrowLeft'?-direction*amount:null):(event.key==='ArrowDown'?-amount:event.key==='ArrowUp'?amount:null);const next=event.key==='Home'?range.min:event.key==='End'?range.max:delta===null?null:range.value+delta;if(next===null)return;event.preventDefault();const result=apply(next);if(result.changed)options.commit?.(result.desired);};
43
+ element.style.touchAction='none';element.addEventListener('pointerdown',down);element.addEventListener('pointermove',move);element.addEventListener('pointerup',up);element.addEventListener('pointercancel',cancel);element.addEventListener('lostpointercapture',cancel);element.addEventListener('keydown',key);events?.addEventListener('blur',blur);refresh();
44
+ return {refresh,dispose(){if(disposed)return;release(true);disposed=true;element.removeEventListener('pointerdown',down);element.removeEventListener('pointermove',move);element.removeEventListener('pointerup',up);element.removeEventListener('pointercancel',cancel);element.removeEventListener('lostpointercapture',cancel);element.removeEventListener('keydown',key);events?.removeEventListener('blur',blur);element.style.touchAction=touchAction;element.tabIndex=tabIndex;for(const [key,value]of saved)if(value===null)element.removeAttribute(key);else element.setAttribute(key,value);}};
45
+ }
46
+ /** Use a native modal dialog for collapsed panels: focus containment/Escape belong to the browser.
47
+ * The trigger remains outside the collapsed panel; closing returns focus to that trigger. */
48
+ export function bindWorkspaceDrawer(options:{trigger:HTMLButtonElement;drawer:HTMLDialogElement;label:string;close?:HTMLButtonElement}):()=>void {
49
+ const {trigger,drawer}=options;if(!drawer.id||!options.label)throw new Error('WORKSPACE_DRAWER: named dialog and trigger required');let opened=false;
50
+ trigger.setAttribute('aria-haspopup','dialog');trigger.setAttribute('aria-controls',drawer.id);trigger.setAttribute('aria-expanded',String(drawer.open));if(!drawer.getAttribute('aria-label')&&!drawer.getAttribute('aria-labelledby'))drawer.setAttribute('aria-label',options.label);
51
+ const close=()=>{if(drawer.open)drawer.close();closed();};const toggle=()=>{if(drawer.open){close();return;}drawer.showModal();opened=true;trigger.setAttribute('aria-expanded','true');};const closed=()=>{trigger.setAttribute('aria-expanded','false');if(opened){opened=false;trigger.focus({preventScroll:true});}};
52
+ trigger.addEventListener('click',toggle);drawer.addEventListener('close',closed);options.close?.addEventListener('click',close);
53
+ return ()=>{if(opened)close();trigger.removeEventListener('click',toggle);drawer.removeEventListener('close',closed);options.close?.removeEventListener('click',close);};
54
+ }
55
+ export const inspectorTabs=['view','rig','playback','feedback'] as const;
56
+ export type InspectorTab=typeof inspectorTabs[number];
57
+ export interface InspectorScrollOffset {x:number;y:number}
58
+ export type InspectorScrollSnapshot=Partial<Record<InspectorTab,InspectorScrollOffset>>;
59
+ export interface ScrollScheduler {request(callback:()=>void):number;cancel(handle:number):void}
60
+ const validTab=(tab:string):tab is InspectorTab=>inspectorTabs.includes(tab as InspectorTab);
61
+ export function parseInspectorScroll(value:unknown):InspectorScrollSnapshot {
62
+ if(!value||typeof value!=='object'||Array.isArray(value))throw new Error('INSPECTOR_SCROLL: expected tab object');const result:InspectorScrollSnapshot={};
63
+ for(const [tab,raw]of Object.entries(value)){if(!validTab(tab)||!raw||typeof raw!=='object'||Array.isArray(raw))throw new Error('INSPECTOR_SCROLL: unknown tab or malformed offset');const o=raw as Record<string,unknown>;if(Object.keys(o).some(key=>key!=='x'&&key!=='y')||![o.x,o.y].every(n=>typeof n==='number'&&Number.isFinite(n)&&n>=0&&n<=10000000))throw new Error('INSPECTOR_SCROLL: finite nonnegative x/y <=10000000 required');result[tab]={x:o.x as number,y:o.y as number};}return result;
64
+ }
65
+ const scrollScheduler:ScrollScheduler={request:callback=>typeof requestAnimationFrame==='function'?requestAnimationFrame(callback):Number(setTimeout(callback,0)),cancel:handle=>typeof cancelAnimationFrame==='function'?cancelAnimationFrame(handle):clearTimeout(handle)};
66
+ /** One instance per source draft. Capture BEFORE hiding a tab; restore AFTER activation.
67
+ * Two frame boundaries settle layout, and later content can explicitly restore again.
68
+ * Clamping never overwrites the remembered desired offsets with a temporary empty extent.
69
+ * Programmatic scroll events are ignored through the following frame; explicit user input can capture with true. */
70
+ export class InspectorScrollMemory {
71
+ private offsets:InspectorScrollSnapshot;private pending=new Map<InspectorTab,number>();private applied=new Map<InspectorTab,InspectorScrollOffset>();private disposed=false;
72
+ constructor(initial:InspectorScrollSnapshot={},private readonly scheduler:ScrollScheduler=scrollScheduler){this.offsets=parseInspectorScroll(initial);}
73
+ snapshot():InspectorScrollSnapshot{return parseInspectorScroll(this.offsets);}
74
+ replace(value:unknown):void {const next=parseInspectorScroll(value);for(const handle of this.pending.values())this.scheduler.cancel(handle);this.pending.clear();this.applied.clear();this.offsets=next;}
75
+ restoring(tab:InspectorTab):boolean{return this.pending.has(tab);}
76
+ capture(tab:InspectorTab,panel:Pick<HTMLElement,'scrollLeft'|'scrollTop'>,userInitiated=false):void {if(this.disposed||this.pending.has(tab)&&!userInitiated)return;if(!validTab(tab))throw new Error('INSPECTOR_SCROLL: unknown tab');const offset={x:Math.max(0,panel.scrollLeft),y:Math.max(0,panel.scrollTop)},applied=this.applied.get(tab);if(!userInitiated&&applied&&offset.x===applied.x&&offset.y===applied.y)return;const handle=this.pending.get(tab);if(handle!==undefined)this.scheduler.cancel(handle);this.pending.delete(tab);this.applied.delete(tab);this.offsets={...this.offsets,...parseInspectorScroll({[tab]:offset})};}
77
+ restore(tab:InspectorTab,panel:Pick<HTMLElement,'scrollLeft'|'scrollTop'|'scrollWidth'|'scrollHeight'|'clientWidth'|'clientHeight'>):()=>void {
78
+ if(this.disposed)return ()=>{};if(!validTab(tab))throw new Error('INSPECTOR_SCROLL: unknown tab');const previous=this.pending.get(tab);if(previous!==undefined)this.scheduler.cancel(previous);let cancelled=false;
79
+ let handle=0;const apply=()=>{if(cancelled||this.disposed||this.pending.get(tab)!==handle)return;this.pending.delete(tab);if(![panel.scrollWidth,panel.scrollHeight,panel.clientWidth,panel.clientHeight].every(n=>Number.isFinite(n)&&n>=0))throw new Error('INSPECTOR_SCROLL_LAYOUT: finite nonnegative extents required');const offset=this.offsets[tab]??{x:0,y:0};panel.scrollLeft=clamp(offset.x,0,Math.max(0,panel.scrollWidth-panel.clientWidth));panel.scrollTop=clamp(offset.y,0,Math.max(0,panel.scrollHeight-panel.clientHeight));this.applied.set(tab,{x:panel.scrollLeft,y:panel.scrollTop});handle=this.scheduler.request(()=>{if(this.pending.get(tab)===handle)this.pending.delete(tab);});this.pending.set(tab,handle);};
80
+ handle=this.scheduler.request(()=>{if(cancelled||this.disposed||this.pending.get(tab)!==handle)return;handle=this.scheduler.request(apply);this.pending.set(tab,handle);});this.pending.set(tab,handle);
81
+ return ()=>{cancelled=true;this.scheduler.cancel(handle);if(this.pending.get(tab)===handle)this.pending.delete(tab);};
82
+ }
83
+ dispose():void {if(this.disposed)return;this.disposed=true;for(const handle of this.pending.values())this.scheduler.cancel(handle);this.pending.clear();this.applied.clear();}
84
+ }
@@ -0,0 +1,95 @@
1
+ /** Browser-owned app defaults. Source inspection, review data and capabilities never belong here. */
2
+ export const workspacePreferenceKey='rig-review-preferences/1';
3
+ export const legacyAppearanceKey='rig-review-appearance/1';
4
+ export type WorkspaceTheme='system'|'dark'|'light';
5
+ export interface WorkspacePanels {leftWidth:number;rightWidth:number;inspectorHeight:number}
6
+ export const panelBounds=Object.freeze({leftWidth:Object.freeze({min:120,max:720}),rightWidth:Object.freeze({min:180,max:900}),inspectorHeight:Object.freeze({min:160,max:900})});
7
+ export const defaultWorkspacePanels:Readonly<WorkspacePanels>=Object.freeze({leftWidth:170,rightWidth:260,inspectorHeight:280});
8
+ type Field='theme'|'defaultCameraSync'|'defaultIdleRepeat'|keyof WorkspacePanels;
9
+ const fields:readonly Field[]=['theme','defaultCameraSync','defaultIdleRepeat','leftWidth','rightWidth','inspectorHeight'];
10
+ interface FieldStamp {revision:number;writer:string}
11
+ export interface WorkspacePreferences {schema:'rig-review-preferences/1';revision:number;theme:WorkspaceTheme;defaultCameraSync:boolean;defaultIdleRepeat:boolean;panels:WorkspacePanels;stamps:Record<Field,FieldStamp>}
12
+ export interface WorkspacePreferencePatch {theme?:WorkspaceTheme;defaultCameraSync?:boolean;defaultIdleRepeat?:boolean;panels?:Partial<WorkspacePanels>}
13
+ export interface PreferenceStorage {getItem(key:string):string|null;setItem(key:string,value:string):void}
14
+ export interface PreferenceStatus {state:'available'|'memory-only'|'corrupt';detail:string}
15
+ export interface PreferenceEventSource {addEventListener(type:string,listener:EventListener):void;removeEventListener(type:string,listener:EventListener):void}
16
+ export interface WorkspacePreferenceOptions {storage?:PreferenceStorage|null;events?:PreferenceEventSource|null;writer?:string;onStatus?:(status:PreferenceStatus)=>void}
17
+ const object=(v:unknown,label:string):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw new Error(`${label}: expected object`);return v as Record<string,unknown>;};
18
+ const closed=(v:Record<string,unknown>,allowed:readonly string[],label:string)=>{for(const key of Object.keys(v))if(!allowed.includes(key))throw new Error(`${label}.${key}: unknown field`);};
19
+ const theme=(v:unknown):WorkspaceTheme=>{if(v!=='system'&&v!=='dark'&&v!=='light')throw new Error('PREFERENCES_THEME: expected system, dark or light');return v;};
20
+ const boolean=(v:unknown,label:string):boolean=>{if(typeof v!=='boolean')throw new Error(`${label}: expected boolean`);return v;};
21
+ const panel=(v:unknown,key:keyof WorkspacePanels):number=>{const {min,max}=panelBounds[key];if(typeof v!=='number'||!Number.isFinite(v)||v<min||v>max)throw new Error(`PREFERENCES_PANEL: ${key} requires finite ${min}..${max} CSS pixels`);return v;};
22
+ export function parseWorkspacePanels(value:unknown):WorkspacePanels {const o=object(value,'panels');closed(o,Object.keys(panelBounds),'panels');return {leftWidth:panel(o.leftWidth,'leftWidth'),rightWidth:panel(o.rightWidth,'rightWidth'),inspectorHeight:panel(o.inspectorHeight,'inspectorHeight')};}
23
+ export function defaultWorkspacePreferences():WorkspacePreferences {return {schema:workspacePreferenceKey,revision:0,theme:'system',defaultCameraSync:true,defaultIdleRepeat:true,panels:{...defaultWorkspacePanels},stamps:Object.fromEntries(fields.map(field=>[field,{revision:0,writer:''}])) as Record<Field,FieldStamp>};}
24
+ const clone=(v:WorkspacePreferences):WorkspacePreferences=>({...v,panels:{...v.panels},stamps:Object.fromEntries(fields.map(field=>[field,{...v.stamps[field]}])) as Record<Field,FieldStamp>});
25
+ function leaf(value:WorkspacePreferences,field:Field):WorkspaceTheme|boolean|number {return field in panelBounds?value.panels[field as keyof WorkspacePanels]:value[field as 'theme'|'defaultCameraSync'|'defaultIdleRepeat'];}
26
+ function put(value:WorkspacePreferences,field:Field,v:WorkspaceTheme|boolean|number):void {if(field==='theme')value.theme=theme(v);else if(field==='defaultCameraSync'||field==='defaultIdleRepeat')value[field]=boolean(v,field);else value.panels[field]=panel(v,field);}
27
+ export function parseWorkspacePreferences(value:unknown):WorkspacePreferences {
28
+ const o=object(value,'preferences');closed(o,['schema','revision','theme','defaultCameraSync','defaultIdleRepeat','panels','stamps'],'preferences');if(o.schema!==workspacePreferenceKey)throw new Error('PREFERENCES_VERSION: unsupported schema');
29
+ const result=defaultWorkspacePreferences();result.theme=theme(o.theme);result.defaultCameraSync=boolean(o.defaultCameraSync,'defaultCameraSync');result.defaultIdleRepeat=boolean(o.defaultIdleRepeat,'defaultIdleRepeat');result.panels=parseWorkspacePanels(o.panels);
30
+ const stamps=object(o.stamps,'stamps');closed(stamps,fields,'stamps');
31
+ for(const field of fields){const stamp=object(stamps[field],`stamps.${field}`);closed(stamp,['revision','writer'],`stamps.${field}`);if(typeof stamp.revision!=='number'||!Number.isSafeInteger(stamp.revision)||stamp.revision<0||typeof stamp.writer!=='string'||!(/^[A-Za-z0-9_-]{1,80}$/.test(stamp.writer)||stamp.revision===0&&stamp.writer===''))throw new Error(`PREFERENCES_STAMP: invalid ${field} revision/writer`);if(stamp.revision===0&&(stamp.writer!==''||leaf(result,field)!==leaf(defaultWorkspacePreferences(),field)))throw new Error(`PREFERENCES_STAMP: unstamped ${field} differs from defaults`);result.stamps[field]={revision:stamp.revision,writer:stamp.writer};}
32
+ const revision=Math.max(...fields.map(field=>result.stamps[field].revision));if(o.revision!==revision)throw new Error('PREFERENCES_REVISION: record revision must equal maximum field revision');result.revision=revision;return result;
33
+ }
34
+ function parseText(raw:string):WorkspacePreferences {if(raw.length>8192)throw new Error('PREFERENCES_SIZE: record exceeds 8192 characters');return parseWorkspacePreferences(JSON.parse(raw));}
35
+ function patches(value:unknown):Map<Field,WorkspaceTheme|boolean|number> {
36
+ const o=object(value,'preference patch');closed(o,['theme','defaultCameraSync','defaultIdleRepeat','panels'],'preference patch');const patch=new Map<Field,WorkspaceTheme|boolean|number>();
37
+ if('theme'in o)patch.set('theme',theme(o.theme));for(const field of ['defaultCameraSync','defaultIdleRepeat'] as const)if(field in o)patch.set(field,boolean(o[field],field));
38
+ if('panels'in o){const p=object(o.panels,'panel patch');closed(p,Object.keys(panelBounds),'panel patch');for(const key of Object.keys(p) as (keyof WorkspacePanels)[])patch.set(key,panel(p[key],key));}return patch;
39
+ }
40
+ /** Per-field Lamport stamps converge even when independent tabs wrote stale whole records.
41
+ * Equal-field concurrent writes use revision then writer ordering, never wall-clock time. */
42
+ export function mergeWorkspacePreferences(...records:WorkspacePreferences[]):WorkspacePreferences {
43
+ const result=defaultWorkspacePreferences();
44
+ for(const raw of records){const value=parseWorkspacePreferences(raw);for(const field of fields){const a=result.stamps[field],b=value.stamps[field],order=b.revision-a.revision|| (b.writer<a.writer?-1:b.writer>a.writer?1:0);if(order>0){put(result,field,leaf(value,field));result.stamps[field]={...b};}else if(order===0&&leaf(result,field)!==leaf(value,field))throw new Error(`PREFERENCES_STAMP_CONFLICT: ${field} has two values at one stamp`);}}
45
+ result.revision=Math.max(...fields.map(field=>result.stamps[field].revision));return result;
46
+ }
47
+ let fallbackWriter=0;
48
+ export class WorkspacePreferenceStore {
49
+ private value=defaultWorkspacePreferences();private persistence:PreferenceStatus={state:'available',detail:'Browser preferences use defaults.'};private readonly storage:PreferenceStorage|null;private readonly events:PreferenceEventSource|null;private readonly writer:string;private listeners=new Set<(value:WorkspacePreferences)=>void>();private conflicts=new Set<Field>();private closed=false;
50
+ constructor(private readonly options:WorkspacePreferenceOptions={}){
51
+ this.writer=options.writer??(globalThis.crypto?.randomUUID?.()??`local-${Date.now().toString(36)}-${++fallbackWriter}`);if(!/^[A-Za-z0-9_-]{1,80}$/.test(this.writer))throw new Error('PREFERENCES_WRITER: invalid tab writer');
52
+ let storage=options.storage??null;if(options.storage===undefined){try{storage=globalThis.localStorage;}catch{this.statusTo('memory-only','Browser storage access was denied; preferences remain usable in this page.');}}this.storage=storage;this.events=options.events===undefined?(typeof window==='undefined'?null:window):options.events;
53
+ const loaded=this.read();if(loaded.kind==='valid'){this.value=loaded.value;this.statusTo('available','Saved browser preferences loaded.');}else if(loaded.kind==='absent')this.migrateAppearance();this.events?.addEventListener('storage',this.storageEvent);
54
+ }
55
+ get():WorkspacePreferences{return clone(this.value);}
56
+ status():PreferenceStatus{return {...this.persistence};}
57
+ subscribe(listener:(value:WorkspacePreferences)=>void):()=>void {this.listeners.add(listener);return ()=>this.listeners.delete(listener);}
58
+ update(patch:WorkspacePreferencePatch):WorkspacePreferences {
59
+ if(this.closed)throw new Error('PREFERENCES_DISPOSED');const changes=patches(patch);if(!changes.size)return this.get();const loaded=this.read();if(loaded.kind==='valid')this.reconcile(loaded.value);const next=clone(this.value);
60
+ if(next.revision===Number.MAX_SAFE_INTEGER)throw new Error('PREFERENCES_REVISION_EXHAUSTED');const revision=next.revision+1;for(const [field,v]of changes)put(next,field,v);for(const field of new Set([...changes.keys(),...this.conflicts]))next.stamps[field]={revision,writer:this.writer};next.revision=revision;this.conflicts.clear();this.value=next;this.persist();this.notify();return this.get();
61
+ }
62
+ resetLayout():WorkspacePreferences{return this.update({panels:{...defaultWorkspacePanels}});}
63
+ /** Reconcile after resuming a suspended tab as well as on storage events. */
64
+ refresh():WorkspacePreferences {if(this.closed)return this.get();const loaded=this.read();if(loaded.kind==='valid'){this.reconcile(loaded.value);if(!this.conflicts.size)this.persist();this.notify();}return this.get();}
65
+ dispose():void {if(this.closed)return;this.closed=true;this.events?.removeEventListener('storage',this.storageEvent);this.listeners.clear();}
66
+ private statusTo(state:PreferenceStatus['state'],detail:string){this.persistence={state,detail};this.options.onStatus?.(this.status());}
67
+ /** Quarantine only equal-stamp conflicting leaves; retain every independent valid update.
68
+ * An explicit edit gives quarantined known values a new stamp before repairing storage. */
69
+ private reconcile(...records:WorkspacePreferences[]):void {
70
+ const next=clone(this.value);
71
+ for(const value of records)for(const field of fields){const a=next.stamps[field],b=value.stamps[field],order=b.revision-a.revision||(b.writer<a.writer?-1:b.writer>a.writer?1:0);if(order>0){put(next,field,leaf(value,field));next.stamps[field]={...b};this.conflicts.delete(field);}else if(order===0&&leaf(next,field)!==leaf(value,field))this.conflicts.add(field);}
72
+ next.revision=Math.max(...fields.map(field=>next.stamps[field].revision));this.value=next;if(this.conflicts.size)this.statusTo('corrupt',`Browser preference fields were refused because their stamps conflict: ${[...this.conflicts].join(', ')}. Known values remain usable; an explicit edit repairs them.`);
73
+ }
74
+ private read():{kind:'valid';value:WorkspacePreferences}|{kind:'absent'|'denied'|'corrupt'} {
75
+ if(!this.storage){this.statusTo('memory-only','Browser storage is unavailable; preferences remain usable in this page.');return {kind:'denied'};}
76
+ let raw:string|null;try{raw=this.storage.getItem(workspacePreferenceKey);}catch{this.statusTo('memory-only','Browser storage read was denied; preferences remain usable in this page.');return {kind:'denied'};}
77
+ if(raw===null)return {kind:'absent'};try{return {kind:'valid',value:parseText(raw)};}catch(error){this.statusTo('corrupt',`Saved browser preferences were refused: ${error instanceof Error?error.message:String(error)}. Current page defaults remain usable.`);return {kind:'corrupt'};}
78
+ }
79
+ private persist():void {
80
+ if(!this.storage)return;const latest=this.read();if(latest.kind==='valid')this.reconcile(latest.value);if(this.conflicts.size)return;
81
+ const text=JSON.stringify(this.value);if(latest.kind==='valid'&&JSON.stringify(latest.value)===text){this.statusTo('available','Browser preferences are current.');return;}
82
+ try{this.storage.setItem(workspacePreferenceKey,text);this.statusTo('available','Browser preferences saved.');}catch{this.statusTo('memory-only','Browser storage write was denied; changed preferences remain usable in this page.');}
83
+ }
84
+ private migrateAppearance():void {
85
+ if(!this.storage)return;let raw:string|null;try{raw=this.storage.getItem(legacyAppearanceKey);}catch{this.statusTo('memory-only','Legacy appearance storage read was denied; defaults remain usable.');return;}if(raw===null)return;
86
+ if(raw!=='system'&&raw!=='dark'&&raw!=='light'){this.statusTo('corrupt','Legacy appearance value was refused; defaults remain usable.');return;}
87
+ this.value.theme=raw;this.value.stamps.theme={revision:1,writer:this.writer};this.value.revision=1;this.persist();
88
+ }
89
+ private storageEvent:EventListener=(event)=>{
90
+ if(this.closed)return;const e=event as StorageEvent;if(e.key!==workspacePreferenceKey&&e.key!==null||e.storageArea&&e.storageArea!==this.storage)return;const latest=this.read();
91
+ if(latest.kind==='absent'){this.value=defaultWorkspacePreferences();this.conflicts.clear();this.statusTo('available','Browser preferences were cleared; defaults remain usable.');this.notify();return;}
92
+ try{const incoming=e.newValue===null||e.key===null?null:parseText(e.newValue);this.reconcile(...(incoming?[incoming]:[]),...(latest.kind==='valid'?[latest.value]:[]));if(latest.kind!=='corrupt'&&!this.conflicts.size)this.persist();this.notify();}catch(error){this.statusTo('corrupt',`Browser preference update was refused: ${error instanceof Error?error.message:String(error)}.`);}
93
+ };
94
+ private notify(){for(const listener of this.listeners)listener(this.get());}
95
+ }
@@ -0,0 +1,91 @@
1
+ /** Browser workspace presentation. Source selection, player clocks and review decisions stay with the page. */
2
+ import {WorkspacePreferenceStore,panelBounds,type WorkspacePanels,type WorkspacePreferences,type WorkspaceTheme} from './workspace-preferences.ts';
3
+ import {allocateWorkspaceLayout,bindWorkspaceSeparator,bindWorkspaceDrawer,InspectorScrollMemory,inspectorTabs,type InspectorTab,type WorkspacePanel,type SeparatorRange} from './workspace-layout.ts';
4
+
5
+ /** Reachable effective bounds, accounting for the other panel's saved desired size. */
6
+ export function workspaceResizeRange(available:{width:number;height:number},desired:WorkspacePanels,panel:WorkspacePanel):SeparatorRange {
7
+ const base=allocateWorkspaceLayout(available,desired),range=base.separators[panel];if(!range.enabled)return range;
8
+ const low=allocateWorkspaceLayout(available,{...desired,[panel]:panelBounds[panel].min})[panel],high=allocateWorkspaceLayout(available,{...desired,[panel]:panelBounds[panel].max})[panel];
9
+ return {min:low,max:high,value:base[panel],enabled:high>low};
10
+ }
11
+ /** Invert the allocator instead of storing an already-clamped pixel value as a desktop desire. */
12
+ export function workspaceResizeTarget(available:{width:number;height:number},desired:WorkspacePanels,panel:WorkspacePanel,target:number):number {
13
+ const range=workspaceResizeRange(available,desired,panel);if(!range.enabled)return desired[panel];
14
+ if(target<=range.min)return panelBounds[panel].min;if(target>=range.max)return panelBounds[panel].max;
15
+ let low:number=panelBounds[panel].min,high:number=panelBounds[panel].max;
16
+ for(let i=0;i<48;i++){const mid=(low+high)/2,value=allocateWorkspaceLayout(available,{...desired,[panel]:mid})[panel];if(value<target)low=mid;else high=mid;}
17
+ return (low+high)/2;
18
+ }
19
+ export interface WorkspaceUI {
20
+ defaults:()=>WorkspacePreferences;beforeTab:()=>void;afterTab:()=>void;scroll:()=>string;restoreScroll:(value:unknown)=>void;
21
+ revealInspector:()=>void;refresh:()=>void;dispose:()=>void;
22
+ }
23
+ export function bindWorkspaceUI(options:{changed:()=>void;defaultTheme:(theme:WorkspaceTheme)=>void}):WorkspaceUI {
24
+ const require=<T extends HTMLElement=HTMLElement>(id:string)=>{const node=document.getElementById(id);if(!node)throw new Error('Missing workspace element: '+id);return node as T;};
25
+ const layout=document.querySelector<HTMLElement>('.layout')!,main=layout.querySelector<HTMLElement>('main')!,stage=require('stage-layout'),inspector=document.querySelector<HTMLElement>('.inspector')!,right=require('parts-explorer');
26
+ layout.classList.add('workspace-layout');
27
+ let left=layout.querySelector<HTMLElement>('.left-panel');if(!left){left=document.createElement('aside');left.className='left-panel';left.innerHTML='<h2>INPUTS</h2><p class="hint">A and B retain their fixed source bindings. The reference video and shared animation are named above the stage.</p>';layout.prepend(left);}left.id='workspace-left';inspector.id='workspace-inspector';
28
+ const sourceList=document.createElement('div');sourceList.className='workspace-sources';sourceList.append(...left.childNodes);left.append(sourceList);
29
+ const settings=document.createElement('section');settings.className='workspace-settings';settings.setAttribute('aria-label','Settings');
30
+ settings.innerHTML='<h2>Settings</h2><label>Theme <select id="settings-theme" data-app-preference><option value="system">System</option><option value="dark">Dark</option><option value="light">Light</option></select></label><label><input id="settings-sync" data-app-preference type="checkbox">Sync cameras by default</label><label><input id="settings-idle" data-app-preference type="checkbox">Repeat idle by default</label><button id="settings-reset" type="button">Reset layout</button><p class="hint">Defaults apply to new inspections. Links and source drafts retain their explicit view.</p><p id="settings-state" class="hint" role="status"></p>';
31
+ left.append(settings);
32
+ const theme=require<HTMLSelectElement>('settings-theme'),sync=require<HTMLInputElement>('settings-sync'),idle=require<HTMLInputElement>('settings-idle'),status=require('settings-state');
33
+ const store=new WorkspacePreferenceStore({onStatus:value=>{status.textContent=value.detail;}});let desired={...store.get().panels};
34
+ const stack=document.createElement('div');stack.className='workspace-stage-stack';for(const child of [...main.children])if(child!==inspector)stack.append(child);main.prepend(stack);
35
+ const triggers=document.createElement('div');triggers.className='workspace-drawer-triggers';triggers.setAttribute('aria-label','Workspace panels');stack.prepend(triggers);
36
+ const panels:Record<WorkspacePanel,HTMLElement>={leftWidth:left,rightWidth:right,inspectorHeight:inspector};
37
+ const labels:Record<WorkspacePanel,string>={leftWidth:'Sources and settings',rightWidth:'Parts explorer',inspectorHeight:'Inspector'};
38
+ const handles={} as Record<WorkspacePanel,HTMLElement>,dialogs={} as Record<WorkspacePanel,HTMLDialogElement>,buttons={} as Record<WorkspacePanel,HTMLButtonElement>,disposers:(()=>void)[]=[];
39
+ for(const panel of Object.keys(panels) as WorkspacePanel[]){
40
+ const handle=document.createElement('div');handle.className='workspace-separator workspace-'+panel;handles[panel]=handle;
41
+ if(panel==='leftWidth')left.after(handle);else if(panel==='rightWidth')right.before(handle);else inspector.before(handle);
42
+ const trigger=document.createElement('button');trigger.type='button';trigger.textContent=labels[panel];trigger.hidden=true;triggers.append(trigger);buttons[panel]=trigger;
43
+ const dialog=document.createElement('dialog');dialog.className='workspace-drawer';dialog.id='workspace-drawer-'+panel;dialog.dataset.panel=panel;const close=document.createElement('button');close.type='button';close.textContent='Close '+labels[panel];close.className='workspace-drawer-close';dialog.append(close);document.body.append(dialog);dialogs[panel]=dialog;
44
+ disposers.push(bindWorkspaceDrawer({trigger,drawer:dialog,label:labels[panel],close}));
45
+ }
46
+ const memory=new InspectorScrollMemory();const active=():InspectorTab=>(document.querySelector<HTMLElement>('[data-inspector][aria-selected=true]')?.dataset.inspector as InspectorTab)??'view';
47
+ let disposed=false,frame=0,applying=false,available={width:0,height:0},effective=allocateWorkspaceLayout(available,desired);
48
+ const panelFor=(tab:InspectorTab)=>require('inspector-'+tab);
49
+ const capture=()=>{const tab=active(),panel=panelFor(tab),saved=memory.snapshot()[tab];if(!panel.hidden&&(!saved||saved.x<=Math.max(0,panel.scrollWidth-panel.clientWidth)&&saved.y<=Math.max(0,panel.scrollHeight-panel.clientHeight)))memory.capture(tab,panel);};
50
+ const restore=()=>{if(!disposed)memory.restore(active(),panelFor(active()));};
51
+ const schedule=()=>{if(!disposed&&!frame)frame=requestAnimationFrame(()=>{frame=0;apply();});};
52
+ const dock=(panel:WorkspacePanel,drawer:boolean)=>{
53
+ const node=panels[panel],dialog=dialogs[panel],trigger=buttons[panel];trigger.hidden=!drawer;handles[panel].hidden=drawer;
54
+ if(drawer&&node.parentElement!==dialog){const focus=node.contains(document.activeElement);dialog.append(node);if(focus)trigger.focus({preventScroll:true});}
55
+ if(!drawer&&node.parentElement===dialog){const focused=node.contains(document.activeElement)||document.activeElement===trigger;if(dialog.open)dialog.close();trigger.setAttribute('aria-expanded','false');if(panel==='leftWidth')layout.prepend(node);else if(panel==='rightWidth')layout.append(node);else main.append(node);if(focused){node.tabIndex=-1;node.focus({preventScroll:true});}}
56
+ };
57
+ const bindings=(Object.keys(panels) as WorkspacePanel[]).map(panel=>bindWorkspaceSeparator({element:handles[panel],panel,controls:panels[panel].id,label:'Resize '+labels[panel],range:()=>workspaceResizeRange(available,desired,panel),resolveDesired:value=>workspaceResizeTarget(available,desired,panel,value),desired:()=>desired[panel],resize:value=>{desired={...desired,[panel]:value};apply();},commit:value=>store.update({panels:{[panel]:value}})}));
58
+ function apply():void {
59
+ if(disposed||applying)return;applying=true;
60
+ try{
61
+ // Chrome shares the center width. It is not charged against the actual stage minimum.
62
+ const chrome=[...stack.children].filter(node=>node!==stage&&!((node as HTMLElement).hidden)).reduce((sum,node)=>sum+(node as HTMLElement).getBoundingClientRect().height,0);
63
+ available={width:Math.max(0,layout.clientWidth),height:Math.max(0,layout.clientHeight-chrome)};effective=allocateWorkspaceLayout(available,desired);
64
+ for(const panel of Object.keys(panels) as WorkspacePanel[])dock(panel,effective[panel==='leftWidth'?'leftMode':panel==='rightWidth'?'rightMode':'inspectorMode']==='drawer');
65
+ layout.style.setProperty('--workspace-columns',`${effective.leftWidth}px ${effective.leftMode==='panel'?8:0}px minmax(0,1fr) ${effective.rightMode==='panel'?8:0}px ${effective.rightWidth}px`);
66
+ main.style.gridTemplateRows=`minmax(0,1fr) ${effective.inspectorMode==='panel'?8:0}px ${effective.inspectorHeight}px`;
67
+ layout.dataset.stageMinimum=String(effective.stageMinimumSatisfied);for(const binding of bindings)binding.refresh();options.changed();restore();
68
+ }finally{applying=false;}
69
+ }
70
+ const renderSettings=()=>{const value=store.get();theme.value=value.theme;sync.checked=value.defaultCameraSync;idle.checked=value.defaultIdleRepeat;status.textContent=store.status().detail;};
71
+ theme.onchange=()=>{store.update({theme:theme.value as WorkspaceTheme});options.defaultTheme(store.get().theme);};sync.onchange=()=>{store.update({defaultCameraSync:sync.checked});};idle.onchange=()=>{store.update({defaultIdleRepeat:idle.checked});};require<HTMLButtonElement>('settings-reset').onclick=()=>store.resetLayout();
72
+ const unsubscribe=store.subscribe(value=>{desired={...value.panels};renderSettings();schedule();});renderSettings();
73
+ for(const tab of inspectorTabs){
74
+ const panel=panelFor(tab);let user=false,timer:ReturnType<typeof setTimeout>|undefined;
75
+ const intent=()=>{user=true;if(timer)clearTimeout(timer);timer=setTimeout(()=>{user=false;},600);};const scrolled=()=>{
76
+ if(!user)return;const saved=memory.snapshot()[tab]??{x:0,y:0};
77
+ const expected={x:Math.min(saved.x,Math.max(0,panel.scrollWidth-panel.clientWidth)),y:Math.min(saved.y,Math.max(0,panel.scrollHeight-panel.clientHeight))};
78
+ // Scroll steps precede animation frames. An actual new user offset may cancel
79
+ // a queued restore; the restore's own expected clamp must retain its desire.
80
+ if(panel.scrollLeft!==expected.x||panel.scrollTop!==expected.y)memory.capture(tab,panel,true);
81
+ };
82
+ for(const type of ['wheel','pointerdown','touchstart','keydown'])panel.addEventListener(type,intent,{passive:true});panel.addEventListener('scroll',scrolled,{passive:true});
83
+ let extent='';const content=new MutationObserver(()=>{const next=`${panel.scrollWidth}:${panel.scrollHeight}:${panel.clientWidth}:${panel.clientHeight}`;if(next!==extent){extent=next;if(tab===active())restore();}});content.observe(panel,{childList:true,subtree:true});
84
+ disposers.push(()=>{if(timer)clearTimeout(timer);for(const type of ['wheel','pointerdown','touchstart','keydown'])panel.removeEventListener(type,intent);panel.removeEventListener('scroll',scrolled);content.disconnect();});
85
+ }
86
+ const observer=new ResizeObserver(schedule);observer.observe(layout);for(const node of [...stack.children])if(node!==stage)observer.observe(node);for(const tab of inspectorTabs)observer.observe(panelFor(tab));
87
+ const resumed=()=>{if(!document.hidden)store.refresh();};document.addEventListener('visibilitychange',resumed);schedule();
88
+ return {defaults:()=>store.get(),beforeTab:capture,afterTab:restore,scroll:()=>{capture();return JSON.stringify(memory.snapshot());},restoreScroll(value){if(typeof value!=='string')return;memory.replace(JSON.parse(value));restore();},revealInspector(){if(effective.inspectorMode==='drawer'&&!dialogs.inspectorHeight.open){for(const dialog of Object.values(dialogs))if(dialog.open)dialog.close();buttons.inspectorHeight.click();}},refresh:schedule,
89
+ dispose(){if(disposed)return;capture();disposed=true;if(frame)cancelAnimationFrame(frame);observer.disconnect();document.removeEventListener('visibilitychange',resumed);unsubscribe();for(const binding of bindings)binding.dispose();for(const dispose of disposers)dispose();memory.dispose();store.dispose();}
90
+ };
91
+ }