@oddessentials/agent-guild 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ import { WORLDS, photoName } from './model.mjs';
2
+
3
+ const CREST = new URL('../brand/crest.png', import.meta.url).href;
4
+ const SITE = 'agentguild.oddessentials.ai';
5
+ const INK = '#f8ecd4', FAINT = '#c9c2b4', PANEL = 'rgba(13,20,22,.85)', PANEL_END = 'rgba(13,20,22,.6)';
6
+
7
+ async function crestImage() {
8
+ const image = new Image();
9
+ image.src = CREST;
10
+ await image.decode();
11
+ return image;
12
+ }
13
+
14
+ function canCopy() {
15
+ return window.isSecureContext && typeof ClipboardItem === 'function' && typeof navigator.clipboard?.write === 'function'
16
+ && (ClipboardItem.supports?.('image/png') ?? true);
17
+ }
18
+
19
+ function canShare() {
20
+ try { return Boolean(navigator.canShare?.({ files: [new File([], 'yard.png', { type: 'image/png' })] })); } catch { return false; }
21
+ }
22
+
23
+ function fit(context, text, width) {
24
+ if (context.measureText(text).width <= width + .5) return text;
25
+ let cut = text;
26
+ while (cut && context.measureText(cut + '…').width > width) cut = cut.slice(0, -1);
27
+ return cut + '…';
28
+ }
29
+
30
+ function write(context, line, x, color) {
31
+ context.font = line.font;
32
+ context.fillStyle = color;
33
+ context.fillText(fit(context, line.text, line.box.w), x, line.box.y + line.box.h / 2);
34
+ }
35
+
36
+ function shadow(context, color, blur, y) {
37
+ context.shadowColor = color; context.shadowBlur = blur; context.shadowOffsetY = y;
38
+ }
39
+
40
+ // Tags as yard.css draws them unselected, in stage pixels.
41
+ function drawTag(context, tag) {
42
+ context.save();
43
+ context.textBaseline = 'middle';
44
+ if (tag.kind === 'chip') {
45
+ const { x, y, w, h } = tag.box;
46
+ shadow(context, 'rgba(0,0,0,.33)', 10, 2);
47
+ context.beginPath(); context.roundRect(x, y, w, h, 10);
48
+ context.fillStyle = PANEL; context.fill();
49
+ shadow(context, 'transparent', 0, 0);
50
+ context.strokeStyle = 'rgba(224,191,121,.27)'; context.lineWidth = 1; context.stroke();
51
+ context.textAlign = 'center';
52
+ shadow(context, '#000', 3, 1);
53
+ write(context, tag.lines[0], x + w / 2, INK);
54
+ context.restore();
55
+ return;
56
+ }
57
+ const bottom = tag.box.y + tag.box.h, name = tag.lines[0].box;
58
+ const box = tag.kind === 'session' ? { x: name.x - 6, y: bottom - name.h - 8, w: name.w + 12, h: name.h + 8 } : tag.box;
59
+ const lines = tag.kind === 'session' ? [{ ...tag.lines[0], box: { ...name, y: box.y + 3 } }] : tag.lines;
60
+ const color = tag.color || '#ac9362';
61
+ context.globalAlpha = tag.status === 'exited' ? .5 : tag.kind === 'session' ? .82 : 1;
62
+ shadow(context, 'rgba(0,0,0,.33)', 12, 2);
63
+ context.beginPath(); context.roundRect(box.x, box.y, box.w, box.h, 4);
64
+ const fill = context.createLinearGradient(0, box.y, 0, bottom);
65
+ fill.addColorStop(0, PANEL); fill.addColorStop(1, PANEL_END);
66
+ context.fillStyle = fill; context.fill();
67
+ shadow(context, 'transparent', 0, 0);
68
+ context.save();
69
+ context.clip();
70
+ if (tag.status === 'locked') {
71
+ context.strokeStyle = color; context.lineWidth = 2; context.setLineDash([4, 3]);
72
+ context.beginPath(); context.moveTo(box.x, bottom - 1); context.lineTo(box.x + box.w, bottom - 1); context.stroke();
73
+ } else {
74
+ context.fillStyle = color; context.fillRect(box.x, bottom - 2, box.w, 2);
75
+ }
76
+ context.restore();
77
+ shadow(context, '#000', 3, 1);
78
+ lines.forEach((line, i) => write(context, line, line.box.x, i ? FAINT : INK));
79
+ context.restore();
80
+ }
81
+
82
+ function drawSignature(context, skin, crest, width, height) {
83
+ const title = WORLDS[skin].title;
84
+ const display = getComputedStyle(document.documentElement).getPropertyValue('--font-display').trim() || 'serif';
85
+ const titleFont = `600 15px ${display}`, siteFont = `500 10px ${getComputedStyle(document.body).fontFamily}`;
86
+ context.save();
87
+ context.font = titleFont; const titleWidth = context.measureText(title).width;
88
+ context.font = siteFont; const siteWidth = context.measureText(SITE).width;
89
+ const mark = crest && skin !== 'professional' ? 34 : 0, markWidth = mark && mark * crest.naturalWidth / crest.naturalHeight;
90
+ const pad = 10, w = pad * 2 + (mark ? markWidth + 10 : 0) + Math.max(titleWidth, siteWidth), h = pad * 2 + 34;
91
+ const x = width - w - 14, y = height - h - 14, left = x + pad + (mark ? markWidth + 10 : 0);
92
+ shadow(context, 'rgba(0,0,0,.33)', 12, 2);
93
+ context.beginPath(); context.roundRect(x, y, w, h, 8);
94
+ context.fillStyle = 'rgba(13,20,22,.72)'; context.fill();
95
+ shadow(context, 'transparent', 0, 0);
96
+ if (mark) context.drawImage(crest, x + pad, y + (h - mark) / 2, markWidth, mark);
97
+ context.textBaseline = 'top';
98
+ context.font = titleFont; context.fillStyle = INK; context.fillText(title, left, y + pad + 1);
99
+ context.font = siteFont; context.fillStyle = FAINT; context.fillText(SITE, left, y + pad + 22);
100
+ context.restore();
101
+ }
102
+
103
+ export class YardPhoto {
104
+ constructor(dialog) {
105
+ this.dialog = dialog;
106
+ this.preview = dialog.querySelector('canvas');
107
+ this.labels = dialog.querySelector('[data-labels]');
108
+ this.status = dialog.querySelector('[role="status"]');
109
+ this.copyButton = dialog.querySelector('[data-copy]');
110
+ this.saveButton = dialog.querySelector('[data-save]');
111
+ this.shareButton = dialog.querySelector('[data-share]');
112
+ this.shot = null; this.png = null; this.blob = null; this.url = ''; this.version = 0; this.opening = false;
113
+ this.crest = crestImage().catch(() => null);
114
+ this.labels.addEventListener('change', () => { this.draw(); this.status.textContent = ''; });
115
+ this.copyButton.addEventListener('click', () => this.copy());
116
+ this.saveButton.addEventListener('click', () => this.save());
117
+ this.shareButton.addEventListener('click', () => this.share());
118
+ dialog.querySelector('[data-done]').addEventListener('click', () => dialog.close());
119
+ dialog.addEventListener('close', () => {
120
+ if (dialog.open) return;
121
+ this.clear(); this.opener?.focus();
122
+ });
123
+ }
124
+
125
+ async open(renderer, opener) {
126
+ if (this.dialog.open || this.opening) return;
127
+ const version = this.version;
128
+ this.opening = true;
129
+ try {
130
+ const display = getComputedStyle(document.documentElement).getPropertyValue('--font-display');
131
+ const [crest] = await Promise.all([this.crest, document.fonts.load(`600 15px ${display}`).catch(() => {})]);
132
+ const stage = document.getElementById('yard-stage');
133
+ if (version !== this.version || !(stage.checkVisibility?.() ?? stage.isConnected) || !stage.clientWidth || !stage.clientHeight) return;
134
+ let shot = null;
135
+ try { shot = renderer.capture(); } catch {}
136
+ Object.assign(this, { shot, opener, crestImage: crest, taken: new Date(), background: getComputedStyle(document.getElementById('yard')).backgroundColor });
137
+ this.labels.checked = false;
138
+ try { this.draw(); } catch { shot.canvas.width = 0; this.shot = shot = null; this.draw(); }
139
+ this.labels.parentElement.hidden = !shot;
140
+ this.copyButton.hidden = !shot || !canCopy();
141
+ this.shareButton.hidden = !shot || !canShare();
142
+ this.saveButton.hidden = !shot;
143
+ this.status.textContent = shot ? '' : 'Couldn’t take the picture.';
144
+ this.dialog.showModal();
145
+ [this.copyButton, this.saveButton, this.dialog.querySelector('[data-done]')].find(button => !button.hidden).focus();
146
+ } finally { this.opening = false; }
147
+ }
148
+
149
+ draw() {
150
+ this.version++;
151
+ this.png = null; this.blob = null;
152
+ const { shot, preview } = this;
153
+ if (!shot) { preview.width = preview.height = 0; return; }
154
+ preview.width = shot.canvas.width; preview.height = shot.canvas.height;
155
+ const context = preview.getContext('2d');
156
+ context.fillStyle = this.background;
157
+ context.fillRect(0, 0, preview.width, preview.height);
158
+ context.drawImage(shot.canvas, 0, 0);
159
+ context.save();
160
+ context.scale(shot.scale, shot.scale);
161
+ if (this.labels.checked) for (const tag of shot.tags) drawTag(context, tag);
162
+ drawSignature(context, shot.skin, this.crestImage, preview.width / shot.scale, preview.height / shot.scale);
163
+ context.restore();
164
+ }
165
+
166
+ encode() {
167
+ if (this.png) return this.png;
168
+ const png = this.png = new Promise((resolve, reject) => this.preview.toBlob(blob => blob ? resolve(blob) : reject(new Error('The picture could not be encoded.')), 'image/png'));
169
+ png.then(blob => { if (this.png === png) this.blob = blob; }, () => { if (this.png === png) this.png = null; });
170
+ return png;
171
+ }
172
+
173
+ name() { return photoName(WORLDS[this.shot.skin].title, this.taken); }
174
+
175
+ isCurrent(version) { return version === this.version && this.dialog.open; }
176
+
177
+ report(version, text) {
178
+ if (this.isCurrent(version)) this.status.textContent = text;
179
+ }
180
+
181
+ async copy() {
182
+ const version = this.version;
183
+ try {
184
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': this.encode() })]);
185
+ this.report(version, 'Copied');
186
+ } catch { this.report(version, 'Couldn’t copy the picture.'); }
187
+ }
188
+
189
+ async save() {
190
+ const version = this.version, name = this.name();
191
+ try {
192
+ const blob = await this.encode();
193
+ if (!this.isCurrent(version)) return;
194
+ URL.revokeObjectURL(this.url);
195
+ this.url = URL.createObjectURL(blob);
196
+ Object.assign(document.createElement('a'), { href: this.url, download: name }).click();
197
+ this.report(version, 'Saved ' + name);
198
+ } catch { this.report(version, 'Couldn’t save the picture.'); }
199
+ }
200
+
201
+ async share() {
202
+ const version = this.version, name = this.name(), ready = this.blob;
203
+ try {
204
+ const blob = ready || await this.encode();
205
+ if (!this.isCurrent(version)) return;
206
+ const file = new File([blob], name, { type: 'image/png' });
207
+ await navigator.share({ files: [file] });
208
+ this.report(version, 'Shared');
209
+ } catch (err) {
210
+ if (err?.name === 'AbortError') return;
211
+ this.report(version, err?.name === 'NotAllowedError' && !ready ? 'The picture is ready. Press Share again.' : 'Couldn’t share the picture.');
212
+ }
213
+ }
214
+
215
+ close() {
216
+ this.clear();
217
+ if (this.dialog.open) this.dialog.close();
218
+ }
219
+
220
+ clear() {
221
+ this.version++;
222
+ URL.revokeObjectURL(this.url);
223
+ if (this.shot) this.shot.canvas.width = 0;
224
+ this.shot = null; this.png = null; this.blob = null; this.url = '';
225
+ this.preview.width = this.preview.height = 0;
226
+ this.status.textContent = '';
227
+ }
228
+ }
@@ -1,6 +1,6 @@
1
1
  import * as T from './vendor/engine.js';
2
2
  import { WORLDS, PROVIDER_ORDER, characterIndex, CAMERA, SUN, minZoom, overviewFor, clampPan, viewBasis, providerPositions, sessionPose, familiarPose, hash,
3
- stage, nextStagingChange, doorFor, arrives, route, clipFor, helperOffset, stationProp, hallActivity, lookTarget, lookLimits, STAGING } from './model.mjs';
3
+ stage, nextStagingChange, doorFor, arrives, route, clipFor, helperOffset, stationProp, hallActivity, lookTarget, lookLimits, STAGING, photoSize } from './model.mjs';
4
4
 
5
5
  const ASSETS = new URL('./assets/', import.meta.url);
6
6
  const vector = new T.Vector3();
@@ -940,6 +940,51 @@ export class YardRenderer {
940
940
  }
941
941
  this.dirty=true;this.drawOnce();
942
942
  }
943
+ // The frame on screen at twice its size, without the selection ring, and
944
+ // the tags shown over it. The live view is left exactly as it was.
945
+ capture() {
946
+ if(this.disposed||!this.world||this.renderer.getContext().isContextLost())return null;
947
+ const w=this.host.clientWidth,h=this.host.clientHeight,buffer=this.renderer.getDrawingBufferSize(new T.Vector2());
948
+ const size=photoSize(w,h,buffer.x,buffer.y);
949
+ const viewport=this.renderer.getViewport({copy:v=>v.clone()}),ratio=this.renderer.getPixelRatio();
950
+ const particleScale=this.effects?.points.material.uniforms.scale,scale=particleScale?.value;
951
+ const radius=(this.effects?Math.max(...this.effects.preset.size):0)*size.height*this.camera.projectionMatrix.elements[5]/4;
952
+ const pad=Math.ceil(radius)+1,tw=buffer.x+2*pad,th=buffer.y+2*pad;
953
+ const canvas=Object.assign(document.createElement('canvas'),{width:size.width,height:size.height});
954
+ let tags;
955
+ try {
956
+ this.positionLabels();
957
+ tags=this.tags();
958
+ for(const unit of this.units.values()){unit.ring.material.color.set(unit.session?.provider.color||0xbaaa80);unit.ring.scale.setScalar(1);}
959
+ const context=canvas.getContext('2d');
960
+ // Keep particle centers within a padded frustum. Extending the viewport
961
+ // beyond the canvas crops the margins without enlarging the live buffer.
962
+ this.renderer.setViewport(-pad/ratio,-pad/ratio,tw/ratio,th/ratio);
963
+ if(particleScale)particleScale.value=th/2;
964
+ for(const {x,y}of size.tiles) {
965
+ this.camera.setViewOffset(size.width,size.height,x-pad,y-pad,tw,th);
966
+ this.renderer.render(this.scene,this.camera);
967
+ context.drawImage(this.canvas,x,y);
968
+ }
969
+ } finally {
970
+ this.renderer.setViewport(viewport);
971
+ if(particleScale)particleScale.value=scale;
972
+ this.camera.clearViewOffset();
973
+ this.select(this.selected);
974
+ this.renderer.render(this.scene,this.camera);
975
+ }
976
+ return {canvas,scale:size.scale,skin:this.skin,theme:this.theme,tags};
977
+ }
978
+ tags() {
979
+ const origin=this.host.getBoundingClientRect();
980
+ const box=el=>{const r=el.getBoundingClientRect();return {x:r.left-origin.left,y:r.top-origin.top,w:r.width,h:r.height};};
981
+ const text=el=>{const style=getComputedStyle(el);return {text:el.textContent,font:style.fontWeight+' '+style.fontSize+' '+style.fontFamily,box:box(el)};};
982
+ return [...this.labels.children].filter(el=>el.style.visibility!=='hidden'&&(el.classList.contains('yard-label')||el.classList.contains('yard-crowd'))).map(el=>{
983
+ if(el.classList.contains('yard-crowd'))return {kind:'chip',box:box(el),lines:[text(el)]};
984
+ const kind=el.dataset.kind,lines=kind==='session'?[text(el.children[0])]:[...el.children].map(text);
985
+ return {kind,status:el.dataset.status,color:el.style.getPropertyValue('--unit-color'),box:box(el),lines};
986
+ });
987
+ }
943
988
  pick(event) {
944
989
  const rect=this.canvas.getBoundingClientRect();
945
990
  this.pointer.set((event.clientX-rect.left)/rect.width*2-1,-(event.clientY-rect.top)/rect.height*2+1);
package/web/yard/view.js CHANGED
@@ -21,7 +21,8 @@ const megabytes = bytes => (bytes / 1048576).toFixed(1) + ' MB';
21
21
  /** A layout with injected canonical controls. No API, sockets, or business rules. */
22
22
  export function initYard(controller) {
23
23
  let selected = null, renderer = null, loading = null, failed = false, disposed = false;
24
- let sceneAttempt = 0, worldAttempt = 0, pendingRenderer = null, swapReveal = 0;
24
+ let sceneAttempt = 0, worldAttempt = 0, pendingRenderer = null, swapReveal = 0, photo = null;
25
+ const closePhoto = () => photo?.close();
25
26
  const rows = new Map();
26
27
  const motion = matchMedia('(prefers-reduced-motion: reduce)');
27
28
  const root = document.documentElement;
@@ -59,6 +60,7 @@ export function initYard(controller) {
59
60
  if (allowed) yardChoice.parentElement.removeAttribute('title');
60
61
  else yardChoice.parentElement.title = `${skinEntry()?.name || 'This skin'} has no yard yet.`;
61
62
  $('yard').hidden = view !== 'yard';
63
+ if (view !== 'yard') closePhoto();
62
64
  $('providers').hidden = view === 'yard';
63
65
  $('providers').inert = view === 'yard';
64
66
  for (const section of document.querySelectorAll('#app > section.sessions, #app > section.news')) {
@@ -158,6 +160,7 @@ export function initYard(controller) {
158
160
  renderer.setActive(sceneVisible());
159
161
  hideLoading();
160
162
  $('yard-stage').dataset.ready = 'true';
163
+ $('yard-photo').disabled = false;
161
164
  } catch (err) {
162
165
  candidate?.dispose();
163
166
  if (attempt === sceneAttempt && !disposed) {
@@ -169,6 +172,7 @@ export function initYard(controller) {
169
172
  await loading;
170
173
  }
171
174
  function failScene() {
175
+ closePhoto();
172
176
  const restoreFocus = $('yard-stage').contains(document.activeElement);
173
177
  sceneAttempt++; worldAttempt++; loading = null;
174
178
  pendingRenderer?.dispose(); pendingRenderer = null;
@@ -178,6 +182,7 @@ export function initYard(controller) {
178
182
  $('yard-swap-failure').hidden = true;
179
183
  $('yard-failure').hidden = false;
180
184
  $('yard-stage').dataset.ready = 'false';
185
+ $('yard-photo').disabled = true;
181
186
  if (restoreFocus) $('yard-retry').focus();
182
187
  }
183
188
  function roster() {
@@ -304,6 +309,14 @@ export function initYard(controller) {
304
309
  $('yard-focus').addEventListener('click',() => renderer?.focus(selected));
305
310
  $('yard-zoom-in').addEventListener('click',() => renderer?.zoom(1.2));
306
311
  $('yard-zoom-out').addEventListener('click',() => renderer?.zoom(1/1.2));
312
+ $('yard-photo').hidden = !controller.photo;
313
+ $('yard-photo').addEventListener('click',async () => {
314
+ let YardPhoto;
315
+ try { ({ YardPhoto } = await import('./photo.js')); }
316
+ catch { controller.toast('Couldn’t open Photo. Reload the page and try again.'); return; }
317
+ photo ||= new YardPhoto($('yard-photo-dialog'));
318
+ if (renderer && !disposed && sceneVisible()) photo.open(renderer, $('yard-photo'));
319
+ });
307
320
  $('yard-stage').addEventListener('keydown',event => {
308
321
  if (event.target !== $('yard-stage') || event.ctrlKey || event.metaKey || event.altKey) return;
309
322
  if (event.key === 'Escape') { select(null); event.preventDefault(); }
@@ -335,7 +348,8 @@ export function initYard(controller) {
335
348
  window.addEventListener('pagehide',pageHide);
336
349
  window.addEventListener('pageshow',visibility);
337
350
  setView(view,{remember:false});
338
- return { update, select, setView, dispose() {
351
+ return { update, select, setView, closePhoto, dispose() {
352
+ closePhoto();
339
353
  disposed=true; sceneAttempt++; worldAttempt++; clearTimeout(swapReveal); observer.disconnect(); pendingRenderer?.dispose(); renderer?.dispose();
340
354
  document.removeEventListener('visibilitychange',visibility);
341
355
  motion.removeEventListener('change',motionChange);
package/web/yard/yard.css CHANGED
@@ -16,7 +16,7 @@
16
16
  #yard-title { font-size:clamp(22px,2.2vw,34px); letter-spacing:.015em; text-shadow:0 2px 14px var(--bg); }
17
17
  #yard-subtitle { margin:6px 0 0; font-size:12px; color:var(--muted); }
18
18
  .yard-summary { align-self:flex-start; margin-top:3px; font-size:11px; color:var(--text); padding:8px 11px; border:1px solid var(--yard-line); border-radius:30px; background:color-mix(in srgb,var(--surface) 75%,transparent); white-space:nowrap; }
19
- .yard-stage { flex:1; min-height:400px; position:relative; overflow:hidden; outline-offset:-3px; }
19
+ .yard-stage { flex:1; min-height:400px; position:relative; overflow:hidden; outline-offset:-3px; display:flex; flex-direction:column; justify-content:flex-end; }
20
20
  .yard-stage canvas { display:block; position:absolute; inset:0; width:100%; height:100%; cursor:grab; touch-action:none; }
21
21
  .yard-stage canvas:active { cursor:grabbing; }
22
22
  .yard-stage:focus-visible,.yard-row:focus-visible,.yard-label:focus-visible,.yard-crowd:focus-visible,.yard-crowd-pick:focus-visible,.yard-crowd-open:focus-visible,.yard-search input:focus-visible { outline:2px solid var(--focus); outline-offset:-2px; }
@@ -41,17 +41,19 @@
41
41
  .yard-crowd-pick .label-state { font-size:9px; color:#c9c2b4; }
42
42
  .yard-crowd-open { font-size:10px; white-space:nowrap; color:#e0bf79; }
43
43
  .yard-crowd-pick:hover, .yard-crowd-open:hover { background:#1d3236; }
44
- .yard-camera { position:absolute; right:18px; bottom:48px; z-index:2; display:flex; gap:5px; }
45
- .yard-camera .btn { min-height:30px; padding:5px 10px; font-size:11px; background:var(--surface); }
46
- #yard-help { position:absolute; bottom:10px; left:50%; transform:translateX(-50%); width:max-content; max-width:calc(100% - 36px); margin:0; padding:4px 12px; border-radius:30px; font-size:10px; color:var(--muted); text-align:center; pointer-events:none; background:color-mix(in srgb,var(--surface) 80%,transparent); }
44
+ .yard-overlay { z-index:2; display:flex; flex-direction:column; align-items:center; gap:10px; padding:0 18px 10px; pointer-events:none; }
45
+ .yard-overlay > * { pointer-events:auto; max-width:100%; }
46
+ .yard-camera { align-self:flex-end; display:flex; gap:5px; flex-wrap:wrap; justify-content:flex-end; }
47
+ .yard-camera .btn { min-height:30px; padding:5px 10px; font-size:11px; background:var(--surface); white-space:nowrap; }
48
+ #yard-help { width:max-content; margin:6px 0 0; padding:4px 12px; border-radius:30px; font-size:10px; color:var(--muted); text-align:center; pointer-events:none; background:color-mix(in srgb,var(--surface) 80%,transparent); }
47
49
  .yard-loading { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); display:grid; grid-template-columns:1fr auto; align-items:center; gap:10px 16px; min-width:240px; padding:18px 26px; color:var(--text); border:1px solid var(--yard-line); background:var(--surface); z-index:4; border-radius:var(--radius); }
48
50
  .yard-loading-size { font-size:11px; color:var(--muted); font-variant-numeric:tabular-nums; }
49
51
  .yard-loading-bar { grid-column:1 / -1; height:3px; border-radius:3px; overflow:hidden; background:color-mix(in srgb,var(--gold) 18%,transparent); }
50
52
  .yard-loading-bar::before { content:""; display:block; width:35%; height:100%; border-radius:inherit; background:var(--gold); animation:yard-loading 1.3s ease-in-out infinite; }
51
53
  @keyframes yard-loading { from { transform:translateX(-100%); } to { transform:translateX(290%); } }
52
54
  /* A swap keeps the current world on screen, so its notices stay out of the way. */
53
- .yard-loading[data-mode="swap"],.yard-swap-failure { top:auto; bottom:88px; transform:translateX(-50%); min-width:0; padding:8px 14px; font-size:12px; border-radius:30px; background:color-mix(in srgb,var(--surface) 92%,transparent); }
54
- .yard-swap-failure { position:absolute; left:50%; z-index:4; display:flex; align-items:center; gap:12px; color:var(--text); border:1px solid var(--border); }
55
+ .yard-loading[data-mode="swap"],.yard-swap-failure { position:static; transform:none; min-width:0; padding:8px 14px; font-size:12px; border-radius:30px; background:color-mix(in srgb,var(--surface) 92%,transparent); }
56
+ .yard-swap-failure { display:flex; align-items:center; gap:12px; color:var(--text); border:1px solid var(--border); }
55
57
  .yard-swap-failure .btn { min-height:26px; padding:3px 10px; font-size:11px; }
56
58
  .yard-failure { position:absolute; top:40%; left:50%; transform:translate(-50%,-50%); max-width:380px; width:85%; padding:24px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); z-index:4; }
57
59
  .yard-failure p { color:var(--muted); line-height:1.5; font-size:13px; }
@@ -112,8 +114,8 @@
112
114
  .yard-heading { padding:18px; }
113
115
  :root[data-view="yard"] #app > .launcher { padding:10px 14px; }
114
116
  :root[data-view="yard"] #launch-heading { display:none; }
115
- .yard-camera { right:12px; bottom:56px; }
116
- #yard-help { font-size:9px; }
117
+ .yard-overlay { padding:0 12px 10px; }
118
+ #yard-help { font-size:9px; margin-top:16px; }
117
119
  }
118
120
  @media(max-width:640px) { :root[data-view="yard"] .cwd { flex-basis:100%; } }
119
121
  :root[data-layout="compact"] .topbar, :root[data-layout="compact"] #app > .launcher, :root[data-layout="compact"] .yard-heading, :root[data-layout="compact"] .yard-news, :root[data-layout="compact"] .yard-sidebar, :root[data-layout="compact"] #yard-help, :root[data-layout="compact"] #yard-failure > p { display:none; }
@@ -121,8 +123,8 @@
121
123
  :root[data-layout="compact"] #yard { display:flex; height:100dvh; min-height:0; }
122
124
  :root[data-layout="compact"] .yard-main { flex:1; height:auto; min-height:0; }
123
125
  :root[data-layout="compact"] .yard-stage { min-height:0; }
124
- :root[data-layout="compact"] .yard-camera { right:8px; bottom:8px; }
125
- :root[data-layout="compact"] #yard-offline { top:8px; bottom:auto; left:8px; right:8px; transform:none; flex-wrap:wrap; justify-content:center; text-align:center; border-radius:var(--radius); background:var(--surface); }
126
+ :root[data-layout="compact"] .yard-overlay { padding:0 8px 8px; }
127
+ :root[data-layout="compact"] #yard-offline { position:absolute; z-index:4; top:8px; bottom:auto; left:8px; right:8px; transform:none; flex-wrap:wrap; justify-content:center; text-align:center; border-radius:var(--radius); background:var(--surface); }
126
128
  :root[data-layout="compact"] #yard-stage:has(#yard-offline:not([hidden])) :is(canvas, .yard-labels) { opacity:.5; filter:grayscale(1); }
127
129
  :root[data-layout="compact"] #yard-stage:has(#yard-offline:not([hidden])) :is(#yard-download, #yard-loading) { display:none; }
128
130
  :root[data-expand="none"] :is(#yard-offline-open, #yard-cards, #yard-download-cards, .yard-crowd-open) { display:none; }
@@ -138,3 +140,10 @@
138
140
  .yard-row[data-key^="provider:"] .yard-row-sub { display:none; }
139
141
  .yard-row[data-status="locked"] .yard-row-sub { display:block; }
140
142
  .yard-search input { color:var(--text); background:var(--surface-2); border:1px solid var(--border); border-radius:var(--radius); }
143
+ .yard-photo { width:min(1120px,calc(100vw - 32px)); height:min(860px,calc(100dvh - 48px)); }
144
+ .yard-photo .models-body { display:flex; flex-direction:column; gap:12px; padding-top:14px; overflow:hidden; }
145
+ .yard-photo-preview { flex:1 1 0; min-height:0; width:100%; object-fit:contain; }
146
+ .yard-photo-labels { flex:none; display:flex; flex-wrap:wrap; align-items:center; gap:8px; font-size:13px; }
147
+ .yard-photo-labels span { color:var(--muted); font-size:12px; }
148
+ .yard-photo-footer { display:flex; flex-wrap:wrap; justify-content:flex-end; align-items:center; gap:10px; }
149
+ .yard-photo-footer p { flex:1 1 160px; min-width:0; margin:0; color:var(--muted); font-size:13px; }