@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.
- package/apps/studio/api.ts +43 -2
- package/apps/studio/canvas-build.ts +49 -8
- package/apps/studio/canvas-edit.ts +77 -0
- package/apps/studio/canvas-list-watch.ts +33 -2
- package/apps/studio/client/app.jsx +97 -4
- package/apps/studio/client/canvas-url.js +39 -1
- package/apps/studio/client/styles/3-shell-maude.css +1 -0
- package/apps/studio/collab/awareness-bridge.ts +65 -14
- package/apps/studio/dist/client.bundle.js +449 -449
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/accepted-guard.ts +36 -0
- package/apps/studio/hmr-broadcast.ts +34 -1
- package/apps/studio/http.ts +11 -0
- package/apps/studio/managed-projects.ts +22 -0
- package/apps/studio/sync/index.ts +106 -12
- package/apps/studio/sync/presentation.ts +30 -0
- package/apps/studio/sync/status.ts +5 -1
- package/apps/studio/sync/transaction-client.ts +26 -0
- package/apps/studio/use-tool-mode.tsx +44 -0
- package/apps/studio/whats-new.json +36 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +48 -7
package/apps/studio/api.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
-
|
|
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 `"` 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 {
|
|
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')
|
|
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={
|
|
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
|
|
9582
|
-
|
|
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
|
-
|
|
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; }
|