@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,136 @@
1
+ /** Slot inspection over the public last-drawn frame. Never changes a track, skin or source. */
2
+ import type {Json} from '../src/manifest.ts';
3
+ import type {PosedFrame,RigPlayer,PoseBounds,SlotIdentitySnapshot} from '@rig-c/play';
4
+ import {parseControlPart,parsePartIdentity,type ControlPart,type ControlPartIdentity,type PartIdentityReason} from '../src/control.ts';
5
+ export interface PartDeclaration {skin:string;attachment:string;kind:string;path:string|null}
6
+ export interface PartEntry {slot:string;bone:string;setup:string|null;declarations:PartDeclaration[]}
7
+ export interface PartObservation {side:ControlPart['side'];slot:string;mode:ControlPart['mode'];identity:ControlPartIdentity|null;generation:number|null;reason:PartIdentityReason|null;drawn:boolean;kind:string|null;page:string|null;region:string|null;sequence:number|null;vertices:number|null;triangles:number|null;z:number|null;skin:string|null;set:string|null;activeAttachment:{skin:string;attachment:string}|null;identityUnavailable:string|null;bounds:PoseBounds|null;declarations:PartDeclaration[]}
8
+ const object=(v:unknown,name:string):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw new Error(`${name}: expected object`);return v as Record<string,unknown>;};
9
+ const name=(v:unknown,field:string):string=>{if(typeof v!=='string'||!v||v.length>512||/[\x00-\x1f]/.test(v))throw new Error(`${field}: expected nonempty name <= 512`);return v;};
10
+ export function partCatalog(value:unknown,expected:readonly string[]):PartEntry[]{
11
+ const model=object(value,'part model');if(!Array.isArray(model.slots)||!Array.isArray(model.skins)||model.slots.length>20000||model.skins.length>2000)throw new Error('Part catalog requires bounded model slots and skins');
12
+ const entries=model.slots.map((v,i)=>{const slot=object(v,`slot ${i}`);return {slot:name(slot.name,'slot.name'),bone:name(slot.bone,'slot.bone'),setup:slot.setup===undefined||slot.setup===null||slot.setup===''?null:name(slot.setup,'slot.setup'),declarations:[] as PartDeclaration[]};});
13
+ const bySlot=new Map(entries.map(e=>[e.slot,e]));if(bySlot.size!==entries.length||expected.length!==entries.length||new Set(expected).size!==expected.length||expected.some(s=>!bySlot.has(s)))throw new Error('PART_SOURCE_CHANGED: slot catalog differs from reviewed model');
14
+ let count=0;for(const [i,v]of model.skins.entries()){const skin=object(v,`skin ${i}`),skinName=name(skin.name,'skin.name');const slots=object(skin.attachments??{},'skin.attachments');for(const [slot,raw]of Object.entries(slots)){const entry=bySlot.get(slot);if(!entry)throw new Error(`Part catalog unknown slot ${slot}`);for(const [attachment,a]of Object.entries(object(raw,'slot attachments'))){if(++count>100000)throw new Error('Part declaration count exceeds 100000');const spec=object(a,'attachment');entry.declarations.push({skin:skinName,attachment:name(attachment,'attachment name'),kind:name(spec.kind,'attachment.kind'),path:typeof spec.path==='string'?name(spec.path,'attachment.path'):null});}}}
15
+ return entries;
16
+ }
17
+ export async function loadPartCatalog(url:string,sha256:string,slots:readonly string[],fetcher:typeof fetch=fetch):Promise<PartEntry[]>{
18
+ const response=await fetcher(url);if(!response.ok)throw new Error('Part catalog model unavailable');const bytes=await response.arrayBuffer();if(bytes.byteLength>128*1024*1024)throw new Error('Part catalog exceeds 128 MiB');
19
+ const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(v=>v.toString(16).padStart(2,'0')).join('');if(hash!==sha256)throw new Error('PART_SOURCE_CHANGED: model bytes differ from reviewed SHA-256');
20
+ return partCatalog(JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes)),slots);
21
+ }
22
+ /** Resolve clips on the whole frame first. Filtering player.slots would drop clipping slots. */
23
+ export function selectedFrame(full:PosedFrame,part:ControlPart|null,side:ControlPart['side']):PosedFrame{return part?.side===side&&part.mode==='solo'?{...(full.generation===undefined?{}:{generation:full.generation}),order:full.order.filter(row=>row.slot===part.slot)}:full;}
24
+ /** Retained frames own geometry: a later prepared seek cannot change an earlier drawn pose. */
25
+ export function copyPartFrame(frame:PosedFrame):PosedFrame{return {...(frame.generation===undefined?{}:{generation:frame.generation}),order:frame.order.map(row=>{
26
+ const common={tint:[...row.tint] as [number,number,number,number],...(row.dark?{dark:[...row.dark] as [number,number,number]}:{}),...(row.clip?{clip:{...row.clip,polygon:[...row.clip.polygon]}}:{}),uvs:[...row.uvs]};
27
+ return row.kind==='mesh'?{...row,...common,vertices:[...row.vertices],triangles:[...row.triangles]}:{...row,...common,corners:[...row.corners]};
28
+ })};}
29
+ export function partGeometry(frame:PosedFrame,slot:string){
30
+ const row=frame.order.find(r=>r.slot===slot);if(!row)return null;const coords=row.kind==='mesh'?row.vertices:row.corners;
31
+ if(coords.length%2||coords.some(v=>!Number.isFinite(v))||row.kind==='mesh'&&(row.triangles.length%3||row.triangles.some(i=>!Number.isSafeInteger(i)||i<0||i>=coords.length/2)))throw new Error(`Part ${slot}: malformed posed geometry`);
32
+ return {kind:row.kind,page:row.page,region:row.kind==='region'?row.region:null,sequence:row.kind==='mesh'?row.sequence:null,vertices:row.kind==='mesh'?coords.length/2:null,triangles:row.kind==='mesh'?row.triangles.length/3:null,z:row.z};
33
+ }
34
+ /** Conservative world AABB of the last drawn geometry. Normal clips restrict its AABB;
35
+ * inverse clips remain conservative. This is not an alpha/pixel coverage measurement. */
36
+ export function partBounds(frame:PosedFrame,slot:string):PoseBounds|null {
37
+ const row=frame.order.find(r=>r.slot===slot);if(!row)return null;partGeometry(frame,slot);
38
+ const box=(coordinates:readonly number[])=>{let left=Infinity,bottom=Infinity,right=-Infinity,top=-Infinity;for(let i=0;i<coordinates.length;i+=2){const x=coordinates[i]!,y=coordinates[i+1]!;if(!Number.isFinite(x)||!Number.isFinite(y))throw new Error(`Part ${slot}: malformed posed bounds`);left=Math.min(left,x);right=Math.max(right,x);bottom=Math.min(bottom,y);top=Math.max(top,y);}return {left,right,bottom,top};};
39
+ const bounds=box(row.kind==='mesh'?row.vertices:row.corners);
40
+ if(row.clip&&!row.clip.inverse){if(row.clip.polygon.length<6||row.clip.polygon.length%2)throw new Error(`Part ${slot}: malformed clip bounds`);const clip=box(row.clip.polygon);bounds.left=Math.max(bounds.left,clip.left);bounds.right=Math.min(bounds.right,clip.right);bounds.bottom=Math.max(bounds.bottom,clip.bottom);bounds.top=Math.min(bounds.top,clip.top);}
41
+ const width=bounds.right-bounds.left,height=bounds.top-bounds.bottom;return Number.isFinite(width)&&Number.isFinite(height)&&width>0&&height>0?{x:bounds.left,y:bounds.bottom,width,height}:null;
42
+ }
43
+ /** Identity comes only from the held public snapshot, never catalog/UV/geometry inference. */
44
+ export function partIdentity(snapshot:SlotIdentitySnapshot|null,slot:string):ControlPartIdentity|null {
45
+ const row=snapshot?.slots.find(row=>row.slot===slot);return row?{attachment:row.attachment,skin:row.skin,reason:row.reason,set:snapshot!.set,activeSkin:snapshot!.skin}:null;
46
+ }
47
+ export function copyPartIdentity(snapshot:SlotIdentitySnapshot,frame:PosedFrame,entries:readonly PartEntry[],before:number,after:number):SlotIdentitySnapshot {
48
+ if(!Number.isSafeInteger(before)||before<0||before!==after||snapshot.generation!==before||frame.generation!==before)throw new Error('PART_IDENTITY_STALE: held frame and public slot identity generations differ');
49
+ if(snapshot.slots.length!==entries.length)throw new Error('PART_IDENTITY_SOURCE_CHANGED: snapshot slot count differs from reviewed catalog');
50
+ const names=new Set(entries.map(entry=>entry.slot)),seen=new Set<string>();
51
+ const slots=snapshot.slots.map((row,index)=>{
52
+ if(!names.has(row.slot)||seen.has(row.slot)||row.z!==index)throw new Error(`PART_IDENTITY_SOURCE_CHANGED: slot "${row.slot}" has unknown, repeated or noncontiguous z ${row.z}`);seen.add(row.slot);
53
+ parsePartIdentity({attachment:row.attachment,skin:row.skin,reason:row.reason,set:snapshot.set,activeSkin:snapshot.skin});return {...row};
54
+ });
55
+ const bySlot=new Map(slots.map(row=>[row.slot,row]));let lastZ=-1;
56
+ for(const row of frame.order){const identity=bySlot.get(row.slot);if(!identity||identity.reason!==null||identity.z!==row.z||row.z<=lastZ)throw new Error(`PART_IDENTITY_FRAME_MISMATCH: slot "${row.slot}" does not match held frame order`);lastZ=row.z;}
57
+ // Identity-known slots need not have a visible primitive (zero alpha/empty
58
+ // geometry/nonvisual setup). A known key is not a pixel or fit guarantee.
59
+ return {generation:snapshot.generation,set:snapshot.set,skin:snapshot.skin,slots};
60
+ }
61
+ export function orderedParts(entries:readonly PartEntry[],frame:PosedFrame|null,identity:SlotIdentitySnapshot|null=null):PartEntry[]{
62
+ const z=new Map(identity?.slots.map(row=>[row.slot,row.z])??frame?.order.map(row=>[row.slot,row.z])??[]),setup=new Map(entries.map((entry,i)=>[entry.slot,i]));
63
+ return [...entries].sort((a,b)=>{const az=z.get(a.slot),bz=z.get(b.slot);return az!==undefined&&bz!==undefined?az-bz:az!==undefined?-1:bz!==undefined?1:setup.get(a.slot)!-setup.get(b.slot)!;});
64
+ }
65
+ export interface PartPlayer extends Pick<RigPlayer,'posedFrame'|'slotIdentity'|'generation'|'skin'|'set'> {readonly renderer:Pick<RigPlayer['renderer'],'render'>}
66
+ interface SideState {entries:PartEntry[];player:PartPlayer;frame:PosedFrame|null;identity:SlotIdentitySnapshot|null}
67
+ /** Why a side's parts cannot be inspected. A catalog refusal lasts until that side registers again; an identity refusal until a later valid frame. */
68
+ export interface PartRefusal {code:string;message:string;catalog:boolean}
69
+ export function partRefusal(error:unknown,catalog:boolean):PartRefusal{const message=error instanceof Error?error.message:String(error),code=/^([A-Z][A-Z0-9_]+):/.exec(message)?.[1]??(catalog?'PART_CATALOG_UNAVAILABLE':'PART_IDENTITY_UNAVAILABLE');return {code,message,catalog};}
70
+ export class PartInspection {
71
+ protected sides=new Map<ControlPart['side'],SideState>();protected selected:ControlPart|null=null;protected refused=new Map<ControlPart['side'],PartRefusal>();
72
+ register(side:ControlPart['side'],entries:PartEntry[],player:PartPlayer){this.refused.delete(side);this.sides.set(side,{entries,player,frame:null,identity:null});}
73
+ /** The catalog for this side failed to load or hash: the rig still draws, the side offers no parts. */
74
+ refuseCatalog(side:ControlPart['side'],error:unknown):void {this.sides.delete(side);this.refused.set(side,partRefusal(error,true));}
75
+ refusal(side:ControlPart['side']):PartRefusal|null {return this.refused.get(side)??null;}
76
+ /** A selection on a refused side is retained for recovery but never exposed, drawn Solo or targeted. */
77
+ protected live():ControlPart|null {const part=this.selected;return part&&!this.refused.has(part.side)?part:null;}
78
+ value():ControlPart|null {const part=this.live();if(!part)return null;const state=this.sides.get(part.side),identity=partIdentity(state?.identity??null,part.slot);return {...part,...(identity?{identity}:{} )};}
79
+ assertIdentity(expected:ControlPart|null|undefined):void {if(!expected)return;this.validate(expected);const actual=partIdentity(this.sides.get(expected.side)?.identity??null,expected.slot);if(!actual)throw new Error(`PART_IDENTITY_UNAVAILABLE: ${expected.side} / ${expected.slot} has no held public snapshot`);if(expected.identity&&JSON.stringify(actual)!==JSON.stringify(parsePartIdentity(expected.identity)))throw new Error(`PART_IDENTITY_MISMATCH: ${expected.side} / ${expected.slot} no longer resolves the bookmarked skin/attachment/set/reason`);}
80
+ validate(value:ControlPart|null|undefined):void {if(!value)return;parseControlPart(value);const state=this.sides.get(value.side);if(!state||!state.entries.some(e=>e.slot===value.slot))throw new Error(`PART_UNAVAILABLE: ${value.side} / ${value.slot}`);}
81
+ select(value:ControlPart|null):void {this.validate(value);const refusal=value?this.refused.get(value.side):undefined;if(refusal)throw new Error(`PARTS_UNAVAILABLE: ${value!.side} / ${refusal.message}`);const next=value?{side:value.side,slot:value.slot,mode:value.mode}:null,previous=this.selected;try{for(const [side,state]of this.sides)if(state.frame)state.player.renderer.render(selectedFrame(state.frame,next,side));}catch(error){for(const [side,state]of this.sides)if(state.frame)state.player.renderer.render(selectedFrame(state.frame,previous,side));throw error;}this.selected=next;}
82
+ /** Called inside the successful draw boundary, before compare submission is measured complete. */
83
+ present(side:ControlPart['side']):void {const state=this.sides.get(side);if(!state)return;this.commit(side,state,this.capture(state));}
84
+ protected capture(state:SideState):{full:PosedFrame;identity:SlotIdentitySnapshot} {const before=state.player.generation,full=copyPartFrame(state.player.posedFrame());return {full,identity:copyPartIdentity(state.player.slotIdentity(),full,state.entries,before,state.player.generation)};}
85
+ /** Solo is drawn only from a frame whose identity was just captured; a renderer failure still propagates to the draw boundary. */
86
+ protected commit(side:ControlPart['side'],state:SideState,{full,identity}:{full:PosedFrame;identity:SlotIdentitySnapshot}):void {if(this.selected?.side===side&&this.selected.mode==='solo')state.player.renderer.render(selectedFrame(full,this.selected,side));state.frame=full;state.identity=identity;this.refused.delete(side);}
87
+ /** Drop the held frame and identity: nothing stale is shown as current, and the player's own whole draw stays on screen. */
88
+ protected refuseIdentity(side:ControlPart['side'],state:SideState,error:unknown):void {state.frame=null;state.identity=null;this.refused.set(side,partRefusal(error,false));}
89
+ bounds():PoseBounds|null {const part=this.live(),state=part?this.sides.get(part.side):null;return part&&state?.frame?partBounds(state.frame,part.slot):null;}
90
+ frame(side:ControlPart['side']):PosedFrame|undefined {const state=this.sides.get(side);return state?.frame?selectedFrame(state.frame,this.live(),side):undefined;}
91
+ observation():PartObservation|null {const part=this.live();if(!part)return null;const state=this.sides.get(part.side),entry=state?.entries.find(e=>e.slot===part.slot);if(!state||!entry)return null;const geometry=state.frame?partGeometry(state.frame,part.slot):null,identity=partIdentity(state.identity,part.slot),row=state.identity?.slots.find(row=>row.slot===part.slot);return {...part,identity,generation:state.identity?.generation??null,reason:identity?.reason??null,drawn:!!geometry,kind:geometry?.kind??null,page:geometry?.page??null,region:geometry?.region??null,sequence:geometry?.sequence??null,vertices:geometry?.vertices??null,triangles:geometry?.triangles??null,z:row?.z??geometry?.z??null,skin:state.identity?.skin??null,set:state.identity?.set??null,activeAttachment:identity?.reason===null&&identity.attachment!==null&&identity.skin!==null?{skin:identity.skin,attachment:identity.attachment}:null,identityUnavailable:identity?null:'PART_IDENTITY_UNAVAILABLE: no held public identity snapshot',bounds:this.bounds(),declarations:entry.declarations.map(d=>({...d}))};}
92
+
93
+ feedback():Record<string,Json>|null {const value=this.observation();return value?{...value,identity:value.identity?{...value.identity}:null,activeAttachment:value.activeAttachment?{...value.activeAttachment}:null,bounds:value.bounds?{...value.bounds}:null,declarations:value.declarations.map(item=>({...item}))}:null;}
94
+ }
95
+ export class PartExplorer extends PartInspection {
96
+ private rows=new Map<string,HTMLButtonElement>();private rowEntries=new Map<HTMLButtonElement,PartEntry>();private detailKey='';private orderKey='';private selectionKey='';
97
+ private detailNodes:{title:HTMLHeadingElement;info:HTMLParagraphElement;declarations:HTMLParagraphElement;bounds:HTMLParagraphElement;unavailable:HTMLParagraphElement;feedback:HTMLButtonElement;status:HTMLParagraphElement}|null=null;
98
+ constructor(private host:HTMLElement,private detail:HTMLElement,private changed:(previous:ControlPart|null)=>void,private target:(part:ControlPart)=>void){
99
+ super();
100
+ this.search.addEventListener('input',()=>this.renderList());this.search.addEventListener('keydown',event=>{if(event.key==='ArrowDown'){event.preventDefault();this.list.querySelector<HTMLButtonElement>('button')?.focus();}if(event.key==='Escape'){this.search.value='';this.renderList();}});
101
+ this.list.addEventListener('keydown',event=>{const buttons=[...this.list.querySelectorAll<HTMLButtonElement>('button')],index=buttons.indexOf(document.activeElement as HTMLButtonElement);const next=event.key==='ArrowDown'?Math.min(index+1,buttons.length-1):event.key==='ArrowUp'?Math.max(0,index-1):event.key==='Home'?0:event.key==='End'?buttons.length-1:null;if(next!==null){event.preventDefault();buttons[next]?.focus();}if(event.key==='Escape')this.search.focus();});
102
+ this.whole.onclick=()=>this.action(()=>this.select(null));this.solo.onchange=()=>this.action(()=>{if(this.selected)this.select({...this.selected,mode:this.solo.checked?'solo':'context'});});this.renderList();this.renderDetail();
103
+ }
104
+ private get search(){return this.host.querySelector<HTMLInputElement>('[data-part-search]')!;}private get list(){return this.host.querySelector<HTMLElement>('[data-part-list]')!;}private get solo(){return this.host.querySelector<HTMLInputElement>('[data-part-solo]')!;}private get whole(){return this.host.querySelector<HTMLButtonElement>('[data-part-whole]')!;}
105
+ register(side:ControlPart['side'],entries:PartEntry[],player:PartPlayer){super.register(side,entries,player);this.renderList();}
106
+ refuseCatalog(side:ControlPart['side'],error:unknown):void {super.refuseCatalog(side,error);this.renderList();this.renderDetail();}
107
+ select(value:ControlPart|null):void {const previous=this.value();super.select(value);this.renderSelection();this.renderDetail();this.changed(previous);}
108
+ /** Runs on every drawn frame. The held copy and Solo draw are per frame; the list is rewritten only when what it shows changed. */
109
+ present(side:ControlPart['side']):void {const state=this.sides.get(side);if(!state)return;let held;try{held=this.capture(state);}catch(error){this.refuseIdentity(side,state,error);}if(held)this.commit(side,state,held);if(this.viewKey()!==this.selectionKey){this.reorder();this.renderSelection();}this.renderDetail();}
110
+ /** Everything the rows and Solo control display: selection, per-side drawn slots in order, and identity. Generation is excluded. */
111
+ private viewKey():string {let key=JSON.stringify([this.selected,[...this.refused]]);for(const [side,state]of this.sides){key+='\u0001'+side+'\u0002'+(state.frame?state.frame.order.map(row=>row.slot).join('\u0000'):'\u0003');for(const row of state.identity?.slots??[])key+='\u0002'+row.slot+'\u0000'+row.z+'\u0000'+row.skin+'\u0000'+row.attachment+'\u0000'+row.reason;}return key;}
112
+ private action(run:()=>void){try{run();}catch(error){this.renderSelection(true);this.host.querySelector<HTMLElement>('[data-part-status]')!.textContent=`Part selection refused: ${error instanceof Error?error.message:String(error)}`;}}
113
+ private renderList(){this.rows.clear();this.rowEntries.clear();this.list.replaceChildren();const query=this.search.value;for(const [side,state]of this.sides)for(const entry of orderedParts(state.entries,state.frame,state.identity)){if(!entry.slot.includes(query))continue;const row=document.createElement('button');row.type='button';row.dataset.side=side;row.dataset.slot=entry.slot;row.title=`${entry.slot} · bone ${entry.bone}`;row.textContent=`${entry.declarations.some(d=>d.kind==='mesh')?'◈':'◇'} ${side==='single'?'':side.toUpperCase()+' · '}${entry.slot}`;row.onclick=()=>this.action(()=>this.select({side,slot:entry.slot,mode:this.selected?.mode??'context'}));const item=document.createElement('li');item.append(row);this.list.append(item);this.rows.set(JSON.stringify([side,entry.slot]),row);this.rowEntries.set(row,entry);}this.orderKey='';this.reorder();this.renderSelection(true);}
114
+ private statusText():string {const refused=[...this.refused].map(([side,r])=>`${side==='single'?'':side.toUpperCase()+' '}${r.code}`);if(refused.length)return `Parts unavailable · ${refused.join(' · ')}. The rig keeps drawing whole; selection, Solo and feedback targeting are off ${[...this.refused.values()].some(r=>r.catalog)?'until the package reloads with a matching catalog':'until a frame with a valid identity snapshot'}.`;return this.sides.size?`${[...this.sides.values()].reduce((n,s)=>n+s.entries.length,0)} declared slots · ${this.rows.size} results`:'No compiled rig catalog available';}
115
+ private reorder(){const keys=[...this.sides].flatMap(([side,state])=>orderedParts(state.entries,state.frame,state.identity).map(entry=>JSON.stringify([side,entry.slot]))).filter(key=>this.rows.has(key)),key=JSON.stringify(keys);if(key===this.orderKey)return;this.orderKey=key;const active=document.activeElement;for(const item of keys){const row=this.rows.get(item)!;this.list.append(row.parentElement!);}if(active instanceof HTMLElement&&this.list.contains(active)&&document.activeElement!==active)active.focus({preventScroll:true});}
116
+ private renderSelection(force=false){const key=this.viewKey();if(!force&&key===this.selectionKey)return;this.selectionKey=key;const status=this.host.querySelector<HTMLElement>('[data-part-status]')!;status.textContent=this.statusText();if(this.refused.size)this.host.dataset.parts='unavailable';else delete this.host.dataset.parts;const drawnBySide=new Map([...this.sides].map(([side,state])=>[side,new Set(state.frame?.order.map(row=>row.slot)??[])]));this.whole.setAttribute('aria-pressed',String(!this.selected));this.solo.disabled=!this.live();this.solo.checked=this.live()?.mode==='solo';for(const [key,row]of this.rows){row.setAttribute('aria-pressed',String(key===JSON.stringify([this.live()?.side,this.live()?.slot])));const refusal=this.refused.get(row.dataset.side as ControlPart['side']);row.disabled=!!refusal;if(refusal){const label=`${row.dataset.side==='single'?'':row.dataset.side!.toUpperCase()+' · '}${row.dataset.slot} · parts unavailable · ${refusal.code}`;if(row.textContent!==label)row.textContent=label;row.title=label;row.setAttribute('aria-label',label);row.dataset.drawn='false';continue;}const state=this.sides.get(row.dataset.side as ControlPart['side']);const drawn=drawnBySide.get(row.dataset.side as ControlPart['side'])?.has(row.dataset.slot!);row.dataset.drawn=String(!!drawn);const entry=this.rowEntries.get(row),identity=entry?partIdentity(state?.identity??null,row.dataset.slot!):null;row.title=`${row.dataset.slot} · ${state?.frame?drawn?'drawn':'not drawn':'awaiting draw'}${identity?identity.reason?' · '+identity.reason:' · '+identity.skin+' / '+identity.attachment:' · awaiting identity snapshot'}`;const label=`${entry?.declarations.some(d=>d.kind==='mesh')?'◈':'◇'} ${row.dataset.side==='single'?'':row.dataset.side!.toUpperCase()+' · '}${row.dataset.slot} · ${state?.frame?drawn?'drawn':'not drawn':'awaiting draw'} · ${identity?identity.reason?'no active attachment · '+identity.reason:identity.skin+' / '+identity.attachment:'awaiting identity snapshot'}`;if(row.textContent!==label)row.textContent=label;row.setAttribute('aria-label',`${row.textContent} · ${state?.frame?drawn?'drawn':'not drawn':'awaiting draw'}`);}}
117
+ private renderDetail(){
118
+ const observation=this.observation(),refusal=this.selected?this.refused.get(this.selected.side):[...this.refused.values()][0],key=JSON.stringify([observation,refusal??null]);if(key===this.detailKey)return;this.detailKey=key;
119
+ // Keep focused controls connected while the selected pose's bounds animate.
120
+ const nodes=this.detailNodes??=(()=>{
121
+ const title=document.createElement('h3'),info=document.createElement('p'),declarations=document.createElement('p'),bounds=document.createElement('p'),unavailable=document.createElement('p'),feedback=document.createElement('button'),status=document.createElement('p');
122
+ for(const node of [info,declarations,bounds,unavailable,status])node.className='hint';
123
+ feedback.type='button';feedback.textContent='Use slot for feedback';feedback.onclick=()=>{const part=this.value();if(part)this.target(part);};
124
+ unavailable.textContent='Named vertex weights and original source alpha unavailable in this inspection. A packed page is not original part art.';
125
+ status.textContent='Mesh optimization requires an eligible producer handoff. This inspection changes no source art, geometry or motion.';
126
+ this.detail.replaceChildren(title,info,declarations,bounds,unavailable,feedback,status);
127
+ return {title,info,declarations,bounds,unavailable,feedback,status};
128
+ })();
129
+ nodes.title.textContent=observation?`${observation.side==='single'?'':observation.side.toUpperCase()+' · '}${observation.slot}`:'Whole rig';
130
+ nodes.info.textContent=observation?`${observation.mode==='solo'?'Solo':'In context'} · ${observation.drawn?observation.kind==='mesh'?`${observation.vertices} posed vertices · ${observation.triangles} triangles`:'Region · four posed corners':'Not drawn at current pose'} · Skin: ${observation.skin??'not set'} · Set: ${observation.set??'not set'}`:refusal?`Parts unavailable · ${refusal.message}. The whole rig stays drawn; no slot is selected, soloed or targeted from stale data.`:'Select an actual slot in the right explorer. Selection preserves animation, time and physics history.';
131
+ for(const node of [nodes.declarations,nodes.bounds,nodes.unavailable,nodes.feedback])node.hidden=!observation;nodes.feedback.disabled=!observation;
132
+ nodes.declarations.textContent=observation?`${observation.activeAttachment?'Resolved public attachment: '+observation.activeAttachment.skin+' / '+observation.activeAttachment.attachment+'.':(observation.identityUnavailable??'No active drawable attachment: '+observation.reason)+'.'} Declared: ${observation.declarations.map(d=>`${d.skin} / ${d.attachment} (${d.kind})`).join('; ')||'none'}. ${observation.page?'Drawn page: '+observation.page:''}${observation.region?' · region '+observation.region:''}${observation.sequence!==null?' · sequence '+observation.sequence:''}`:'';
133
+ nodes.bounds.textContent=observation?observation.bounds?`Selection bounds · world units / Y up: x ${observation.bounds.x}, y ${observation.bounds.y}, width ${observation.bounds.width}, height ${observation.bounds.height}. Conservative geometry/clip bounds; fit uses the existing 0.25–8× camera range.`:'PART_FIT_UNAVAILABLE: no positive drawn bounds at the held pose.':'';
134
+ }
135
+
136
+ }
@@ -0,0 +1,31 @@
1
+ /** Public last-render telemetry only; neither draws, poses nor probes GL. */
2
+ import type {DomRenderer,CanvasRenderer} from '@rig-c/play';
3
+ export type RendererTelemetry=Pick<DomRenderer,'meshBackend'|'meshBackendActive'|'meshCount'|'meshReuseCount'|'triangleCount'>|Pick<CanvasRenderer,'backend'|'backendActive'|'framesReused'|'trianglesDrawn'>;
4
+ export interface RasterStatus {kind:'pending'|'failed'|'fresh'|'cached'|'no-mesh';mode:'dom'|'canvas'|null;backend:'webgl'|'canvas2d'|null;fresh:number;reused:number;triangles:number;label:string}
5
+ const empty=(kind:'pending'|'failed'):RasterStatus=>({kind,mode:null,backend:null,fresh:0,reused:0,triangles:0,label:kind==='failed'?'Rendering failed':'Not rendered'});
6
+ /** Call only after successful draw. DOM actual-flush reporting requires play>=1.11.1. */
7
+ export function rendererStatus(renderer:RendererTelemetry|null,draw:'pending'|'succeeded'|'failed'):RasterStatus {
8
+ if(draw!=='succeeded'||!renderer)return empty(draw==='failed'?'failed':'pending');
9
+ if('meshBackendActive' in renderer){
10
+ const base={mode:'dom' as const,fresh:renderer.meshCount,reused:renderer.meshReuseCount,triangles:renderer.triangleCount};
11
+ if(renderer.meshCount===0)return {...base,kind:renderer.meshReuseCount?'cached':'no-mesh',backend:null,label:renderer.meshReuseCount?'Cached mesh rasters reused':'No mesh · DOM images'};
12
+ const backend=renderer.meshBackendActive,label=backend==='webgl'?'WebGL':renderer.meshBackend==='webgl'?'Canvas2D fallback':'Canvas2D';
13
+ return {...base,kind:'fresh',backend,label:`Fresh meshes · ${label}${renderer.meshReuseCount?' · Cached rasters also reused':''}`};
14
+ }
15
+ const base={mode:'canvas' as const,fresh:renderer.framesReused?0:1,reused:renderer.framesReused,triangles:renderer.trianglesDrawn};
16
+ if(renderer.framesReused)return {...base,kind:'cached',backend:null,label:'Cached canvas frame reused'};
17
+ if(!renderer.trianglesDrawn)return {...base,kind:'no-mesh',backend:null,label:'Canvas frame · No drawable triangles'};
18
+ return {...base,kind:'fresh',backend:renderer.backendActive,label:`Canvas frame · ${renderer.backendActive==='webgl'?'WebGL':renderer.backend==='webgl'?'Canvas2D fallback':'Canvas2D'}`};
19
+ }
20
+ export interface RendererBadgeState {phase:'pending'|'failed'|'drawn'|'held';raster:RasterStatus;label:string}
21
+ /** A skipped preparation cannot resample stale counters as a new visible draw. */
22
+ export class RendererBadge {
23
+ private last:RasterStatus|null=null;
24
+ private value:RendererBadgeState={phase:'pending',raster:empty('pending'),label:'Not rendered'};
25
+ observe(phase:'unknown'|'drawn'|'held'|'failed',renderer:RendererTelemetry|null):RendererBadgeState {return phase==='drawn'?this.succeeded(renderer):phase==='held'?this.held():phase==='failed'?this.failed():this.pending();}
26
+ snapshot():RendererBadgeState{return {...this.value,raster:{...this.value.raster}};}
27
+ pending():RendererBadgeState {this.value={phase:'pending',raster:empty('pending'),label:this.last?'Previous draw · synchronizing':'Not rendered'};return this.snapshot();}
28
+ failed():RendererBadgeState {this.last=null;this.value={phase:'failed',raster:empty('failed'),label:'Rendering failed · pair unavailable'};return this.snapshot();}
29
+ succeeded(renderer:RendererTelemetry|null):RendererBadgeState {const raster=rendererStatus(renderer,'succeeded');this.last=raster;this.value={phase:'drawn',raster,label:raster.label};return this.snapshot();}
30
+ held():RendererBadgeState {const raster=this.last??empty('pending');this.value={phase:'held',raster,label:this.last?`Last drawn · ${raster.label} · held`:'Not rendered · held'};return this.snapshot();}
31
+ }
@@ -0,0 +1,51 @@
1
+ import { reportFields, type ReportEvent, type ReportSeeds, type ReportTarget } from '../src/report.ts';
2
+
3
+ /** Presentation values only. This never derives seconds from frame count or duration. */
4
+ export function eventSelection(event: ReportEvent, targetIndex?: number) {
5
+ const target = targetIndex === undefined
6
+ ? event.targets.length === 1 ? event.targets[0]! : null
7
+ : Number.isInteger(targetIndex) ? event.targets[targetIndex] ?? null : null;
8
+ return {
9
+ frames: event.frames ? [...event.frames] as [number, number] : null,
10
+ target: target ? { ...target } : null,
11
+ targets: event.targets.map(value => ({ ...value })),
12
+ exactTimes: event.times ? [...event.times] : [],
13
+ seek: event.seek ? { ...event.seek } : null,
14
+ };
15
+ }
16
+
17
+ /** A chosen sample is one exact supplied timestamp, never the span between samples. */
18
+ export function eventSeekTime(event: ReportEvent, timeIndex?: number): number | null {
19
+ if (timeIndex === undefined) return event.seek?.time ?? null;
20
+ return Number.isInteger(timeIndex) && timeIndex >= 0 ? event.times?.[timeIndex] ?? null : null;
21
+ }
22
+
23
+ export function eventTimingLabel(event: ReportEvent): string {
24
+ if (!event.seek) return event.frames ? 'No time mapping — cannot seek' : 'No time data — cannot seek';
25
+ const source = { event: 'Explicit time', frameTimes: 'frameTimes mapping', fps: 'Explicit fps + frame0 mapping' }[event.seek.source];
26
+ return `${source} · ${event.seek.time} s`;
27
+ }
28
+
29
+ export function eventCoordinates(event: ReportEvent): string {
30
+ const labels: string[] = [];
31
+ if (event.frames) labels.push(`F${event.frames[0]}–${event.frames[1]}`);
32
+ if (event.field === 'uncovered' && event.times) labels.push(`${event.times.length} discrete times`);
33
+ else if (event.timeInterval) labels.push(`${event.timeInterval.start}–${event.timeInterval.end} s (${event.timeInterval.endKind === 'boundary' ? 'end boundary' : 'frame time'})`);
34
+ else if (event.seek) labels.push(`${event.seek.time} s`);
35
+ return labels.join(' · ');
36
+ }
37
+
38
+ export function reportCounts(report: ReportSeeds) {
39
+ return {
40
+ events: report.categories.reduce((sum, category) => sum + category.events.length, 0),
41
+ empty: report.categories.filter(category => category.state === 'empty').map(category => category.field),
42
+ missing: reportFields.filter(field => !report.categories.some(category => category.field === field)),
43
+ };
44
+ }
45
+
46
+ export function targetLabel(target: ReportTarget): string { return `${target.kind}:${target.name}`; }
47
+
48
+ /** A new finding needs its own human judgment; sub-selections keep that draft. */
49
+ export function reportDraftForMarker(previousId: string | null, nextId: string, draft: { category: string; summary: string }) {
50
+ return previousId === nextId ? { ...draft } : { category: '', summary: '' };
51
+ }
@@ -0,0 +1,150 @@
1
+ /** Inspection graphics from the last successfully drawn public player pose. */
2
+ import {partBounds} from './part-selection.ts';
3
+ import type {BoneWorld,PosedFrame,RigPlayer} from '@rig-c/play';
4
+
5
+ export interface OverlayPoint {x:number;y:number}
6
+ export type OverlayProject=(x:number,y:number)=>OverlayPoint;
7
+ export interface BoneOverlaySpec {name:string;parent?:string;length?:number}
8
+ export interface RigOverlaySettings {images:boolean;mesh:boolean;bones:boolean;meshOpacity:number}
9
+ export interface OverlayPlayer extends Pick<RigPlayer,'posedFrame'|'bone'> {}
10
+ export interface OverlayLine {from:OverlayPoint;to:OverlayPoint;slot?:string}
11
+ export interface OverlayBone {name:string;point:OverlayPoint;xAxis:OverlayPoint;yAxis:OverlayPoint}
12
+ export interface RigOverlayGeometry {selection:OverlayLine[];mesh:OverlayLine[];bones:OverlayLine[];joints:OverlayBone[];warnings:string[];captured:{mesh:boolean;bones:boolean}}
13
+ export interface OverlaySnapshot {
14
+ available:boolean;needsCapture:boolean;meshEdges:number;boneCount:number;
15
+ capabilities:{images:boolean;mesh:boolean;bones:boolean};warnings:string[];
16
+ }
17
+ export interface OverlayView {project:OverlayProject;settings:RigOverlaySettings;dpr?:number}
18
+ export interface OverlayRefresh extends OverlayView {player:OverlayPlayer|null;bones:readonly BoneOverlaySpec[];frame?:PosedFrame;selectedSlot?:string|null}
19
+ const point=(x:number,y:number):OverlayPoint=>({x,y});
20
+ const finite=(p:OverlayPoint)=>Number.isFinite(p.x)&&Number.isFinite(p.y);
21
+ const message=(error:unknown)=>error instanceof Error?error.message:String(error);
22
+
23
+ function meshLines(frame:PosedFrame,warnings:string[]):OverlayLine[]{
24
+ const lines:OverlayLine[]=[];
25
+ for(const slot of frame.order){
26
+ if(slot.kind!=='mesh')continue;
27
+ const edges=new Set<string>();
28
+ if(slot.vertices.length%2||slot.triangles.length%3){warnings.push(`Mesh overlay ${slot.slot}: incomplete coordinates or triangle`);continue;}
29
+ for(let i=0;i<slot.triangles.length;i+=3){
30
+ const ids=slot.triangles.slice(i,i+3);
31
+ if(ids.some(index=>!Number.isSafeInteger(index)||index<0||index*2+1>=slot.vertices.length)){warnings.push(`Mesh overlay ${slot.slot}: triangle index outside posed vertices`);continue;}
32
+ const points=ids.map(index=>point(slot.vertices[index*2]!,slot.vertices[index*2+1]!));
33
+ if(points.some(p=>!finite(p))){warnings.push(`Mesh overlay ${slot.slot}: nonfinite posed vertex`);continue;}
34
+ for(let edge=0;edge<3;edge++){
35
+ const a=ids[edge]!,b=ids[(edge+1)%3]!,key=a<b?`${a}:${b}`:`${b}:${a}`;
36
+ if(edges.has(key))continue;edges.add(key);
37
+ lines.push({from:{...points[edge]!},to:{...points[(edge+1)%3]!},slot:slot.slot});
38
+ }
39
+ }
40
+ }
41
+ return lines;
42
+ }
43
+
44
+ /** Takes copies; later seeks cannot turn an old overlay into a prepared pose. */
45
+ export function captureRigGeometry(player:OverlayPlayer,specs:readonly BoneOverlaySpec[],layers:Pick<RigOverlaySettings,'mesh'|'bones'>,frame?:PosedFrame,selectedSlot?:string|null):RigOverlayGeometry{
46
+ const geometry:RigOverlayGeometry={selection:[],mesh:[],bones:[],joints:[],warnings:[],captured:{mesh:false,bones:false}};
47
+ if(selectedSlot){try{const bounds=partBounds(frame??player.posedFrame(null),selectedSlot);if(bounds){const {x,y,width,height}=bounds,points=[point(x,y),point(x+width,y),point(x+width,y+height),point(x,y+height)];geometry.selection=points.map((from,i)=>({from,to:points[(i+1)%4]!,slot:selectedSlot}));}}catch(error){geometry.warnings.push(`Selection bounds unavailable: ${message(error)}`);}}
48
+ if(layers.mesh){
49
+ try{geometry.mesh=meshLines(frame??player.posedFrame(),geometry.warnings);geometry.captured.mesh=true;}
50
+ catch(error){geometry.warnings.push(`Mesh overlay unavailable: ${message(error)}`);}
51
+ }
52
+ if(layers.bones){
53
+ const found=new Map<string,BoneWorld>();
54
+ const read=(name:string):BoneWorld|null=>{
55
+ if(found.has(name))return found.get(name)!;
56
+ try{const value=player.bone(name);if(![value.x,value.y,value.a,value.b,value.c,value.d].every(Number.isFinite))throw new Error('nonfinite world transform');const copied={...value};found.set(name,copied);return copied;}
57
+ catch(error){geometry.warnings.push(`Bone overlay ${name}: ${message(error)}`);return null;}
58
+ };
59
+ const names=new Set<string>();
60
+ for(const spec of specs){
61
+ if(!spec.name||names.has(spec.name)){geometry.warnings.push('Bone overlay: missing or duplicate declared name');continue;}names.add(spec.name);
62
+ const bone=read(spec.name);if(!bone?.active)continue;
63
+ const from=point(bone.x,bone.y);
64
+ geometry.joints.push({name:spec.name,point:from,xAxis:point(bone.a,bone.c),yAxis:point(bone.b,bone.d)});
65
+ if(spec.length!==undefined&&(!Number.isFinite(spec.length)||spec.length<0)){geometry.warnings.push(`Bone overlay ${spec.name}: invalid declared length`);continue;}
66
+ if(spec.length!==undefined&&spec.length>0){geometry.bones.push({from:{...from},to:point(bone.x+bone.a*spec.length,bone.y+bone.c*spec.length)});}
67
+ else if(spec.parent){const parent=read(spec.parent);if(parent?.active)geometry.bones.push({from:point(parent.x,parent.y),to:{...from}});}
68
+ }
69
+ geometry.captured.bones=true;
70
+ }
71
+ return geometry;
72
+ }
73
+
74
+ /**
75
+ * The canvas must be a sibling above rigRoot. refresh follows an actual successful
76
+ * draw; redraw is for camera/layer changes and never samples a prepared player.
77
+ */
78
+ export class RigOverlays {
79
+ private geometry:RigOverlayGeometry|null=null;
80
+ private playerAvailable=false;
81
+ private disposed=false;
82
+ private readonly visibility:string;
83
+ private readonly pointerEvents:string;
84
+ constructor(private readonly host:{canvas:HTMLCanvasElement;rigRoot:HTMLElement}){
85
+ this.visibility=host.rigRoot.style.visibility;this.pointerEvents=host.canvas.style.pointerEvents;
86
+ host.canvas.style.pointerEvents='none';
87
+ }
88
+ refresh(input:OverlayRefresh):OverlaySnapshot{
89
+ if(this.disposed)return this.unavailable('Overlay has been disposed');
90
+ this.playerAvailable=!!input.player;
91
+ this.geometry=input.player?captureRigGeometry(input.player,input.bones,input.settings,input.frame,input.selectedSlot):null;
92
+ return this.redraw(input);
93
+ }
94
+ redraw({project,settings,dpr=globalThis.devicePixelRatio??1}:OverlayView):OverlaySnapshot{
95
+ if(this.disposed)return this.unavailable('Overlay has been disposed');
96
+ this.host.rigRoot.style.visibility=settings.images?this.visibility:'hidden';
97
+ const warnings=[...(this.geometry?.warnings??[])],ctx=this.host.canvas.getContext('2d');
98
+ const captured=this.geometry?.captured;
99
+ const needsCapture=this.playerAvailable&&((settings.mesh&&!captured?.mesh)||(settings.bones&&!captured?.bones));
100
+ if(needsCapture)warnings.push('Overlay pending: a successful rig draw is required for the selected layers');
101
+ if(!this.playerAvailable&&(settings.mesh||settings.bones))warnings.push('Overlay unavailable: no successfully drawn rig pose');
102
+ if(!ctx){warnings.push('Overlay unavailable: Canvas2D context missing');return this.snapshot(false,needsCapture,warnings);}
103
+ const width=this.host.canvas.clientWidth,height=this.host.canvas.clientHeight;
104
+ if(!Number.isFinite(dpr)||dpr<=0){warnings.push('Overlay unavailable: positive finite pixel ratio required');ctx.clearRect(0,0,this.host.canvas.width,this.host.canvas.height);return this.snapshot(false,needsCapture,warnings);}
105
+ if(width<=0||height<=0){ctx.clearRect(0,0,this.host.canvas.width,this.host.canvas.height);return this.snapshot(true,needsCapture,warnings);}
106
+ const pw=Math.max(1,Math.round(width*dpr)),ph=Math.max(1,Math.round(height*dpr));
107
+ if(this.host.canvas.width!==pw)this.host.canvas.width=pw;
108
+ if(this.host.canvas.height!==ph)this.host.canvas.height=ph;
109
+ ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
110
+ const projected=(p:OverlayPoint):OverlayPoint|null=>{
111
+ const value=project(p.x,p.y);if(finite(value))return value;
112
+ if(!warnings.includes('Overlay projection returned nonfinite coordinates'))warnings.push('Overlay projection returned nonfinite coordinates');return null;
113
+ };
114
+ const line=(value:OverlayLine)=>{const from=projected(value.from),to=projected(value.to);if(from&&to){ctx.moveTo(from.x,from.y);ctx.lineTo(to.x,to.y);}};
115
+ ctx.save();
116
+ if(settings.mesh&&captured?.mesh){
117
+ ctx.strokeStyle='#007d98';ctx.lineWidth=2.3;ctx.globalAlpha=Math.max(0,Math.min(1,Number.isFinite(settings.meshOpacity)?settings.meshOpacity:0));
118
+ ctx.beginPath();for(const edge of this.geometry!.mesh)line(edge);ctx.stroke();
119
+ ctx.strokeStyle='#69f1ff';ctx.lineWidth=.9;ctx.beginPath();for(const edge of this.geometry!.mesh)line(edge);ctx.stroke();
120
+ }
121
+ if(settings.bones&&captured?.bones){
122
+ ctx.globalAlpha=1;ctx.strokeStyle='#5c3b00';ctx.lineWidth=3;ctx.beginPath();for(const edge of this.geometry!.bones)line(edge);ctx.stroke();
123
+ ctx.strokeStyle='#ffe26b';ctx.lineWidth=1.3;ctx.beginPath();for(const edge of this.geometry!.bones)line(edge);ctx.stroke();
124
+ for(const bone of this.geometry!.joints){
125
+ const center=projected(bone.point);if(!center)continue;
126
+ // Axis ticks are display glyphs, not invented bone lengths.
127
+ for(const [axis,color] of [[bone.xAxis,'#ff876f'],[bone.yAxis,'#80e9a2']] as const){
128
+ const end=projected(point(bone.point.x+axis.x,bone.point.y+axis.y));if(!end)continue;
129
+ const dx=end.x-center.x,dy=end.y-center.y,length=Math.hypot(dx,dy);if(length===0)continue;
130
+ ctx.strokeStyle=color;ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(center.x,center.y);ctx.lineTo(center.x+dx/length*9,center.y+dy/length*9);ctx.stroke();
131
+ }
132
+ ctx.fillStyle='#ffe26b';ctx.strokeStyle='#44310d';ctx.lineWidth=1;ctx.beginPath();ctx.arc(center.x,center.y,2.8,0,Math.PI*2);ctx.fill();ctx.stroke();
133
+ }
134
+ }
135
+ if(this.geometry?.selection.length){ctx.globalAlpha=1;ctx.strokeStyle='#ffbb47';ctx.lineWidth=2;ctx.setLineDash([5,3]);ctx.beginPath();for(const edge of this.geometry.selection)line(edge);ctx.stroke();ctx.setLineDash([]);}
136
+ ctx.restore();
137
+ return this.snapshot(true,needsCapture,warnings);
138
+ }
139
+ clear():void{
140
+ this.geometry=null;this.playerAvailable=false;
141
+ const ctx=this.host.canvas.getContext('2d');if(ctx){ctx.save();ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,this.host.canvas.width,this.host.canvas.height);ctx.restore();}
142
+ }
143
+ dispose():void{
144
+ if(this.disposed)return;this.clear();this.host.rigRoot.style.visibility=this.visibility;this.host.canvas.style.pointerEvents=this.pointerEvents;this.disposed=true;
145
+ }
146
+ private snapshot(canvasAvailable:boolean,needsCapture:boolean,warnings:string[]):OverlaySnapshot{
147
+ return {available:this.playerAvailable&&canvasAvailable,needsCapture,meshEdges:this.geometry?.mesh.length??0,boneCount:this.geometry?.joints.length??0,capabilities:{images:this.playerAvailable,mesh:this.playerAvailable&&canvasAvailable,bones:this.playerAvailable&&canvasAvailable},warnings};
148
+ }
149
+ private unavailable(warning:string):OverlaySnapshot{return {available:false,needsCapture:false,meshEdges:0,boneCount:0,capabilities:{images:false,mesh:false,bones:false},warnings:[warning]};}
150
+ }
package/web/styles.css ADDED
@@ -0,0 +1,139 @@
1
+ :root{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e3eceb;background:#10181c;font-size:13px;color-scheme:dark;font-synthesis:none}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button{color:inherit;background:#26383f;border:1px solid #3a535f;border-radius:6px;padding:7px 10px;cursor:pointer}button:hover{background:#344d58}button:disabled{opacity:.45;cursor:default}button[aria-pressed=true]{background:#355445;border-color:#729985;color:#dcf4e5}button.primary{background:#b1dec6;border-color:#b1dec6;color:#163728;font-weight:600}button.outline{background:transparent}button.block{width:100%;margin-top:10px}a{color:#b2dccb}input,select,textarea{color:#e3eceb;background:#12242d;border:1px solid #486472;border-radius:6px;padding:8px;max-width:100%}select,input[type=text],textarea{width:100%}textarea{resize:vertical;line-height:1.6}input[type=range]{accent-color:#b2dec7;padding:0;min-width:0}input[type=checkbox],input[type=radio]{accent-color:#b2dec7}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid #c6e7d7;outline-offset:3px}[hidden]{display:none!important}.topbar{min-height:72px;padding:15px 22px;display:flex;align-items:center;gap:25px;border-bottom:1px solid #30464f;flex-wrap:wrap;background:#15232a}.brand{font-size:19px;letter-spacing:-.6px;white-space:nowrap}.brand-mark{display:inline-grid;place-items:center;width:24px;height:24px;border:1px solid #afdcc5;color:#afdcc5;border-radius:50%;margin-right:9px;font-size:14px}.topbar strong{font-weight:500;font-size:13px}.topbar #revision{margin-left:9px}.top-actions{display:flex;align-items:center;gap:8px;margin-left:auto;flex-wrap:wrap}.subtle,.hint{color:#a2bac4;font-size:11px}.hint{line-height:1.6}.workspaces{display:flex;gap:10px;padding:0 18px;border-bottom:1px solid #30464f;background:#142129}.workspaces button{border:0;border-bottom:2px solid transparent;border-radius:0;background:transparent;padding:15px 13px;font-size:12px;letter-spacing:.06em;color:#a6bec8}.workspaces button[aria-pressed=true]{border-bottom-color:#b1dec6;color:#c8ead8;background:transparent}.workspaces button span{font-size:11px;margin-left:8px;letter-spacing:0;color:#8faab6}.layout{display:grid;grid-template-columns:190px minmax(250px,1fr) 286px;min-height:calc(100vh - 160px)}aside{background:#182a33;padding:20px 15px}.left-panel{border-right:1px solid #30464f}.right-panel{border-left:1px solid #30464f}.eyebrow{font-size:11px;letter-spacing:.08em;color:#aac0c8;margin:0 0 11px}.label{display:block;color:#acbec8;font-size:11px;margin:0 0 7px}.space-top{margin-top:15px}.left-panel section{margin-top:26px}.tool{width:100%;display:flex;justify-content:space-between;align-items:center;text-align:left;gap:5px;border-color:transparent;background:transparent;padding:9px 6px;margin:3px 0}.tool small{font-size:11px;color:#9bb4bf}.checkpoint{display:block;width:100%;background:transparent;border:1px solid transparent;text-align:left;margin:4px 0;padding:10px 9px;white-space:normal;line-height:1.5}.checkpoint[aria-current=true]{background:#294638;border-color:#658d76}.checkpoint small{display:block;font-size:11px;color:#9eb8aa;margin-top:3px}.source-link{display:block;font-size:11px;overflow-wrap:anywhere;margin:8px 0}main{min-width:0;display:flex;flex-direction:column;background:#12232c}.viewbar{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;padding:12px;border-bottom:1px solid #2e4855}.segmented{display:flex;gap:3px;padding:3px;border:1px solid #3a5663;border-radius:8px;background:#11202a}.segmented button{border:0;background:transparent;font-size:11px;padding:5px 9px}.segmented button[aria-pressed=true]{background:#395560;color:#e3eceb}.zoom-controls{display:flex;gap:4px;align-items:center}.zoom-controls button{padding:4px 8px;font-size:12px}.zoom-controls output{font:11px ui-monospace,SFMono-Regular,monospace;min-width:39px;text-align:center;color:#abc1cc}.stage-meta{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 16px 0;font-size:11px;flex-wrap:wrap}.stages{display:flex;gap:10px;padding:12px 12px 0;flex:1;min-height:460px}.stage-pane{position:relative;min-width:0;flex:1;border:1px solid #35535f;border-radius:8px;overflow:hidden;background:radial-gradient(ellipse at 50% 40%,#233d48,#10222c 74%)}.pane-label{position:absolute;left:10px;top:10px;z-index:2;background:#152a35d9;border-radius:4px;padding:4px 7px;color:#c0d4db;font-size:11px;pointer-events:none}.viewport{position:relative;overflow:hidden;width:100%;height:100%;min-height:450px;touch-action:none;background-image:radial-gradient(#8eb2c333 .7px,transparent .7px);background-size:22px 22px}.empty{position:absolute;inset:65px 22px 30px;display:grid;place-items:center;text-align:center;color:#9dbac7;font-size:12px;line-height:1.8}.reference-content,#rig-root{position:absolute;transform-origin:0 0}.reference-content img,.reference-content video{display:block;max-width:none}.reference-content svg{display:block;max-width:none}.reference-content video{object-fit:contain}.stage-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;padding:11px 16px;color:#90afbd;font-size:11px}.transport{display:flex;align-items:center;gap:11px;padding:13px;border-top:1px solid #35515e;background:#162e3b}.transport select{width:auto;max-width:150px;font-size:11px}.transport input{flex:1}.transport output{font:11px ui-monospace,SFMono-Regular,monospace;white-space:nowrap;color:#c1d7df}.sound-panel,.angle-panel{padding:15px;border-top:1px solid #35515e;background:#172f3b}.row{display:flex;align-items:center;gap:11px;flex-wrap:wrap}.row .eyebrow{margin:0}.sound-panel .row{font-size:11px;margin-bottom:9px}.sound-panel input[type=range]{flex:1;max-width:160px}.cue{display:flex;gap:9px;align-items:center;justify-content:space-between;border-top:1px solid #35505c;padding:9px 0;font-size:11px;flex-wrap:wrap}.cue button{padding:4px 7px;font-size:11px}.angle-panel{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:11px}.angle-panel select{width:auto;max-width:150px}.angle-panel .hint{width:100%;margin:0}.angle-panel input{flex:1}.divider{height:1px;background:#38515e;margin:22px 0}h1{font-size:15px;font-weight:500;line-height:1.6;margin:0 0 7px}.outcome{border:0;padding:0;margin:18px 0 0;display:flex;gap:11px;flex-wrap:wrap}.outcome legend{font-size:11px;color:#aec6cf;margin-bottom:8px;padding:0}.outcome label{display:flex;align-items:center;gap:3px;font-size:11px}.proposal-item{border-top:1px solid #3b5664;padding:11px 0;margin-top:12px;font-size:11px;overflow-wrap:anywhere}.proposal-item strong{display:block;font-weight:500;margin-bottom:5px}.proposal-item button{font-size:11px;padding:3px 6px;margin-top:7px}footer{display:flex;justify-content:space-between;gap:15px;flex-wrap:wrap;padding:11px 18px;border-top:1px solid #344e5a;background:#14242c;font-size:11px;color:#b4cad3}.error{color:#f0baac!important}.dirty{color:#e4c99a!important}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
2
+ @media(min-width:1500px){.layout{grid-template-columns:218px minmax(250px,1fr) 310px}.stages{min-height:600px}}
3
+ @media(max-width:1000px){.layout{grid-template-columns:158px minmax(250px,1fr) 250px}aside{padding:17px 12px}.topbar{gap:14px}.topbar #revision{display:block;margin-left:0}.stages{min-height:400px}.viewport{min-height:400px}.workspaces span{display:none}}
4
+ @media(max-width:780px){.layout{grid-template-columns:150px minmax(0,1fr)}.right-panel{grid-column:1/-1;border-left:0;border-top:1px solid #30464f;display:grid;grid-template-columns:1fr 1fr;gap:12px 24px}.right-panel>.eyebrow,.right-panel>.label,.right-panel>#review-status{grid-column:1/-1}.right-panel>.divider{display:none}#checkpoint-detail,#proposal-panel{min-width:0}.top-actions{margin-left:0}.stages{min-height:420px}.viewport{min-height:420px}.transport{flex-wrap:wrap}.transport input{min-width:130px}.topbar{padding:14px 16px}}
5
+ @media(max-width:540px){.layout{grid-template-columns:1fr}.left-panel{border-right:0;border-bottom:1px solid #30464f;display:grid;grid-template-columns:1fr 1fr;gap:12px}.left-panel>.eyebrow,.left-panel>#checkpoints{grid-column:1/-1}.left-panel section{margin-top:8px}#checkpoints{display:flex;gap:5px;flex-wrap:wrap}.checkpoint{width:auto;flex:1;min-width:120px}.right-panel{display:block}#proposal-panel{margin-top:25px}.stages{gap:6px;padding:8px}.stage-pane{border-radius:5px}.viewport{min-height:365px}.stages{min-height:365px}.pane-label{left:5px;top:6px;font-size:11px;padding:3px}.viewbar{gap:8px}.workspaces{padding:0 8px}.workspaces button{padding:13px 9px}.stage-footer{padding:9px 12px}.source-link{font-size:11px}footer{padding:11px 12px}}
6
+ @media(pointer:coarse){button{min-height:42px}input,select,textarea{font-size:16px}.outcome label{min-height:40px}.zoom-controls button{min-width:38px}}
7
+
8
+ .frame-range{display:grid;grid-template-columns:1fr 1fr;gap:9px}.frame-range label{font-size:11px;color:#aec6cf}.frame-range input{width:100%;margin-top:6px}
9
+
10
+ .provenance{margin-top:20px;padding:12px;background:#1d3540;border:1px solid #45616c;border-radius:7px}.provenance-row{display:flex;gap:9px;justify-content:space-between;font-size:11px;margin:6px 0}.provenance-row span{color:#a9c0ca}.provenance-row strong{font-weight:500;overflow-wrap:anywhere}.provenance code{font:11px ui-monospace,SFMono-Regular,monospace}.provenance details{margin-top:10px;font-size:11px;color:#b9d0d9}.provenance summary{cursor:pointer}.provenance pre{white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.6 ui-monospace,SFMono-Regular,monospace}.right-panel>.provenance{grid-column:1/-1}
11
+
12
+ .report-panel{padding:15px;border-top:1px solid #35515e;background:#142a35}.report-header{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.report-header h2{font-size:13px;font-weight:500;margin:0}.report-header a{font-size:11px;margin-left:auto}.report-controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:12px}.report-controls select{width:auto;max-width:250px;flex:1;font-size:11px}.report-controls button{font-size:11px;padding:5px 9px}.report-controls output{font:11px ui-monospace,SFMono-Regular,monospace;color:#b3cbd5}.report-empty-categories{display:flex;gap:6px;flex-wrap:wrap;margin-top:11px}.report-empty-categories span{font-size:11px;color:#a8c2ce;background:#213943;padding:2px 6px;border-radius:4px}.report-event-list{list-style:none;padding:0;margin:10px 0 0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px}.report-event-list li{min-width:0}.report-event-list button{display:flex;flex-direction:column;gap:2px;text-align:left;width:100%;height:100%;padding:8px 9px;border-color:#304b58;background:#192f3b;white-space:normal;overflow-wrap:anywhere;line-height:1.5}.report-event-list button[aria-current=true]{border-color:#88b39a;background:#294537}.report-event-list strong{font-size:11px;font-weight:500}.report-event-list small{font-size:11px;color:#a4bdc8}.report-event-list .no-time{color:#dfc49c}.report-selection{border-top:1px solid #3e5b68;margin-top:12px;padding-top:12px}.report-selection h3{font-size:12px;font-weight:500;margin:0}.report-selection details{font-size:11px;color:#aac3ce;margin-top:10px}.report-selection summary{cursor:pointer}.report-selection pre{white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.6 ui-monospace,SFMono-Regular,monospace;background:#11232d;padding:10px;border-radius:5px}.report-selection #report-time{max-width:100%}@media(max-width:1000px){.report-event-list{grid-template-columns:1fr}}@media(max-width:540px){.report-controls select{min-width:140px}.report-header a{margin-left:0}}
13
+
14
+ .transport #frame-label{color:#c6e6d4;font-size:11px}.frame-controls{display:flex;align-items:center;gap:9px;flex-wrap:wrap;padding:10px 14px;background:#17303b;border-top:1px solid #35515e;font-size:11px}.frame-controls label{color:#b1c9d2}.frame-controls input[type=number]{width:86px;font-variant-numeric:tabular-nums}.frame-controls input[type=range]{flex:1;min-width:100px}.frame-controls button{font-size:11px;padding:5px 9px}.frame-controls .hint{margin:0}.frame-controls #frame-outside{width:100%}
15
+
16
+ /* Inspection layout is bounded independently of pane content and review forms. */
17
+ #stage-layout{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:minmax(0,1fr);height:clamp(480px,calc(100dvh - 300px),900px);min-height:0;flex:none;align-items:stretch}
18
+ #stage-layout[data-orientation=vertical]{grid-template-columns:minmax(0,1fr);grid-template-rows:repeat(2,minmax(0,1fr))}
19
+ #stage-layout>.stage-pane,#stage-layout>.pane{grid-column:auto;min-height:0;min-width:0;display:flex;flex-direction:column}
20
+ #stage-layout .viewport,#stage-layout .rig-viewport,#stage-layout .reference-viewport{min-height:0;min-width:0;flex:1;touch-action:none;cursor:grab;user-select:none}
21
+ #stage-layout [data-panning=true]{cursor:grabbing!important}
22
+ #stage-layout img{-webkit-user-drag:none;user-select:none}
23
+ .rig-overlay{position:absolute;inset:0;pointer-events:none;width:100%;height:100%;z-index:1}
24
+ .inspection-controls{display:flex;align-items:center;gap:8px 14px;flex-wrap:wrap;padding:9px 14px;border-bottom:1px solid #35515e;font-size:11px;background:#172f3b}
25
+ .inspection-controls label{display:inline-flex;align-items:center;gap:5px;margin:0;white-space:nowrap}
26
+ .inspection-controls select{width:auto;padding:4px;font-size:11px}
27
+ .inspection-controls input[type=checkbox]{width:auto;margin:0}
28
+ .inspection-controls fieldset{display:flex;gap:10px;align-items:center;border:0;padding:0;margin:0;min-width:0;flex-wrap:wrap}
29
+ .inspection-controls legend{float:left;margin-right:9px;font-size:11px}
30
+ #control-state:empty{display:none}
31
+ :root[data-theme=light]{color-scheme:light;color:#233c47;background:#f4f7f9}
32
+ :root[data-theme=dark]{color-scheme:dark}
33
+ [data-theme=light] body,[data-theme=light] main{background:#f4f7f9;color:#233c47}
34
+ [data-theme=light] header,[data-theme=light] .topbar,[data-theme=light] .workspaces,[data-theme=light] footer{background:#edf3f5;border-color:#bacbd3;color:#294552}
35
+ [data-theme=light] aside,[data-theme=light] .transport,[data-theme=light] .frame-controls,[data-theme=light] .inspection-controls,[data-theme=light] .sound-panel,[data-theme=light] .angle-panel,[data-theme=light] .report-panel{background:#e9f0f3;border-color:#b7c9d1;color:#294552}
36
+ [data-theme=light] button{background:#e2ebef;border-color:#a6bdc8;color:#264652}
37
+ [data-theme=light] button:hover{background:#d0e2e8}
38
+ [data-theme=light] button.primary,[data-theme=light] .primary{background:#24694d;border-color:#24694d;color:#fff}
39
+ [data-theme=light] input,[data-theme=light] select,[data-theme=light] textarea{background:#fff;border-color:#8faab7;color:#243e4a;accent-color:#276c50}
40
+ [data-theme=light] input::placeholder,[data-theme=light] textarea::placeholder{color:#607986}
41
+ [data-theme=light] .hint,[data-theme=light] .subtle,[data-theme=light] .label,[data-theme=light] .eyebrow,[data-theme=light] aside label,[data-theme=light] .tool small,[data-theme=light] .stage-footer,[data-theme=light] .compare-info,[data-theme=light] .boundary,[data-theme=light] .trace,[data-theme=light] .transport output,[data-theme=light] .zoom-controls output,[data-theme=light] .frame-range label,[data-theme=light] .frame-controls label{color:#496774}
42
+ [data-theme=light] .workspaces button,[data-theme=light] .segmented button{color:#3e5c69}
43
+ [data-theme=light] .workspaces button[aria-pressed=true]{color:#185738;border-bottom-color:#276c50}
44
+ [data-theme=light] .workspaces button span{color:#58727e}
45
+ [data-theme=light] .segmented,[data-theme=light] .provenance,[data-theme=light] .report-selection pre{background:#e1ebef;border-color:#9cb5c0;color:#294552}
46
+ [data-theme=light] .segmented button[aria-pressed=true],[data-theme=light] .checkpoint[aria-current=true],[data-theme=light] .report-event-list button[aria-current=true]{background:#cde4d8;border-color:#56866e;color:#1f4937}
47
+ [data-theme=light] .checkpoint small{color:#4b6c59}
48
+ [data-theme=light] .provenance p,[data-theme=light] .provenance-row span,[data-theme=light] .provenance details,[data-theme=light] .report-selection details,[data-theme=light] .report-event-list small{color:#486470}
49
+ [data-theme=light] .report-event-list button,[data-theme=light] .report-empty-categories span{background:#e5eef2;border-color:#acbfca;color:#294552}
50
+ [data-theme=light] .feedback-item strong{color:#1e4e3b}
51
+ [data-theme=light] a{color:#175f48}
52
+ [data-theme=light] .error{color:#a03225!important}
53
+ [data-theme=light] .dirty,[data-theme=light] .report-event-list .no-time{color:#875800!important}
54
+ [data-theme=light] :focus-visible{outline-color:#24694d}
55
+ /* Source pixels retain their original color; the neutral stage stays dark in both themes. */
56
+
57
+ /* The application shell never scrolls; each Inspector tab and the source list owns overflow. */
58
+ html,body{height:100%;overflow:hidden}body{height:100dvh}#app{height:100dvh;display:grid;grid-template-rows:auto minmax(0,1fr) auto;overflow:hidden}
59
+ .layout{min-height:0!important;overflow:hidden;grid-template-columns:170px minmax(0,1fr)!important}
60
+ main{min-height:0;overflow:hidden;display:flex;flex-direction:column}
61
+ .left-panel{min-height:0;overflow:auto;padding:13px 10px}
62
+ #stage-layout{height:auto;min-height:0;flex:1;overflow:hidden;padding:8px;gap:7px}
63
+ .inspector{height:clamp(205px,32dvh,340px);min-height:180px;flex:none;display:flex;flex-direction:column;border-top:1px solid #49616d;background:#182d38;overflow:hidden}
64
+ .inspector-heading{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:7px 12px;border-bottom:1px solid #3e5c69;flex:none;font-size:11px}
65
+ .inspector-heading strong{font-size:10px;letter-spacing:.08em;font-weight:500}
66
+ .inspector-heading [role=tablist]{display:flex;gap:4px}
67
+ .inspector-heading button{padding:4px 9px;font-size:11px}
68
+ .inspector-heading button[aria-selected=true]{background:#3d5d4d;color:#e0f3e7;border-color:#7fa98e}
69
+ .inspector-body{flex:1;min-height:0;overflow:hidden}
70
+ .inspector-body>[role=tabpanel]{height:100%;overflow:auto;padding:12px 15px;min-height:0}
71
+ .inspector .inspection-controls{padding:0;border:0;background:transparent}
72
+ .inspector .provenance{margin-top:10px}
73
+ .inspector .transport{padding:0 0 10px;border:0;background:transparent;flex-wrap:wrap}
74
+ .inspector .report-panel{padding:10px 0}
75
+ .inspector .feedback-form{border:0;padding:0;margin:0}
76
+ #inspector-feedback{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(270px,100%),1fr));gap:20px;align-items:start}
77
+ #inspector-feedback>section,#inspector-feedback>#checkpoint-detail,#inspector-feedback>#proposal-panel{break-inside:avoid}
78
+ #inspector-feedback .divider{display:none}
79
+ #inspector-feedback>label,#inspector-feedback>select{display:block;max-width:300px}
80
+ #inspector-rig #modeling-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0}
81
+ #inspector-rig .tool{width:auto}
82
+ #inspector-rig #part-set{max-width:190px}
83
+ #inspector-playback #motion-tools{display:flex;gap:8px;flex-wrap:wrap}
84
+ #inspector-playback .tool{width:auto}
85
+ [data-theme=light] .inspector{background:#edf3f5;border-color:#a6bec8;color:#294552}
86
+ [data-theme=light] .inspector-heading{border-color:#b3c6d0}
87
+ [data-theme=light] .inspector-heading button[aria-selected=true]{background:#cde4d8;color:#1e4d36;border-color:#59836b}
88
+ @media(max-width:780px){.layout{grid-template-columns:130px minmax(0,1fr)!important}.inspector{height:36dvh}.stage-meta{padding:6px 10px 0}.stage-footer{padding:5px 10px}.inspector-heading>.hint{display:none}}
89
+ @media(max-width:540px){.layout{grid-template-columns:minmax(0,1fr)!important}.left-panel{display:block;padding:8px;font-size:11px}.layout{grid-template-columns:105px minmax(0,1fr)!important}.topbar,header{padding:7px 10px;gap:8px}.topbar .brand,header .brand{font-size:14px}.top-actions,.actions{gap:4px}.top-actions button,.actions button{padding:4px 6px;font-size:10px}.workspaces button{padding:6px}.inspector{height:40dvh;min-height:150px}.inspector-heading{gap:8px;padding:5px 8px}.inspector-heading button{padding:4px 7px}.stage-footer{display:none}}
90
+
91
+ .inspector .feedback-form>label,.inspector .review-summary>label{display:block;font-size:11px;margin:10px 0 5px}.inspector .frame-range input{width:100%}.inspector [role=tabpanel]{overscroll-behavior:contain}.left-panel{overscroll-behavior:contain}#stage-layout .pane-label{max-width:calc(100% - 15px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
92
+
93
+ .rig-layer-controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin-bottom:12px;font-size:11px}.rig-layer-controls fieldset{display:flex;gap:12px;align-items:center;flex-wrap:wrap;border:0;margin:0;padding:0}.rig-layer-controls legend{float:left;margin-right:10px}.rig-layer-controls label{display:inline-flex;gap:5px;align-items:center}.rig-layer-controls input[type=checkbox]{width:auto}
94
+
95
+ .numeric-view{font:11px/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%;padding:8px;background:#14232c;color:#c9dfe7;border-radius:5px}.view-link-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px}.draft-notice{flex:none;font-size:11px;padding:0 12px;color:#e0c58e}.draft-notice:has(#draft-state:empty){display:none}.draft-notice #draft-recovery{display:inline-flex;gap:8px;margin-left:8px;flex-wrap:wrap}.draft-notice button{font-size:11px;padding:2px 6px}[data-theme=light] .numeric-view{background:#e1eaee;color:#264653}[data-theme=light] .draft-notice{color:#80521a}[data-theme=light] .outcome legend,[data-theme=light] .brand span,[data-theme=light] .toolbar{color:#496774}
96
+
97
+ .camera-sync{display:inline-flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}.camera-sync input[type=checkbox]{width:auto;flex:none;margin:0}
98
+ #stage-layout:has(>.stage-pane[hidden]){grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}
99
+
100
+ /* Slot explorer shares the stage row; bottom Inspector remains in the main column. */
101
+ .layout{grid-template-columns:150px minmax(0,1fr) 235px!important}.parts-explorer{display:block;grid-template-columns:none;gap:normal;min-width:0;min-height:0;overflow:auto;border-left:1px solid #49616d;background:#182d38;padding:12px;overscroll-behavior:contain}.parts-explorer h2{font-size:12px;margin:0 0 10px}.parts-explorer>label{display:block;font-size:11px;margin-top:12px}.parts-explorer input[type=search]{width:100%;min-width:0}.parts-explorer .part-solo{display:flex;gap:6px;align-items:center}.parts-explorer ul{list-style:none;margin:8px 0;padding:0;display:grid;gap:4px}.parts-explorer li{min-width:0}.parts-explorer li button{display:block;width:100%;text-align:left;overflow-wrap:anywhere;font-size:11px}.parts-explorer button[aria-pressed=true]{background:#355445;border-color:#9cd3b4}.parts-explorer h2,#selected-part h3{font-weight:600}#selected-part{margin-bottom:12px}#selected-part h3{margin:0 0 8px;overflow-wrap:anywhere}#selected-part p{overflow-wrap:anywhere}[data-theme=light] .parts-explorer{background:#edf3f5;border-color:#a6bec8;color:#294552}[data-theme=light] .parts-explorer button[aria-pressed=true]{background:#c5ddcd;color:#183c2c}
102
+ @media(max-width:1050px){.layout{grid-template-columns:110px minmax(0,1fr) 185px!important}.parts-explorer{padding:8px}}
103
+ @media(max-width:700px){.layout{grid-template-columns:minmax(0,1fr) 165px!important}.left-panel{display:none!important}}
104
+ @media(max-width:460px){.layout{grid-template-columns:minmax(0,1fr) 132px!important}.parts-explorer{padding:6px}.parts-explorer li button{padding:6px}.parts-explorer>.hint{font-size:10px}}
105
+
106
+ /* Resizable workspace: one measured shell; workspace-ui clamps the desired browser sizes. */
107
+ .layout.workspace-layout{display:grid;grid-template-columns:var(--workspace-columns)!important;min-height:0!important;overflow:hidden}
108
+ .workspace-layout>main{display:grid;min-width:0;min-height:0;overflow:hidden;grid-column:3;grid-row:1}
109
+ .workspace-stage-stack{grid-row:1;display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
110
+ .workspace-stage-stack>#stage-layout{flex:1;min-height:0;height:auto;overflow:hidden}
111
+ .workspace-layout>.left-panel{grid-column:1;grid-row:1;display:flex!important;flex-direction:column;overflow:hidden;min-width:0;padding:0;border-right:1px solid #49616d}
112
+ .workspace-sources{flex:1;min-height:0;overflow:auto;padding:10px;overscroll-behavior:contain}
113
+ .workspace-settings{flex:none;max-height:65%;overflow:auto;padding:10px;border-top:1px solid #49616d}
114
+ .workspace-settings h2{font-size:12px;margin:0 0 8px}
115
+ .workspace-settings label{display:flex;align-items:center;gap:6px;font-size:11px;margin:7px 0}
116
+ .workspace-settings select{width:auto;min-width:0;flex:1}
117
+ .workspace-settings .hint{margin:6px 0}
118
+ .workspace-settings input[type=checkbox]{width:auto;flex:none}
119
+ .workspace-layout>.parts-explorer{display:block;grid-template-columns:none;grid-column:5;grid-row:1;width:auto;min-width:0;min-height:0;height:auto;overflow:auto}
120
+ .workspace-layout .inspector{grid-row:3;height:auto!important;min-height:0!important;max-height:none;overflow:hidden}
121
+ .workspace-separator{position:relative;z-index:3;background:#34515e;touch-action:none;cursor:col-resize;min-width:0;min-height:0}
122
+ .workspace-separator::after{content:'';position:absolute;inset:0 -4px}
123
+ .workspace-separator:focus-visible{outline:2px solid #c8e8d6;outline-offset:-2px;background:#719581}
124
+ .workspace-separator[data-resizing=true]{background:#8ac9a3}
125
+ .workspace-leftWidth{grid-column:2;grid-row:1}.workspace-rightWidth{grid-column:4;grid-row:1}
126
+ .workspace-inspectorHeight{grid-row:2;cursor:row-resize}.workspace-inspectorHeight::after{inset:-4px 0}
127
+ .workspace-drawer-triggers{display:flex;gap:6px;flex-wrap:wrap;flex:none;padding:4px 8px;min-height:0}
128
+ .workspace-drawer-triggers:not(:has(button:not([hidden]))){display:none}
129
+ .workspace-drawer-triggers button{padding:4px 7px;font-size:11px;min-height:28px}
130
+ .workspace-drawer{position:fixed;inset:0;width:min(94vw,950px);height:min(92dvh,850px);max-width:94vw;max-height:92dvh;margin:auto;padding:8px;border:1px solid #739383;border-radius:8px;background:#182d38;color:inherit;overflow:hidden;z-index:20}
131
+ .workspace-drawer:not([open]){display:none}.workspace-drawer[open]{display:flex;flex-direction:column;gap:8px}
132
+ .workspace-drawer::backdrop{background:#07141ed0}
133
+ .workspace-drawer-close{align-self:flex-end;flex:none}
134
+ .workspace-drawer>.left-panel{display:flex!important;flex-direction:column;flex:1;overflow:hidden;min-height:0;padding:0}
135
+ .workspace-drawer>.parts-explorer{display:block;grid-template-columns:none;flex:1;width:auto;min-width:0;min-height:0;height:auto;overflow:auto}
136
+ .workspace-drawer>.inspector{flex:1;height:auto!important;min-height:0!important;max-height:none;overflow:hidden}
137
+ [data-theme=light] .workspace-settings,[data-theme=light] .workspace-drawer{background:#edf3f5;color:#294552;border-color:#a6bec8}
138
+ [data-theme=light] .workspace-separator{background:#b2c7cf}[data-theme=light] .workspace-separator:focus-visible{outline-color:#24694d;background:#70a58c}
139
+ @media(pointer:coarse){.workspace-separator::after{inset:0 -7px}.workspace-inspectorHeight::after{inset:-7px 0}.workspace-drawer-triggers button{min-height:38px}}