@jxsuite/studio 2.4.2 → 3.0.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.
Files changed (82) hide show
  1. package/dist/chunks/{cf-settings-g5fbf2zp.js → cf-settings-zj2cj1ar.js} +6 -3
  2. package/dist/chunks/{content-source-k9724vcv.js → content-source-7fy9qrm0.js} +10 -9
  3. package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-6vvx96n2.js} +9 -8
  4. package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-kwzvpemr.js} +13 -12
  5. package/dist/chunks/{files-yew27sn9.js → files-xcybanxe.js} +14 -13
  6. package/dist/chunks/{format-host-wewba4nq.js → format-host-8fr3545e.js} +2 -2
  7. package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-g01d93vc.js} +9 -8
  8. package/dist/chunks/{layers-panel-qnsghwwd.js → layers-panel-27essgkc.js} +12 -11
  9. package/dist/chunks/{publish-panel-a0pdft5w.js → publish-panel-09sy2bab.js} +8 -7
  10. package/dist/chunks/{quick-search-9avyc4pc.js → quick-search-sefb61v8.js} +15 -14
  11. package/dist/chunks/{right-panel-kcnhtc5j.js → right-panel-9r32jd5j.js} +11 -10
  12. package/dist/chunks/{studio-aweyr4v1.js → studio-2x9zbxmx.js} +2 -2
  13. package/dist/chunks/{studio-ef8t53vc.js → studio-3dkcd4c5.js} +3 -3
  14. package/dist/chunks/{studio-hb7c46jy.js → studio-5efrtn1m.js} +9 -7
  15. package/dist/chunks/{studio-v1va0vhd.js → studio-6z5eyjwn.js} +24 -25
  16. package/dist/chunks/{studio-ke244pam.js → studio-7p35254k.js} +33 -11
  17. package/dist/chunks/{studio-f5yyvqxv.js → studio-8ezdegcx.js} +13 -11
  18. package/dist/chunks/{studio-cz2j1c0g.js → studio-a0yp7syn.js} +55 -18
  19. package/dist/chunks/{studio-26sv44br.js → studio-dc8s8heb.js} +34 -23
  20. package/dist/chunks/{studio-jnakhg87.js → studio-g5gdqnyd.js} +4 -4
  21. package/dist/chunks/{studio-rqemfhz6.js → studio-hcxzyk3x.js} +2 -2
  22. package/dist/chunks/{studio-ws6ef6js.js → studio-hj2se583.js} +12 -10
  23. package/dist/chunks/{studio-pyrw8h0w.js → studio-j595e748.js} +407 -167
  24. package/dist/chunks/{studio-dgj7gq44.js → studio-k4jvjcfx.js} +4 -4
  25. package/dist/chunks/studio-kfpek536.js +28 -0
  26. package/dist/chunks/{studio-sccge2wf.js → studio-p5dja96s.js} +2 -2
  27. package/dist/chunks/{studio-0b6fs05q.js → studio-pkp61mh8.js} +5 -5
  28. package/dist/chunks/studio-qpfq7xhm.js +1802 -0
  29. package/dist/chunks/{studio-ra7ar50f.js → studio-r5xbtctf.js} +51 -1565
  30. package/dist/chunks/{studio-v2c5pmjj.js → studio-rh1cnamv.js} +17 -15
  31. package/dist/chunks/{studio-28xc8ws4.js → studio-ry1vz5sv.js} +10 -8
  32. package/dist/chunks/{studio-hyn3vhwr.js → studio-xntfdy1x.js} +5 -5
  33. package/dist/iframe-entry.js +344 -26
  34. package/dist/studio.js +374 -248
  35. package/package.json +9 -9
  36. package/src/browse/library-layouts.ts +3 -3
  37. package/src/browse/library-pane.ts +2 -2
  38. package/src/browse/library-preview.ts +19 -3
  39. package/src/canvas/asset-refs.ts +119 -0
  40. package/src/canvas/asset-resolve.ts +256 -0
  41. package/src/canvas/canvas-live-render.ts +23 -15
  42. package/src/canvas/iframe-entry.ts +1 -0
  43. package/src/canvas/iframe-host.ts +4 -0
  44. package/src/canvas/iframe-protocol.ts +9 -0
  45. package/src/canvas/iframe-render.ts +28 -10
  46. package/src/canvas/site-style-css.ts +2 -6
  47. package/src/editor/file-drop-action.ts +2 -1
  48. package/src/files/files.ts +2 -2
  49. package/src/files/media-paths.ts +37 -14
  50. package/src/files/media-upload.ts +47 -6
  51. package/src/grid/grid-idle.ts +45 -0
  52. package/src/grid/grid-view.ts +21 -0
  53. package/src/grid/schema-columns.ts +2 -1
  54. package/src/grid/sources/content-source.ts +24 -3
  55. package/src/new-project/import-tab.ts +3 -2
  56. package/src/panels/ai-chat/composer.ts +30 -13
  57. package/src/panels/frontmatter-fields.ts +6 -2
  58. package/src/panels/properties-panel.ts +8 -2
  59. package/src/panels/seo-modal.ts +5 -6
  60. package/src/panels/signals-panel.ts +2 -1
  61. package/src/platforms/cloud.ts +75 -5
  62. package/src/platforms/devserver.ts +5 -1
  63. package/src/services/ai-models.ts +101 -11
  64. package/src/services/ai-settings.ts +69 -71
  65. package/src/services/cf-settings.ts +19 -37
  66. package/src/services/document-assistant.ts +4 -3
  67. package/src/services/idle.ts +6 -1
  68. package/src/services/settings/definitions.ts +164 -0
  69. package/src/services/settings/kernel.ts +335 -0
  70. package/src/services/settings/write-queue.ts +116 -0
  71. package/src/settings/preferences-accounts.ts +6 -19
  72. package/src/settings/preferences-keymap.ts +8 -18
  73. package/src/settings/schema-field-ui.ts +2 -2
  74. package/src/shell.ts +6 -14
  75. package/src/studio.ts +32 -1
  76. package/src/types.ts +61 -3
  77. package/src/ui/ai-credentials-form.ts +52 -27
  78. package/src/ui/media-picker.ts +6 -11
  79. package/src/utils/studio-utils.ts +45 -3
  80. package/dist/chunks/studio-a7pzveq1.js +0 -110
  81. package/src/canvas/content-assets.ts +0 -263
  82. package/src/services/settings-store.ts +0 -99
@@ -13,18 +13,21 @@
13
13
 
14
14
  import {
15
15
  buildScope,
16
+ canvasStyleValue,
16
17
  defineElement,
17
18
  renderNode,
18
19
  runScoped,
20
+ setCanvasAssetResolver,
19
21
  setCanvasDelinkAnchors,
20
22
  setCanvasViewportTranspose,
21
23
  setRootMedia,
22
24
  setSkipAutoRequests,
23
25
  setSkipServerFunctions,
24
26
  setStampPropBindings,
25
- transposeCanvasUnits,
26
27
  } from "@jxsuite/runtime";
28
+ import { resolveAssetRef } from "./asset-resolve";
27
29
  import { classifyRenderNode, serializeJxPath } from "./path-mapping";
30
+ import type { AssetContext } from "./asset-resolve";
28
31
  import { SITE_STYLE_ID, buildSiteStyleCSS } from "./site-style-css";
29
32
  import type { CanvasMode } from "./iframe-protocol";
30
33
  import type { JxDocument } from "@jxsuite/schema/types";
@@ -409,7 +412,9 @@ export function applySiteStyle(
409
412
  existing?.remove();
410
413
  return;
411
414
  }
412
- const css = buildSiteStyleCSS(siteStyle, mediaQueries, transposeCanvasUnits);
415
+ /* `canvasStyleValue`, not `transposeCanvasUnits`: the site style block is CSS the canvas emits
416
+ itself, so a `url()` in it needs the same asset resolution every other declaration gets. */
417
+ const css = buildSiteStyleCSS(siteStyle, mediaQueries, canvasStyleValue);
413
418
  if (existing) {
414
419
  existing.textContent = css;
415
420
  return;
@@ -434,7 +439,7 @@ export function applyPreviewColorScheme(doc: Document, scheme: "light" | "dark"
434
439
  }
435
440
 
436
441
  /** Inject the document's `$head` (link/meta/script) into the iframe's <head>, de-duped by href/src. */
437
- export function injectHead(doc: JxDocument): void {
442
+ export function injectHead(doc: JxDocument, assets: AssetContext | null = null): void {
438
443
  const head = (doc as { $head?: HeadEntry[] }).$head;
439
444
  if (!Array.isArray(head)) {
440
445
  return;
@@ -451,14 +456,16 @@ export function injectHead(doc: JxDocument): void {
451
456
  const attrs = { ...entry.attributes } as Record<string, unknown>;
452
457
  for (const key of ["href", "src"]) {
453
458
  const val = attrs[key];
454
- if (
455
- typeof val === "string" &&
456
- !val.startsWith("/") &&
457
- !val.startsWith(".") &&
458
- !val.startsWith("http")
459
- ) {
459
+ if (typeof val !== "string" || val === "") {
460
+ continue;
461
+ }
462
+ if (!val.startsWith("/") && !val.startsWith(".") && !val.startsWith("http")) {
463
+ /* The bare-specifier lane owns `/node_modules/<pkg>` — the HOST's URL space, not the
464
+ project's. A project resolver has nothing to resolve it to, so it never sees it. */
460
465
  attrs[key] = `/node_modules/${val}`;
466
+ continue;
461
467
  }
468
+ attrs[key] = resolveAssetRef(val, assets) ?? val;
462
469
  }
463
470
  const sel = `${tag}${attrs.href ? `[href="${String(attrs.href)}"]` : ""}${attrs.src ? `[src="${String(attrs.src)}"]` : ""}`;
464
471
  if (sel !== tag && document.head.querySelector(sel)) {
@@ -620,9 +627,20 @@ export async function renderResolvedDocument(opts: {
620
627
  mode: CanvasMode;
621
628
  mapperCtx: PathMapCtx;
622
629
  siteStyle?: Record<string, unknown> | null;
630
+ /**
631
+ * How this host addresses project media, or null when the canvas origin serves the site's own URL
632
+ * space (desktop, `jx dev`) and references need no resolution at all.
633
+ */
634
+ assets?: AssetContext | null;
623
635
  /** This render may fetch automatic `Request` entries even outside preview (Data-panel Refresh). */
624
636
  allowAutoRequests?: boolean;
625
637
  }): Promise<RenderHandle> {
638
+ /* FIRST, before anything emits CSS or an attribute. The resolver is module-global in the runtime,
639
+ so it is set on every render — including to null — or a previous document's context would
640
+ resolve this one's references. It cannot cross the realm as a function, which is why the parent
641
+ posts the plain-data context and the resolver is closed over it here. */
642
+ const assets = opts.assets ?? null;
643
+ setCanvasAssetResolver(assets ? (value) => resolveAssetRef(value, assets) : null);
626
644
  setSkipServerFunctions(opts.mode !== "preview");
627
645
  // Same gate for automatic `$prototype: "Request"` state entries. `buildScope` re-resolves every
628
646
  // State entry on each full render, so without this an escalating authoring action (a signals-panel
@@ -644,7 +662,7 @@ export async function renderResolvedDocument(opts: {
644
662
  // Component internals get stamped).
645
663
  setStampPropBindings(opts.mode === "design" || opts.mode === "edit");
646
664
  applySiteStyle(opts.siteStyle, (opts.doc as { $media?: Record<string, string> }).$media ?? {});
647
- injectHead(opts.doc);
665
+ injectHead(opts.doc, assets);
648
666
  syncEditModeCss(opts.container.ownerDocument, opts.mode);
649
667
  syncPreviewShell(opts.container.ownerDocument, opts.mode);
650
668
  syncStylebookCss(opts.container.ownerDocument, opts.mode);
@@ -9,7 +9,7 @@
9
9
  * honors both the OS preference and the preview toggle.
10
10
  */
11
11
 
12
- import { pureSchemeOf, schemeSelectors, camelToKebab } from "@jxsuite/runtime";
12
+ import { pureSchemeOf, resolveAtQuery, schemeSelectors, camelToKebab } from "@jxsuite/runtime";
13
13
 
14
14
  /** Id of the injected site-style tag (replace-in-place, never accumulate). */
15
15
  export const SITE_STYLE_ID = "jx-site-style";
@@ -70,11 +70,7 @@ export function buildSiteStyleCSS(
70
70
  }
71
71
 
72
72
  for (const [atKey, block] of condBlocks) {
73
- const query = atKey.startsWith("@--")
74
- ? (mediaQueries[atKey.slice(1)] ?? atKey.slice(1))
75
- : atKey.startsWith("@(")
76
- ? atKey.slice(1)
77
- : null;
73
+ const query = resolveAtQuery(atKey, mediaQueries);
78
74
  if (query === null) {
79
75
  continue;
80
76
  }
@@ -27,6 +27,7 @@ import type { DropPreview, FileDropHit } from "../canvas/iframe-protocol";
27
27
  import type { JxPath } from "../state";
28
28
  import type { Tab } from "../tabs/tab";
29
29
  import type { JxMutableNode } from "@jxsuite/schema/types";
30
+ import { isMediaFormat } from "../utils/studio-utils";
30
31
 
31
32
  /** Where an asset's URL goes when it replaces an existing element's picture. */
32
33
  const REPLACE_ATTRS: Record<string, "src" | "poster"> = {
@@ -71,7 +72,7 @@ export function resolveFileDropTarget(
71
72
  }
72
73
  if (hit.tagName.includes("-")) {
73
74
  const entry = registry.find((c) => c.tagName === hit.tagName);
74
- const imageProps = (entry?.props ?? []).filter((p) => p.format === "image");
75
+ const imageProps = (entry?.props ?? []).filter((p) => isMediaFormat(p.format));
75
76
  if (imageProps.length === 1) {
76
77
  return { mode: "replace-prop", path: hit.path as JxPath, prop: imageProps[0]!.name };
77
78
  }
@@ -36,7 +36,7 @@ import { maybePromptJxsuiteUpdate } from "../packages/jxsuite-update";
36
36
  import { autoSyncProjectOnOpen } from "../packages/pull-package-sync";
37
37
  import { markLocalMutation } from "./fs-events";
38
38
  import { registerPanel } from "../panels/panel-registry";
39
- import { UPLOAD_ACCEPT, isImage, uploadAssets } from "./media-upload";
39
+ import { isImage, uploadAccept, uploadAssets } from "./media-upload";
40
40
  import { isCollabPath } from "../collab/collab-state";
41
41
  import {
42
42
  draggable,
@@ -994,7 +994,7 @@ export function pickAndUploadTo(dir: string, renderLeftPanel: () => void): void
994
994
  const input = document.createElement("input");
995
995
  input.type = "file";
996
996
  input.multiple = true;
997
- input.accept = UPLOAD_ACCEPT;
997
+ input.accept = uploadAccept();
998
998
  input.addEventListener("change", () => {
999
999
  if (input.files?.length) {
1000
1000
  void uploadFilesToDir(input.files, dir, renderLeftPanel);
@@ -25,27 +25,22 @@
25
25
  * as written rather than on the file as stored.
26
26
  *
27
27
  * Everything here is pure string math over project-relative, forward-slashed paths. The only input
28
- * from outside is the open project's content sections, read the same way `content-assets.ts` reads
29
- * them.
28
+ * from outside is the open project's content sections, read the same way `canvas/asset-refs.ts`
29
+ * reads them.
30
30
  *
31
31
  * @docs studio/projects/media
32
32
  */
33
33
 
34
- import { contentMountFor } from "../canvas/content-assets";
34
+ import { encodeProjectPath, normalizeProjectPath, PUBLIC_DIR } from "@jxsuite/schema/asset-paths";
35
+ import { contentMountFor, hostAssetDeclarations, projectLocales } from "../canvas/asset-refs";
36
+ import { loopbackAssetSrc } from "../canvas/canvas-origin";
35
37
  import { projectState } from "../store";
36
38
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
37
39
  import type { ContentSectionEntry } from "../types";
38
40
 
39
- /** The directory whose contents are served from the site root. */
40
- const PUBLIC_DIR = "public";
41
-
42
- /**
43
- * A project-relative path in the one spelling every function here expects: forward slashes, no `./`
44
- * prefix, no leading `/`, no trailing `/`.
45
- */
46
- export function normalizeProjectPath(path: string): string {
47
- return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, "");
48
- }
41
+ /* Re-exported rather than redefined: the same normalization is what the compiler, the servers and
42
+ the canvas resolver all key on, so there is one definition of it in `@jxsuite/schema`. */
43
+ export { normalizeProjectPath } from "@jxsuite/schema/asset-paths";
49
44
 
50
45
  /** The filename of a project-relative path. */
51
46
  export function baseName(path: string): string {
@@ -75,7 +70,7 @@ function contentSections(): Record<string, ContentSectionEntry> | null {
75
70
  * rather than duplicating the loop.
76
71
  */
77
72
  function mountOf(path: string): AssetMount | null {
78
- return contentMountFor(path, contentSections());
73
+ return contentMountFor(path, contentSections(), projectLocales());
79
74
  }
80
75
 
81
76
  /**
@@ -139,3 +134,31 @@ export function authoredRefTargets(path: string): string[] {
139
134
  }
140
135
  return targets;
141
136
  }
137
+
138
+ /**
139
+ * The src a PARENT-REALM preview should load for a PROJECT FILE PATH — a library thumbnail, a
140
+ * media-browser tile.
141
+ *
142
+ * The sibling of `previewAssetSrc` in `canvas/asset-refs`, and separate from it because the two
143
+ * take different things and confusing them is silent. A file path is NOT an authored reference:
144
+ * `public/hero.jpg` is written `/hero.jpg`, a string that shares not one segment with it. The
145
+ * library grid built its `<img src>` by prefixing the path with a slash, so every `public/` image
146
+ * asked for `/public/hero.jpg` — a URL the site does not publish — and every content-collection
147
+ * image skipped its mount entirely.
148
+ *
149
+ * @param {string} path - A project-relative file path, as the file tree spells it
150
+ * @returns {string} A src the parent realm can load
151
+ */
152
+ export function previewFileSrc(path: string): string {
153
+ const normalized = normalizeProjectPath(path);
154
+ if (normalized === "") {
155
+ return path;
156
+ }
157
+ /* In repo space nothing answers a site URL, so the site URL is the wrong question entirely: the
158
+ host serves the FILE, at its own path, under the declared base. */
159
+ const { space, fileBaseUrl } = hostAssetDeclarations();
160
+ if (space === "repo") {
161
+ return `${fileBaseUrl}${encodeProjectPath(normalized)}`;
162
+ }
163
+ return loopbackAssetSrc(mediaSiteUrl(normalized));
164
+ }
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { errorMessage } from "@jxsuite/schema/parse";
20
- import { getPlatform } from "../platform";
20
+ import { getPlatform, hasPlatform } from "../platform";
21
21
  import { notify } from "../services/notify";
22
22
  import { activeTab } from "../workspace/workspace";
23
23
 
@@ -50,8 +50,20 @@ export const MEDIA_EXTENSIONS = new Set([
50
50
  ".otf",
51
51
  ]);
52
52
 
53
- /** The `accept` attribute for every upload file input. */
54
- export const UPLOAD_ACCEPT = [
53
+ /** A byte count as a person reads it, for a refusal that names the limit. */
54
+ function formatBytes(bytes: number): string {
55
+ const units = ["B", "KB", "MB", "GB"];
56
+ let value = bytes;
57
+ let unit = 0;
58
+ while (value >= 1024 && unit < units.length - 1) {
59
+ value /= 1024;
60
+ unit += 1;
61
+ }
62
+ return `${unit === 0 ? value : Number(value.toFixed(1))} ${units[unit]}`;
63
+ }
64
+
65
+ /** What Studio itself offers, before any backend narrows it. */
66
+ const DEFAULT_ACCEPT = [
55
67
  "image/*",
56
68
  "video/*",
57
69
  "audio/*",
@@ -63,6 +75,20 @@ export const UPLOAD_ACCEPT = [
63
75
  ".otf",
64
76
  ].join(",");
65
77
 
78
+ /**
79
+ * The `accept` attribute for every upload file input.
80
+ *
81
+ * A backend may NARROW it by declaring `assetCapabilities.accept`; nothing widens it, because the
82
+ * picker offering a type Studio cannot place in a document helps no one. Read as a function, not a
83
+ * constant, because the platform is registered after this module is imported.
84
+ *
85
+ * @returns {string} An `<input accept>` value
86
+ */
87
+ export function uploadAccept(): string {
88
+ const declared = hasPlatform() ? getPlatform().assetCapabilities?.accept : undefined;
89
+ return declared ?? DEFAULT_ACCEPT;
90
+ }
91
+
66
92
  /** What kind of element an uploaded asset wants to become. */
67
93
  export type AssetKind = "image" | "video" | "audio" | "file";
68
94
 
@@ -225,18 +251,33 @@ export async function uploadAssets(
225
251
  taken = new Set(); // Directory doesn't exist yet — the backend mkdir -p's on write.
226
252
  }
227
253
 
254
+ /* A DECLARED limit, or none. Studio must not invent one: a limit it made up is a file the user
255
+ cannot upload for no reason anyone can name. A declared one is different — refusing here saves
256
+ the round trip and lets the refusal say the number. */
257
+ const maxBytes = hasPlatform() ? getPlatform().assetCapabilities?.maxUploadBytes : undefined;
258
+
228
259
  const uploaded: UploadedAsset[] = [];
229
260
  for (const file of list) {
261
+ if (maxBytes !== undefined && file.size > maxBytes) {
262
+ notify.error(`${file.name} is too large.`, {
263
+ detail: `This backend accepts at most ${formatBytes(maxBytes)}; that file is ${formatBytes(file.size)}.`,
264
+ source: "Media",
265
+ });
266
+ continue;
267
+ }
230
268
  const name = uniqueName(file.name, taken);
231
269
  taken.add(name);
232
- const path = joinDir(dir, name);
270
+ const requested = joinDir(dir, name);
233
271
  try {
234
- await platform.uploadFile(path, file);
272
+ /* The path the backend REPORTS, not the one asked for. A store that de-duplicates by content
273
+ hash, appends a collision suffix, or normalizes a name writes somewhere else, and the
274
+ reference that goes into the document has to name what is really there. */
275
+ const { path } = await platform.uploadFile(requested, file);
235
276
  uploaded.push({ kind: mediaKind(file), name, path, ref: assetRef(path) });
236
277
  } catch (error) {
237
278
  notify.error(`Could not upload ${file.name}.`, {
238
279
  detail: errorMessage(error),
239
- path,
280
+ path: requested,
240
281
  source: "Media",
241
282
  });
242
283
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Grid-idle.ts — the grid's quiescence registry, and nothing else.
3
+ *
4
+ * A separate module from `grid-view.ts` for one reason: `services/idle.ts` must be able to ASK
5
+ * whether any grid is mid-flight without pulling Tabulator into its import graph. It read the
6
+ * probes from `grid-view.ts` first, and `grid-view.ts` imports `tabulator-tables` at module load —
7
+ * so every test that transitively reached `idle.ts` (which is most of them, through the shell)
8
+ * tried to evaluate the Tabulator ESM bundle and died with `Export named 'ClipboardModule' not
9
+ * found` unless it had thought to mock a module it never uses.
10
+ *
11
+ * So the dependency points the other way: this file imports nothing, `idle.ts` reads it, and
12
+ * `grid-view.ts` writes to it.
13
+ */
14
+
15
+ /** Answers `null` when its grid is settled, or a human-readable reason when it is not. */
16
+ export type GridProbe = () => string | null;
17
+
18
+ const liveGrids = new Set<GridProbe>();
19
+
20
+ /** Register a live grid. The returned function deregisters it — call it from `destroy()`. */
21
+ export function registerGridProbe(probe: GridProbe): () => void {
22
+ liveGrids.add(probe);
23
+ return () => liveGrids.delete(probe);
24
+ }
25
+
26
+ /**
27
+ * Grids still building, or still laying out their selection range.
28
+ *
29
+ * A probe must never throw: it runs inside the idle predicate, and an exception there would be
30
+ * indistinguishable from a subsystem that never settles.
31
+ */
32
+ export function gridIdleBlockers(): readonly string[] {
33
+ const blockers: string[] = [];
34
+ for (const probe of liveGrids) {
35
+ try {
36
+ const reason = probe();
37
+ if (reason) {
38
+ blockers.push(reason);
39
+ }
40
+ } catch {
41
+ // A table torn down between registration and probe is settled by definition.
42
+ }
43
+ }
44
+ return blockers;
45
+ }
@@ -40,6 +40,7 @@ import type {
40
40
  } from "tabulator-tables";
41
41
  import type { GridCellValue, GridColumn } from "./grid-source";
42
42
  import type { GridController } from "./grid-controller";
43
+ import { registerGridProbe } from "./grid-idle";
43
44
 
44
45
  export interface GridView {
45
46
  /** Re-pull controller.effectiveRows() into the table (buffer → table sync). */
@@ -222,6 +223,23 @@ export function createGridView(host: HTMLElement, controller: GridController): G
222
223
  }
223
224
  });
224
225
 
226
+ /*
227
+ * Settled means BUILT AND LAID OUT, not merely constructed. `selectableRange: 1` gives a
228
+ * non-empty table an initial range, and that range's outline is a rendered element — so until it
229
+ * exists the picture is still changing. An empty table is exempt, because it never gets one and
230
+ * waiting for it would be waiting forever.
231
+ */
232
+ const probe = (): string | null => {
233
+ if (!built) {
234
+ return `grid[${gridId}]: building`;
235
+ }
236
+ if (controller.effectiveRows().length > 0 && table.getRanges().length === 0) {
237
+ return `grid[${gridId}]: selection range not laid out`;
238
+ }
239
+ return null;
240
+ };
241
+ const unregisterProbe = registerGridProbe(probe);
242
+
225
243
  table.on("cellEdited", (cell: CellComponent) => {
226
244
  if (suppress) {
227
245
  return;
@@ -307,6 +325,9 @@ export function createGridView(host: HTMLElement, controller: GridController): G
307
325
 
308
326
  return {
309
327
  destroy() {
328
+ // Before `table.destroy()`: a probe left registered against a torn-down table would be asking
329
+ // Tabulator questions it can no longer answer.
330
+ unregisterProbe();
310
331
  controller.bindView(null);
311
332
  table.destroy();
312
333
  },
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { GridCellValue, GridColumn, GridColumnKind } from "./grid-source";
12
12
  import type { JsonSchema } from "../ui/schema-form";
13
+ import { isMediaFormat } from "../utils/studio-utils";
13
14
 
14
15
  /** JSON-Schema property as it appears in content-type schemas (superset of ui JsonSchema). */
15
16
  interface PropSchema extends JsonSchema {
@@ -44,7 +45,7 @@ export function kindForProp(prop: PropSchema): GridColumnKind {
44
45
  return "enum";
45
46
  }
46
47
  const format = prop.type === "array" ? undefined : prop.format;
47
- if (format === "image") {
48
+ if (isMediaFormat(format)) {
48
49
  return "image";
49
50
  }
50
51
  if (format === "date" || format === "date-time") {
@@ -269,20 +269,41 @@ function buildEntryFileSource(opts: EntryFileSourceOptions): GridSource {
269
269
  const files = await opts.list();
270
270
  entries.clear();
271
271
  columnsCache = null;
272
- await mapLimit(files, 8, async (path) => {
272
+ /*
273
+ * Read concurrently, INSERT IN ORDER.
274
+ *
275
+ * `entries` is a Map and the grid renders it in insertion order, so setting it from inside
276
+ * the worker made row order the order the reads happened to FINISH in — which is a race
277
+ * between two `platform.readFile` calls, not a property of the collection. The two posts in
278
+ * the `first-collection` fixture swapped places between runs, and that alone rewrote
279
+ * `blog-grid.png` in 3 of the 21 images the screenshot lane pushed over 24 commits.
280
+ *
281
+ * `mapLimit` already documents that it preserves item order; the fix is to USE its result
282
+ * rather than to mutate shared state from inside it. The concurrency is worth keeping — a
283
+ * collection is a directory of files and reading it eight at a time is why the grid opens
284
+ * quickly.
285
+ */
286
+ const loaded = await mapLimit(files, 8, async (path): Promise<EntryRecord | null> => {
273
287
  try {
274
288
  const text = await platform.readFile(path);
275
289
  const parsed = await parseSourceForPath(path, text);
276
- entries.set(path, {
290
+ return {
277
291
  document: parsed.document,
278
292
  formatName: parsed.format.name,
279
293
  frontmatter: parsed.frontmatter ?? {},
280
294
  rawText: text,
281
- });
295
+ };
282
296
  } catch {
283
297
  // Unparseable entry — leave it out rather than failing the whole grid.
298
+ return null;
284
299
  }
285
300
  });
301
+ for (const [index, path] of files.entries()) {
302
+ const record = loaded[index];
303
+ if (record) {
304
+ entries.set(path, record);
305
+ }
306
+ }
286
307
  })();
287
308
  return loadPromise;
288
309
  };
@@ -8,7 +8,8 @@
8
8
 
9
9
  import { html } from "lit-html";
10
10
  import { getPlatform } from "../platform";
11
- import { getBaseUrl, getModel, getOpenAiKey } from "../services/ai-settings";
11
+ import { getBaseUrl, getOpenAiKey } from "../services/ai-settings";
12
+ import { preferredModel } from "../services/ai-models";
12
13
  import { IMPORT_WARNING_PHASE } from "../services/import-client";
13
14
  import { notify } from "../services/notify";
14
15
  import { errorMessage } from "@jxsuite/schema/parse";
@@ -148,7 +149,7 @@ export async function startImport(ctx: ImportTabCtx) {
148
149
  try {
149
150
  const key = getOpenAiKey();
150
151
  const baseUrl = getBaseUrl();
151
- const model = getModel();
152
+ const model = preferredModel();
152
153
  const result = await platform.importSite(
153
154
  {
154
155
  url: parsed.href,
@@ -20,13 +20,18 @@ import type { TemplateResult } from "lit-html";
20
20
  import { live } from "lit-html/directives/live.js";
21
21
  import { ref } from "lit-html/directives/ref.js";
22
22
  import { getNodeAtPath } from "../../state";
23
- import { fetchAvailableModels } from "../../services/ai-models";
24
- import { getModel, setModel } from "../../services/ai-settings";
23
+ import {
24
+ aiConnection,
25
+ cachedModels,
26
+ fetchAvailableModels,
27
+ preferredModel,
28
+ } from "../../services/ai-models";
29
+ import { setModel } from "../../services/ai-settings";
25
30
  import { activeTab } from "../../workspace/workspace";
26
31
  import { primarySelection } from "../../tabs/selection";
27
32
  import { buildMessageWithContext } from "./attached-context";
28
33
  import type { ContextChip } from "./attached-context";
29
- import type { AiModel } from "../../services/ai-models";
34
+ import type { AiCredentials } from "../../services/ai-models";
30
35
  import type { JxMutableNode } from "@jxsuite/schema/types";
31
36
 
32
37
  /** Tallest the textarea auto-grows before it scrolls internally. */
@@ -72,24 +77,36 @@ export function createComposer(opts: ComposerOptions): Composer {
72
77
  let chips: ContextChip[] = [];
73
78
 
74
79
  /** Null until the first fetch settles; kept on error so the picker can offer Retry. */
75
- let models: AiModel[] | null = null;
76
80
  let modelsLoading = false;
77
81
  let modelsError = "";
78
82
 
83
+ /**
84
+ * The connection the last fetch was made for, so a failure is not retried on every render.
85
+ *
86
+ * The list itself is NOT held here. A private copy is how the picker came to show one provider's
87
+ * catalogue while another was configured: it was filled once and never reconsidered. Reading it
88
+ * from `cachedModels(connection)` means a list can only ever be shown for the credentials it was
89
+ * listed under, and a credential change makes it unavailable rather than stale.
90
+ */
91
+ let attempted: AiCredentials | null = null;
92
+
79
93
  // ── Model picker ──────────────────────────────────────────────────────
80
94
 
95
+ function sameConnection(a: AiCredentials | null, b: AiCredentials): boolean {
96
+ return a !== null && a.apiKey === b.apiKey && a.baseUrl === b.baseUrl;
97
+ }
98
+
81
99
  function ensureModels(force = false) {
82
- if (modelsLoading || (models !== null && !force)) {
100
+ const credentials = aiConnection();
101
+ const settled = cachedModels(credentials) !== null || sameConnection(attempted, credentials);
102
+ if (modelsLoading || (settled && !force)) {
83
103
  return;
84
104
  }
85
105
  modelsLoading = true;
86
106
  modelsError = "";
87
- fetchAvailableModels({ force })
88
- .then((list) => {
89
- models = list;
90
- })
107
+ attempted = credentials;
108
+ fetchAvailableModels({ credentials, force })
91
109
  .catch((error: unknown) => {
92
- models = [];
93
110
  modelsError = (error as Error).message || "Failed to fetch models";
94
111
  })
95
112
  .finally(() => {
@@ -101,7 +118,7 @@ export function createComposer(opts: ComposerOptions): Composer {
101
118
  function onModelChange(e: Event) {
102
119
  const { value } = e.target as HTMLInputElement;
103
120
  if (value === RETRY_MODELS) {
104
- models = null;
121
+ attempted = null;
105
122
  ensureModels(true);
106
123
  // Re-render so the picker snaps back to the stored model instead of "Retry".
107
124
  opts.requestRender();
@@ -114,8 +131,8 @@ export function createComposer(opts: ComposerOptions): Composer {
114
131
 
115
132
  function renderModelPicker(): TemplateResult {
116
133
  ensureModels();
117
- const current = getModel();
118
- const listed = models ?? [];
134
+ const current = preferredModel();
135
+ const listed = cachedModels(aiConnection()) ?? [];
119
136
  const items = listed.some((m) => m.id === current)
120
137
  ? listed
121
138
  : [{ id: current, name: current }, ...listed];
@@ -14,7 +14,7 @@ import { renderFieldRow } from "../ui/field-row";
14
14
  import { spNumberField, spTextField } from "../ui/field-input";
15
15
  import { renderMediaPicker } from "../ui/media-picker";
16
16
  import { mutateUpdateFrontmatter, transactDoc } from "../tabs/transact";
17
- import { findContentTypeSchema } from "../utils/studio-utils";
17
+ import { findContentTypeSchema, isMediaFormat } from "../utils/studio-utils";
18
18
  import { NULL_FORM_CONTEXT, getFormControl, referenceTarget } from "../ui/schema-form";
19
19
  import type { JsonSchema } from "../ui/schema-form";
20
20
 
@@ -233,7 +233,11 @@ export function renderFmField(
233
233
  });
234
234
  }
235
235
 
236
- if (entry.format === "image") {
236
+ /* Both spellings. `"uri-reference"` is the one the SPEC uses and the one the content loader keys
237
+ its asset rewrite on (`rewriteEntryAssets`), so a schema written against the spec got a plain
238
+ text box here while the same field got a media picker in the properties panel — which reads
239
+ `inferInputType`, and that has always accepted it. */
240
+ if (isMediaFormat(entry.format)) {
237
241
  return renderFieldRow({
238
242
  hasValue: hasVal,
239
243
  label: displayLabel,
@@ -49,7 +49,13 @@ import { renderFieldRow, renderKvRow } from "../ui/field-row";
49
49
  import type { FieldProvenance } from "./provenance";
50
50
  import { renderDynamicSlot, slotMode } from "../ui/dynamic-slot";
51
51
  import { spTextArea, spTextField } from "../ui/field-input";
52
- import { attrLabel, camelToLabel, inferInputType, parseCemType } from "../utils/studio-utils";
52
+ import {
53
+ attrLabel,
54
+ camelToLabel,
55
+ inferInputType,
56
+ isMediaFormat,
57
+ parseCemType,
58
+ } from "../utils/studio-utils";
53
59
  import { classifyHref, composeHref } from "../utils/link-target";
54
60
  import type { LinkKind } from "../utils/link-target";
55
61
  import {
@@ -274,7 +280,7 @@ function renderComponentPropsFieldsTemplate(
274
280
  /** @type {ReturnType<typeof setTimeout> | undefined} */
275
281
  let debounce: ReturnType<typeof setTimeout> | undefined;
276
282
  let widgetTpl;
277
- if (prop.format === "image") {
283
+ if (isMediaFormat(prop.format)) {
278
284
  widgetTpl = renderMediaPicker(prop.name, staticVal, onChange);
279
285
  } else if (prop.format === "color") {
280
286
  widgetTpl = renderColorSelector(prop.name, staticVal, onChange);
@@ -24,8 +24,7 @@
24
24
 
25
25
  import { html, nothing } from "lit-html";
26
26
  import { activeRegistry } from "../commands/active-registry";
27
- import { loopbackAssetSrc } from "../canvas/canvas-origin";
28
- import { previewAssetSrc } from "../canvas/content-assets";
27
+ import { previewAssetSrc } from "../canvas/asset-refs";
29
28
  import { renderFieldRow } from "../ui/field-row";
30
29
  import { renderMediaPicker } from "../ui/media-picker";
31
30
  import { renderProvenanceChip } from "./provenance";
@@ -140,9 +139,9 @@ function serpCard(preview: SeoPreview): TemplateResult {
140
139
  /**
141
140
  * The mock social card.
142
141
  *
143
- * The image is resolved the same way every other image in the studio chrome is —
144
- * `loopbackAssetSrc(previewAssetSrc(…))` — so a content-relative `./images/hero.jpg` previews at
145
- * its asset-mount URL while the authored ref stays exactly as written.
142
+ * The image is resolved the same way every other image in the studio chrome is — `previewAssetSrc`
143
+ * — so a content-relative `./images/hero.jpg` previews at its asset-mount URL while the authored
144
+ * ref stays exactly as written.
146
145
  */
147
146
  function socialCard(preview: SeoPreview): TemplateResult {
148
147
  const image = seoField(preview, "og:image").value.trim();
@@ -152,7 +151,7 @@ function socialCard(preview: SeoPreview): TemplateResult {
152
151
  <div class="seo-social-media">
153
152
  ${
154
153
  image
155
- ? html`<img src=${loopbackAssetSrc(previewAssetSrc(image))} alt="" />`
154
+ ? html`<img src=${previewAssetSrc(image)} alt="" />`
156
155
  : html`<span class="seo-unset">No image</span>`
157
156
  }
158
157
  </div>