@1agh/maude 1.3.0 → 1.3.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.
@@ -141,7 +141,7 @@ const HIDDEN_OK = new Set(['.ai', '.claude', '.design']);
141
141
  // an explicit enumerated list rather than a broad pattern so it can never
142
142
  // accidentally widen to swallow runtime JSON — findFiles already excludes
143
143
  // `_`-prefixed entries before this list is even consulted.
144
- const PREVIEW_ASSET_EXTS = [
144
+ export const PREVIEW_ASSET_EXTS = [
145
145
  '.svg',
146
146
  '.png',
147
147
  '.jpg',
@@ -171,6 +171,30 @@ const PREVIEW_ASSET_EXTS = [
171
171
  * (`system/<ds>/preview/*.html`) intentionally stay `.html` and travel via
172
172
  * the DS-aware `findFiles()` path below.
173
173
  */
174
+ /**
175
+ * True when `abs` is a directory tree holding nothing but folder placeholders
176
+ * (`.gitkeep`, `.DS_Store`) — what a folder projection leaves before any
177
+ * content arrives. False when it is absent, is not a directory, or holds
178
+ * anything real (a symlink counts as real: never followed, never removed).
179
+ */
180
+ async function onlyFolderPlaceholders(abs: string, depth = 0): Promise<boolean> {
181
+ if (depth > 16) return false;
182
+ let entries: Dirent[];
183
+ try {
184
+ entries = await readdir(abs, { withFileTypes: true });
185
+ } catch {
186
+ return false;
187
+ }
188
+ for (const e of entries) {
189
+ if (e.isDirectory()) {
190
+ if (!(await onlyFolderPlaceholders(path.join(abs, e.name), depth + 1))) return false;
191
+ } else if (!(e.isFile() && (e.name === '.gitkeep' || e.name === '.DS_Store'))) {
192
+ return false;
193
+ }
194
+ }
195
+ return true;
196
+ }
197
+
174
198
  export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): Promise<string[]> {
175
199
  const out: string[] = [];
176
200
  let entries: Dirent[];
@@ -203,7 +227,7 @@ export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): P
203
227
  * traversal instead of adding a second full walk of `system/` (the largest
204
228
  * group) just to enumerate directories.
205
229
  */
206
- async function findFiles(
230
+ export async function findFiles(
207
231
  absRoot: string,
208
232
  prefix: string,
209
233
  exts: string[],
@@ -3214,6 +3238,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3214
3238
  };
3215
3239
  }
3216
3240
  await rename(g.abs, toAbs);
3241
+ // A hub's own studio has no watcher to see this rename: announce both
3242
+ // ends, or the journal learns of it only from the walk-import belt and a
3243
+ // teammate waits minutes for a rename that took a second here.
3244
+ announceWritten(rel);
3245
+ announceWritten(toRel);
3217
3246
  ctx.bus.emit('canvas-list-update', {
3218
3247
  action: 'moved',
3219
3248
  rel: toRel,
@@ -3248,6 +3277,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3248
3277
  await mkdir(trashDir, { recursive: true });
3249
3278
  const trashed = path.join(trashDir, path.posix.basename(rel));
3250
3279
  await rename(g.abs, trashed);
3280
+ announceWritten(rel);
3251
3281
  ctx.bus.emit('canvas-list-update', { action: 'removed', rel, slug });
3252
3282
  return {
3253
3283
  ok: true,
@@ -3903,6 +3933,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3903
3933
  }
3904
3934
 
3905
3935
  await mkdir(toDirAbs, { recursive: true });
3936
+ // OUR OWN ACTION MAY ALREADY BE ON THIS DISK. The destination did not exist
3937
+ // when this move was checked; the project accepted the move since, and its
3938
+ // announcement can reach this machine's sync before the answer reaches this
3939
+ // request — which materializes the destination folders (each with its
3940
+ // `.gitkeep`). Renaming onto that non-empty placeholder failed (ENOTEMPTY),
3941
+ // leaving the old folder in this person's tree while every teammate had
3942
+ // the move (plan T31/L02). A destination holding nothing but placeholders
3943
+ // is that projection; it is replaced, never merged into.
3944
+ if (await onlyFolderPlaceholders(toDirFinalAbs)) {
3945
+ await rm(toDirFinalAbs, { recursive: true, force: true });
3946
+ }
3906
3947
  try {
3907
3948
  await rename(dirAbs, toDirFinalAbs);
3908
3949
  } catch (err) {
@@ -22,7 +22,7 @@
22
22
  // serves the resulting JS at /<designRel>/ui/<slug>.tsx with Content-Type
23
23
  // `application/javascript`.
24
24
 
25
- import { existsSync } from 'node:fs';
25
+ import { existsSync, realpathSync } from 'node:fs';
26
26
  import path from 'node:path';
27
27
 
28
28
  import { canvasLibPath, canvasLibResolver } from './canvas-lib-resolver.ts';
@@ -129,6 +129,10 @@ export async function buildCanvasModule(
129
129
  // needed; RUNTIME_PACKAGES already lists every specifier the importmap covers.
130
130
  const externalSpecifiers = new Set<string>(RUNTIME_PACKAGES);
131
131
  const denials: Array<{ specifier: string; reason: string }> = [];
132
+ // Every stylesheet the canvas pulls in by relative import — they are inlined
133
+ // (below), so the open canvas has no <link> to swap when one changes; the
134
+ // injector records them for the iframe's HMR client (see buildCssInjector).
135
+ const cssSources = new Set<string>();
132
136
 
133
137
  const built = await Bun.build({
134
138
  entrypoints: [canvasAbsPath],
@@ -151,6 +155,17 @@ export async function buildCanvasModule(
151
155
  'process.env.NODE_ENV': '"production"',
152
156
  },
153
157
  plugins: [
158
+ {
159
+ name: 'css-sources',
160
+ setup(builder) {
161
+ builder.onResolve({ filter: /\.css$/ }, (args: { path: string; importer: string }) => {
162
+ if (args.path.startsWith('.') && args.importer) {
163
+ cssSources.add(path.resolve(path.dirname(args.importer), args.path));
164
+ }
165
+ return null; // record only — resolution continues as before
166
+ });
167
+ },
168
+ },
154
169
  // Resolve `@maude/canvas-lib` BEFORE exact-externals — we want the bare
155
170
  // specifier to map to the dev-server-bundled lib, not get marked external.
156
171
  canvasLibResolver(),
@@ -217,7 +232,26 @@ export async function buildCanvasModule(
217
232
  for (const a of cssAssets) css += await a.text();
218
233
  if (css.trim().length > 0) {
219
234
  const slug = canvasAbsPath.split('/').pop() ?? 'canvas';
220
- js = buildCssInjector(slug, css) + js;
235
+ const root = options.designRoot ?? options.restrictImportsTo;
236
+ // Real paths on both sides: the bundler reports importers by their REAL
237
+ // path, and a design root reached through a symlink (macOS `/var` →
238
+ // `/private/var`, where every temp dir lives) made each source look like
239
+ // `../../private/…` — filtered out, so nothing was ever named and the
240
+ // open canvas never restyled.
241
+ const real = (p: string) => {
242
+ try {
243
+ return realpathSync(p);
244
+ } catch {
245
+ return p;
246
+ }
247
+ };
248
+ const realRoot = root ? real(root) : null;
249
+ const sources = realRoot
250
+ ? [...cssSources]
251
+ .map((abs) => path.relative(realRoot, real(abs)).split(path.sep).join('/'))
252
+ .filter((rel) => rel && !rel.startsWith('..'))
253
+ : [];
254
+ js = buildCssInjector(slug, css, sources) + js;
221
255
  }
222
256
  }
223
257
 
@@ -227,16 +261,23 @@ export async function buildCanvasModule(
227
261
 
228
262
  /**
229
263
  * Synthesize a module-init prologue that creates a `<style data-canvas-css>`
230
- * tag with the bundled CSS text. Idempotent per-slug — duplicate mounts of
231
- * the same canvas don't re-inject. Run at top-level so it executes before
232
- * the React component does its first render.
264
+ * tag with the bundled CSS text. One tag per slug — a duplicate mount reuses
265
+ * it, and a RE-IMPORT (the iframe's soft reload after a stylesheet changed)
266
+ * replaces its text, so a changed import actually reaches the page. Run at
267
+ * top-level so it executes before the React component does its first render.
268
+ *
269
+ * `sources` — the design-root-relative stylesheets inlined here — lands on the
270
+ * tag as `data-canvas-css-sources` (`|`-separated): the iframe's HMR client
271
+ * reloads when one of them changes, since there is no <link> to swap (plan
272
+ * T31/L16: a canvas importing `../system/<ds>/tokens.css` never restyled).
233
273
  */
234
- function buildCssInjector(slug: string, css: string): string {
274
+ function buildCssInjector(slug: string, css: string, sources: readonly string[] = []): string {
235
275
  // JSON-encode the CSS text so we don't need to worry about backticks,
236
276
  // backslashes, or embedded `</style>` (which would break a raw template).
237
277
  const enc = JSON.stringify(css);
238
- const id = `canvas-css-${slug.replace(/[^a-zA-Z0-9-]/g, '_')}`;
239
- return `// canvas-build: inject bundled sibling CSS so the canvas is self-contained.\n(function(){if(typeof document==="undefined")return;if(document.getElementById(${JSON.stringify(id)}))return;var s=document.createElement("style");s.id=${JSON.stringify(id)};s.dataset.canvasCss="bundled";s.textContent=${enc};document.head.appendChild(s);})();\n`;
278
+ const id = JSON.stringify(`canvas-css-${slug.replace(/[^a-zA-Z0-9-]/g, '_')}`);
279
+ const src = JSON.stringify(sources.join('|'));
280
+ return `// canvas-build: inject bundled sibling CSS so the canvas is self-contained.\n(function(){if(typeof document==="undefined")return;var s=document.getElementById(${id});if(!s){s=document.createElement("style");s.id=${id};s.dataset.canvasCss="bundled";document.head.appendChild(s);}s.dataset.canvasCssSources=${src};if(s.textContent!==${enc})s.textContent=${enc};})();\n`;
240
281
  }
241
282
 
242
283
  /**
@@ -696,6 +696,7 @@ export function applyTextEdit(
696
696
  const expr = (only as AnyNode).expression;
697
697
  // A single `{'string literal'}` — rewrite the literal in place (DDR-150 P1).
698
698
  if (isStringLit(expr)) {
699
+ if (expr.value === text) return { source, delta: 0 };
699
700
  const s = new MagicString(source);
700
701
  s.overwrite(expr.start as number, expr.end as number, JSON.stringify(text));
701
702
  const out = s.toString();
@@ -727,6 +728,10 @@ export function applyTextEdit(
727
728
  const start = only.start as number;
728
729
  const end = only.end as number;
729
730
  const raw = source.slice(start, end);
731
+ // The text it already shows is not an edit (plan T23): a multi-line JSX text
732
+ // renders as its lines joined by one space, and rewriting it with that same
733
+ // text used to fold the author's line breaks into one line.
734
+ if (jsxRenderedText(raw) === escapeJsxText(text)) return { source, delta: 0 };
730
735
  // Preserve the original indentation/newline framing; swap only the visible text.
731
736
  const lead = /^\s*/.exec(raw)?.[0] ?? '';
732
737
  const trail = /\s*$/.exec(raw)?.[0] ?? '';
@@ -746,6 +751,26 @@ export function applyTextEdit(
746
751
  // the index drifts (`.filter().map()`, reorders). Anything we can't target
747
752
  // unambiguously returns null → the caller throws → routes to /design:edit.
748
753
 
754
+ /**
755
+ * JSX's own whitespace rule for a text child, on the raw source: each line is
756
+ * trimmed (inner lines on both sides), empty lines drop, and the rest join with
757
+ * one space. Entities stay encoded — the caller compares against escaped text.
758
+ */
759
+ function jsxRenderedText(raw: string): string {
760
+ const lines = raw.split(/\r\n|\n|\r/);
761
+ if (lines.length === 1) return raw.trim();
762
+ return lines
763
+ .map((line, i) => {
764
+ let l = line;
765
+ if (i !== 0) l = l.replace(/^[ \t]+/, '');
766
+ if (i !== lines.length - 1) l = l.replace(/[ \t]+$/, '');
767
+ return l;
768
+ })
769
+ .filter((l) => l.length > 0)
770
+ .join(' ')
771
+ .trim();
772
+ }
773
+
749
774
  function isStringLit(n: AnyNode): boolean {
750
775
  return !!(
751
776
  n &&
@@ -4871,6 +4896,28 @@ export function assembleCompSource(
4871
4896
  // ---------------------------------------------------------------------------
4872
4897
  // Edit shapes.
4873
4898
 
4899
+ /**
4900
+ * The literal an attribute expression IS — a string, number or boolean (a
4901
+ * negative number arrives as a unary minus) — or null for anything computed.
4902
+ */
4903
+ function literalOfExpression(expr: AnyNode): string | number | boolean | null {
4904
+ if (!expr) return null;
4905
+ if (expr.type === 'Literal' || expr.type === 'StringLiteral') {
4906
+ const v = expr.value;
4907
+ return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : null;
4908
+ }
4909
+ if (expr.type === 'NumericLiteral') return typeof expr.value === 'number' ? expr.value : null;
4910
+ if (expr.type === 'BooleanLiteral') return typeof expr.value === 'boolean' ? expr.value : null;
4911
+ if (
4912
+ expr.type === 'UnaryExpression' &&
4913
+ expr.operator === '-' &&
4914
+ (expr.argument?.type === 'Literal' || expr.argument?.type === 'NumericLiteral') &&
4915
+ typeof expr.argument.value === 'number'
4916
+ )
4917
+ return -expr.argument.value;
4918
+ return null;
4919
+ }
4920
+
4874
4921
  function editStringAttr(
4875
4922
  s: MagicString,
4876
4923
  opening: AnyNode,
@@ -4897,6 +4944,9 @@ function editStringAttr(
4897
4944
  return;
4898
4945
  }
4899
4946
  if (v.type === 'Literal' || v.type === 'StringLiteral') {
4947
+ // Setting the value it already has is not an edit (plan T23): keep the
4948
+ // author's bytes, quote style included.
4949
+ if (typeof v.value === 'string' && v.value === value) return;
4900
4950
  // Replace the whole `"value"` (quotes included) so we control the escaping.
4901
4951
  // The value lands in a JSX *attribute*, where `"` must become `&quot;` and
4902
4952
  // `<`/`>` their entities — NOT JS backslash escaping. `JSON.stringify` would
@@ -4921,6 +4971,25 @@ function editStringAttr(
4921
4971
  { canvas: canvasAbsPath, id }
4922
4972
  );
4923
4973
  }
4974
+ // A NUMBER OR BOOLEAN STAYS ONE (plan T23). `height={400}`,
4975
+ // `durationInFrames={30}`, `aria-pressed={false}` used to come back as
4976
+ // `"400"` / `"30"` / `"false"` — a different type, which a component that
4977
+ // does arithmetic on its prop (every Remotion timing prop) silently
4978
+ // mis-reads — and even an edit to the value it already had rewrote the
4979
+ // bytes. The same value is now no edit; a new number or boolean is
4980
+ // written into the braces it came in.
4981
+ const lit = literalOfExpression(inner);
4982
+ if (lit !== null) {
4983
+ if (String(lit) === value) return;
4984
+ if (typeof lit === 'number' && /^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) {
4985
+ s.overwrite(inner.start, inner.end, value);
4986
+ return;
4987
+ }
4988
+ if (typeof lit === 'boolean' && (value === 'true' || value === 'false')) {
4989
+ s.overwrite(inner.start, inner.end, value);
4990
+ return;
4991
+ }
4992
+ }
4924
4993
  // Replace the whole `{...}` with a plain quoted literal — keeps the
4925
4994
  // resulting JSX readable. Same JSX-attribute escaping as above (NOT
4926
4995
  // `JSON.stringify`, which would JS-escape a `"` and corrupt the source).
@@ -5726,6 +5795,14 @@ export function relocateElement(
5726
5795
  return matches.length === 1 ? (matches[0] as { id: string }).id : null;
5727
5796
  }
5728
5797
 
5798
+ /** T23 — every JSX element of `source` with its id and print (corpus checks). */
5799
+ export function listElements(
5800
+ canvasAbsPath: string,
5801
+ source: string
5802
+ ): Array<{ id: string; print: ElementPrint }> {
5803
+ return printAll(canvasAbsPath, source, null);
5804
+ }
5805
+
5729
5806
  /** T23 — how many elements of `source` carry a print no other element shares. */
5730
5807
  export function printUniqueness(
5731
5808
  canvasAbsPath: string,
@@ -25,9 +25,28 @@
25
25
 
26
26
  import path from 'node:path';
27
27
 
28
- import { canvasSlugFromRel, findHtmlFiles, SKIP_DIRS } from './api.ts';
28
+ import {
29
+ canvasSlugFromRel,
30
+ findFiles,
31
+ findHtmlFiles,
32
+ PREVIEW_ASSET_EXTS,
33
+ SKIP_DIRS,
34
+ } from './api.ts';
29
35
  import type { Context } from './context.ts';
30
36
 
37
+ /**
38
+ * Supporting files the tree lists beside the canvases (images, fonts, media —
39
+ * the same `PREVIEW_ASSET_EXTS` `/_index-data` walks). A new one written from
40
+ * outside the app — an agent's export, a teammate's image arriving through
41
+ * sync — is a tree change too; without it the row stayed missing until
42
+ * something else reloaded the tree (plan T17/L03). Sidecars and stylesheets are
43
+ * deliberately NOT here: they are rewritten on every edit, and the tree nests
44
+ * them under their canvas anyway.
45
+ */
46
+ function isListedFile(low: string): boolean {
47
+ return PREVIEW_ASSET_EXTS.some((x) => low.endsWith(x));
48
+ }
49
+
31
50
  /** Burst-collapse window. A new canvas writes both `.tsx` and `.meta.json`; a
32
51
  * git checkout touches many files at once — one snapshot per quiet window. */
33
52
  export const CANVAS_LIST_DEBOUNCE_MS = 150;
@@ -59,7 +78,8 @@ export function isCanvasCandidate(rel: string, groupPaths: string[]): boolean {
59
78
  // canvases costs one recompute and emits nothing.
60
79
  const last = p.slice(p.lastIndexOf('/') + 1);
61
80
  const looksLikeDir = !last.includes('.');
62
- if (!looksLikeDir && !low.endsWith('.tsx') && !low.endsWith('.html')) return false;
81
+ if (!looksLikeDir && !low.endsWith('.tsx') && !low.endsWith('.html') && !isListedFile(low))
82
+ return false;
63
83
  const segs = p.split('/');
64
84
  for (const s of segs) {
65
85
  if (!s) return false;
@@ -123,6 +143,11 @@ export function createCanvasListWatch(
123
143
  continue; // group dir missing / unreadable — treat as empty
124
144
  }
125
145
  for (const f of files) out.add(f);
146
+ try {
147
+ for (const f of await findFiles(groupAbs, groupRel, PREVIEW_ASSET_EXTS)) out.add(f);
148
+ } catch {
149
+ /* unreadable — the canvases above still diff */
150
+ }
126
151
  }
127
152
  return out;
128
153
  }
@@ -137,6 +162,12 @@ export function createCanvasListWatch(
137
162
  // render/open/build sink without re-validating against /_index-data.
138
163
  const prefix = `${ctx.paths.designRel.replace(/^\/+|\/+$/g, '')}/`;
139
164
  const relOut = rel.startsWith(prefix) ? rel.slice(prefix.length) : rel;
165
+ if (isListedFile(rel.toLowerCase())) {
166
+ // Not a canvas: its own action and no slug, so no consumer mistakes an
167
+ // image for a canvas to close, retarget or adopt.
168
+ ctx.bus.emit('canvas-list-update', { action: `file-${action}`, rel: relOut });
169
+ return;
170
+ }
140
171
  ctx.bus.emit('canvas-list-update', {
141
172
  action,
142
173
  rel: relOut,
@@ -32,7 +32,7 @@ import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
32
32
  // same "pull only pure logic into the client bundle" shape as the imports
33
33
  // above (a type-only SyncStatusSnapshot import that Bun erases).
34
34
  import { syncPresentation } from '../sync/presentation.ts';
35
- import { canvasUrl } from './canvas-url.js';
35
+ import { canvasTokenRefreshDelay, canvasUrl, setLiveCanvasToken } from './canvas-url.js';
36
36
  import { applyEditRequest } from './apply-edit-request.ts';
37
37
  import { createIndexLoader } from './index-loader.ts';
38
38
  import {
@@ -4684,6 +4684,28 @@ function Viewport({
4684
4684
  useEffect(() => {
4685
4685
  if (previewPath) previewRef.current?.focus();
4686
4686
  }, [previewPath]);
4687
+ // An open canvas keeps the URL it was opened with. The capability in it is
4688
+ // re-minted on a timer (canvas-url.js); letting that change the `src` of a
4689
+ // live iframe would reload every open canvas every few minutes. The fresh
4690
+ // capability reaches an open canvas by message instead, and a frame that is
4691
+ // (re)created — a new tab, a Retry, a config change — is built with it.
4692
+ // Only while the frame is open: a closed tab reopened later is a new frame
4693
+ // and must not inherit a capability that may have expired meanwhile.
4694
+ const srcCache = useRef(new Map());
4695
+ const openPaths = new Set(tabs.map((t) => t.path));
4696
+ for (const [key, entry] of srcCache.current) {
4697
+ if (!openPaths.has(entry.path)) srcCache.current.delete(key);
4698
+ }
4699
+ const stableSrc = (path) => {
4700
+ const bare = canvasUrl(path, { ...cfg, canvasToken: undefined });
4701
+ const key = `${path}#${canvasReloadNonce}|${bare}`;
4702
+ let entry = srcCache.current.get(key);
4703
+ if (!entry) {
4704
+ entry = { path, src: canvasUrl(path, cfg) };
4705
+ srcCache.current.set(key, entry);
4706
+ }
4707
+ return entry.src;
4708
+ };
4687
4709
  // One observable word for "what is the canvas pane actually doing" — the
4688
4710
  // top-frame signal the #115 E2E scenario waits on, and the only place these
4689
4711
  // three states are named together. `ready` is `dgn:'loaded'`-backed, so it
@@ -4776,7 +4798,7 @@ function Viewport({
4776
4798
  // path exactly as before.
4777
4799
  key={`${t.path}#${canvasReloadNonce}`}
4778
4800
  ref={(el) => registerIframe(t.path, el)}
4779
- src={canvasUrl(t.path, cfg)}
4801
+ src={stableSrc(t.path)}
4780
4802
  className={t.path === activePath ? 'active' : ''}
4781
4803
  data-path={t.path}
4782
4804
  data-testid={t.path === activePath ? 'canvas-frame' : undefined}
@@ -5353,6 +5375,7 @@ function CommentsPanel({
5353
5375
  type="button"
5354
5376
  className={'st-cm-filter' + (filter === 'all' ? ' is-active' : '')}
5355
5377
  role="tab"
5378
+ data-testid="comment-filter-all"
5356
5379
  aria-selected={filter === 'all'}
5357
5380
  onClick={() => setFilter('all')}
5358
5381
  >
@@ -5362,6 +5385,7 @@ function CommentsPanel({
5362
5385
  type="button"
5363
5386
  className={'st-cm-filter' + (filter === 'open' ? ' is-active' : '')}
5364
5387
  role="tab"
5388
+ data-testid="comment-filter-open"
5365
5389
  aria-selected={filter === 'open'}
5366
5390
  onClick={() => setFilter('open')}
5367
5391
  >
@@ -5371,6 +5395,7 @@ function CommentsPanel({
5371
5395
  type="button"
5372
5396
  className={'st-cm-filter' + (filter === 'resolved' ? ' is-active' : '')}
5373
5397
  role="tab"
5398
+ data-testid="comment-filter-resolved"
5374
5399
  aria-selected={filter === 'resolved'}
5375
5400
  onClick={() => setFilter('resolved')}
5376
5401
  >
@@ -5402,6 +5427,7 @@ function CommentsPanel({
5402
5427
  {g.comments.map((c) => (
5403
5428
  <div
5404
5429
  key={c.id}
5430
+ data-testid={`comment-item-${c.id}`}
5405
5431
  className={
5406
5432
  'st-comment' +
5407
5433
  (c.status === 'resolved' ? ' is-resolved' : '') +
@@ -9578,8 +9604,10 @@ function InspectorPanel({
9578
9604
  };
9579
9605
  // An annotation-image has no element tabs; force Photo. Otherwise honor the
9580
9606
  // requested tab, but drop off a stale 'photo' tab when the new selection isn't
9581
- // photo-eligible (so switching from a photo to a normal element lands sanely).
9582
- const effTab = photoOnly ? 'photo' : tab === 'photo' && !photoTarget ? 'inspect' : tab;
9607
+ // photo-eligible — onto CSS, the editing tab a fresh selection opens on (Stage
9608
+ // C). Landing on read-only Inspect meant that after touching a sticker, the
9609
+ // next element you clicked showed facts instead of the controls to change it.
9610
+ const effTab = photoOnly ? 'photo' : tab === 'photo' && !photoTarget ? 'css' : tab;
9583
9611
  const tabBtn = (id, label, icon) => (
9584
9612
  <button
9585
9613
  type="button"
@@ -10806,6 +10834,71 @@ function App() {
10806
10834
  const wsRef = useRef(null);
10807
10835
  const iframesRef = useRef(new Map());
10808
10836
 
10837
+ // THE CANVAS CAPABILITY OUTLIVES NO TAB (cloud only). It expires 15 minutes
10838
+ // after the proxy minted it (render-token.mjs) and cannot be revoked, so it
10839
+ // stays short — which left every cloud tab open longer than that with
10840
+ // canvases that stopped updating: a teammate's edit re-imports the canvas
10841
+ // module, and the canvas origin answered 401. Re-mint it well inside the
10842
+ // lifetime (every `/_config` answer carries a fresh one), hand it to each
10843
+ // open canvas, and let new frames be built with it. On waking a tab that
10844
+ // slept past the cadence, do it at once. Desktops have no capability and
10845
+ // never start the timer.
10846
+ useEffect(() => {
10847
+ if (!cfg?.canvasToken) return undefined;
10848
+ let stopped = false;
10849
+ let timer = null;
10850
+ let dueAt = 0;
10851
+ const schedule = (token) => {
10852
+ clearTimeout(timer);
10853
+ const delay = canvasTokenRefreshDelay(token);
10854
+ dueAt = Date.now() + delay;
10855
+ timer = setTimeout(refresh, delay);
10856
+ };
10857
+ async function refresh() {
10858
+ let token = null;
10859
+ try {
10860
+ const res = await fetch('/_config', { cache: 'no-store', credentials: 'same-origin' });
10861
+ if (res.ok) token = (await res.json())?.canvasToken;
10862
+ } catch {
10863
+ /* retried below; the current capability may still be valid */
10864
+ }
10865
+ if (stopped) return;
10866
+ if (typeof token !== 'string' || !token) {
10867
+ clearTimeout(timer);
10868
+ dueAt = Date.now() + 30_000;
10869
+ timer = setTimeout(refresh, 30_000);
10870
+ return;
10871
+ }
10872
+ setLiveCanvasToken(token);
10873
+ // To the canvas origin only, never '*': a frame the canvas content
10874
+ // navigated elsewhere must not be handed the capability.
10875
+ let target = null;
10876
+ try {
10877
+ target = new URL(cfg.canvasOrigin, location.href).origin;
10878
+ } catch {}
10879
+ if (target) {
10880
+ for (const el of iframesRef.current.values()) {
10881
+ try {
10882
+ el.contentWindow?.postMessage({ dgn: 'canvas-cap', t: token }, target);
10883
+ } catch {}
10884
+ }
10885
+ }
10886
+ schedule(token);
10887
+ }
10888
+ schedule(cfg.canvasToken);
10889
+ // A background tab's timers are throttled; one woken past its due time
10890
+ // re-mints at once rather than on the throttled tick.
10891
+ const onVisible = () => {
10892
+ if (document.visibilityState === 'visible' && Date.now() >= dueAt) refresh();
10893
+ };
10894
+ document.addEventListener('visibilitychange', onVisible);
10895
+ return () => {
10896
+ stopped = true;
10897
+ clearTimeout(timer);
10898
+ document.removeEventListener('visibilitychange', onVisible);
10899
+ };
10900
+ }, [cfg?.canvasToken, cfg?.canvasOrigin]);
10901
+
10809
10902
  // Phase 5.1 — postMessage bridge from menubar dropdowns to the canvas iframe.
10810
10903
  // The canvas-shell listens for these `dgn:*` messages and dispatches into the
10811
10904
  // matching local provider (annotations visibility / both selection stores /
@@ -9,6 +9,43 @@
9
9
  // default DS's tokens — whose ladder is scoped to a different `.<rootClass>`
10
10
  // subtree — so every `var(--*)` went undefined and the canvas rendered white.
11
11
 
12
+ // THE CANVAS CAPABILITY EXPIRES (render-token.mjs: 15 minutes) and cannot be
13
+ // revoked, so it is short on purpose. A tab open longer than that must carry a
14
+ // fresh one or its canvases stop loading and stop updating — every module
15
+ // re-import after a teammate's edit answers 401. The shell re-mints it on a
16
+ // timer (`refreshCanvasToken` in app.jsx) and records it here; a URL built
17
+ // after that carries the fresh capability. `null` until the first refresh:
18
+ // the one in `/_config` is then current.
19
+ let liveCanvasToken = null;
20
+ export function setLiveCanvasToken(token) {
21
+ liveCanvasToken = typeof token === 'string' && token ? token : null;
22
+ }
23
+ export function currentCanvasToken(cfg) {
24
+ return cfg?.canvasToken ? (liveCanvasToken ?? cfg.canvasToken) : null;
25
+ }
26
+
27
+ /**
28
+ * When to re-mint a capability: at 60% of what is left of it, read off the
29
+ * token's own expiry claim (`e`, ms — render-token.mjs), so the cadence
30
+ * follows the hub's lifetime instead of a copy of it. Never sooner than 30 s
31
+ * (a skewed clock cannot turn this into a busy loop) and never later than
32
+ * 10 minutes; an unreadable token gets the ceiling.
33
+ */
34
+ export function canvasTokenRefreshDelay(token, now = Date.now()) {
35
+ const MIN = 30_000;
36
+ const MAX = 10 * 60_000;
37
+ try {
38
+ const body = String(token).slice(0, String(token).lastIndexOf('.'));
39
+ const b64 = body.replace(/-/g, '+').replace(/_/g, '/');
40
+ const claims = JSON.parse(atob(b64 + '==='.slice((b64.length + 3) % 4)));
41
+ const left = Number(claims?.e) - now;
42
+ if (!Number.isFinite(left)) return MAX;
43
+ return Math.min(MAX, Math.max(MIN, Math.floor(left * 0.6)));
44
+ } catch {
45
+ return MAX;
46
+ }
47
+ }
48
+
12
49
  export function urlOf(p) {
13
50
  return '/' + p.split('/').map(encodeURIComponent).join('/');
14
51
  }
@@ -47,7 +84,8 @@ export function canvasUrl(p, cfg, opts) {
47
84
  // origin: a cookie scoped widely enough to cover it would be readable by the
48
85
  // untrusted canvas content itself, which is the one thing the DDR-054 split
49
86
  // exists to prevent — so the capability lives in the URL instead.
50
- if (cfg?.canvasToken) params.set('t', cfg.canvasToken);
87
+ const token = currentCanvasToken(cfg);
88
+ if (token) params.set('t', token);
51
89
  const ds0 = cfg?.designSystems?.[0];
52
90
  // Specimen detection: anything under `system/<ds>/preview/` belongs to that
53
91
  // specific DS, so it must render with *that* DS's tokens — not always the
@@ -2734,6 +2734,7 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
2734
2734
  .sp-note-dot { width: 7px; height: 7px; margin-top: 3px; border-radius: var(--radius-pill); background: var(--status-warn); flex: none; }
2735
2735
  .sp-note-dot.is-synced { background: var(--presence-online); }
2736
2736
  .sp-note-dot.is-refused, .sp-note-dot.is-stalled, .sp-note-dot.is-offline { background: var(--status-error); }
2737
+ .sp-note > .btn { flex: none; white-space: nowrap; }
2737
2738
  .sp-sect-attention { color: var(--status-error); }
2738
2739
  .sp-list { list-style: none; margin: 0; padding: 0; }
2739
2740
  .sp-row { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-1) var(--space-4); font-size: var(--type-sm); color: var(--fg-1); min-width: 0; }