@elytracms/studio 0.0.85 → 0.0.87

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 (248) hide show
  1. package/convex/asset-revalidate.test.ts +186 -0
  2. package/convex/assets.ts +53 -19
  3. package/convex/cms.ts +22 -0
  4. package/convex/delivery.ts +54 -116
  5. package/convex/engine-version.test.ts +31 -0
  6. package/convex/functions.ts +3 -3
  7. package/convex/rail-write-parity.test.ts +180 -0
  8. package/convex/redirects.ts +33 -11
  9. package/convex/revalidate.ts +7 -5
  10. package/convex/triggers.ts +53 -3
  11. package/dist/components/ai/ai-chat-panel.js +13 -2
  12. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  13. package/dist/components/app-views/default-modules.d.ts +20 -20
  14. package/dist/components/app-views/default-modules.js +27 -48
  15. package/dist/components/app-views/default-modules.js.map +1 -1
  16. package/dist/components/app-views/studio-config.d.ts +5 -6
  17. package/dist/components/app-views/studio-config.js.map +1 -1
  18. package/dist/components/assets/asset-metadata.d.ts +28 -19
  19. package/dist/components/assets/asset-metadata.js +24 -31
  20. package/dist/components/assets/asset-metadata.js.map +1 -1
  21. package/dist/components/assets/asset-selection.d.ts +24 -5
  22. package/dist/components/assets/asset-selection.js +39 -9
  23. package/dist/components/assets/asset-selection.js.map +1 -1
  24. package/dist/components/assets/assets-collection-view-source.d.ts +17 -14
  25. package/dist/components/assets/assets-collection-view-source.js +25 -31
  26. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  27. package/dist/components/builder/blocks-model.d.ts +5 -4
  28. package/dist/components/builder/blocks-model.js +5 -4
  29. package/dist/components/builder/blocks-model.js.map +1 -1
  30. package/dist/components/builder/blocks-panel.js +3 -3
  31. package/dist/components/builder/blocks-panel.js.map +1 -1
  32. package/dist/components/builder/builder-store.d.ts +25 -1
  33. package/dist/components/builder/builder-store.js +3 -2
  34. package/dist/components/builder/builder-store.js.map +1 -1
  35. package/dist/components/builder/canvas-chrome-css.js +0 -17
  36. package/dist/components/builder/canvas-chrome-css.js.map +1 -1
  37. package/dist/components/builder/canvas-chrome.d.ts +10 -12
  38. package/dist/components/builder/canvas-chrome.js +12 -13
  39. package/dist/components/builder/canvas-chrome.js.map +1 -1
  40. package/dist/components/builder/canvas-dnd.d.ts +8 -0
  41. package/dist/components/builder/canvas-dnd.js +5 -3
  42. package/dist/components/builder/canvas-dnd.js.map +1 -1
  43. package/dist/components/builder/canvas-render-context.d.ts +12 -24
  44. package/dist/components/builder/canvas-render-context.js +15 -44
  45. package/dist/components/builder/canvas-render-context.js.map +1 -1
  46. package/dist/components/builder/canvas.js +5 -11
  47. package/dist/components/builder/canvas.js.map +1 -1
  48. package/dist/components/builder/clipboard-actions.d.ts +15 -1
  49. package/dist/components/builder/clipboard-actions.js +35 -12
  50. package/dist/components/builder/clipboard-actions.js.map +1 -1
  51. package/dist/components/builder/composition-field-editor.d.ts +32 -26
  52. package/dist/components/builder/composition-field-editor.js +47 -54
  53. package/dist/components/builder/composition-field-editor.js.map +1 -1
  54. package/dist/components/builder/insert-context.d.ts +3 -1
  55. package/dist/components/builder/insert-context.js +19 -12
  56. package/dist/components/builder/insert-context.js.map +1 -1
  57. package/dist/components/builder/page-tree.d.ts +29 -0
  58. package/dist/components/builder/page-tree.js +56 -58
  59. package/dist/components/builder/page-tree.js.map +1 -1
  60. package/dist/components/builder/reuse.js +5 -2
  61. package/dist/components/builder/reuse.js.map +1 -1
  62. package/dist/components/cms/asset-field.js +20 -32
  63. package/dist/components/cms/asset-field.js.map +1 -1
  64. package/dist/components/cms/cell-edit.d.ts +7 -0
  65. package/dist/components/cms/cell-edit.js +9 -1
  66. package/dist/components/cms/cell-edit.js.map +1 -1
  67. package/dist/components/cms/cms-composition-validators.js +11 -2
  68. package/dist/components/cms/cms-composition-validators.js.map +1 -1
  69. package/dist/components/cms/collection-table.js +11 -0
  70. package/dist/components/cms/collection-table.js.map +1 -1
  71. package/dist/components/cms/document-url-path.d.ts +7 -4
  72. package/dist/components/cms/document-url-path.js +15 -1
  73. package/dist/components/cms/document-url-path.js.map +1 -1
  74. package/dist/components/cms/editable-cell.js +5 -2
  75. package/dist/components/cms/editable-cell.js.map +1 -1
  76. package/dist/components/cms/field-control.js +4 -0
  77. package/dist/components/cms/field-control.js.map +1 -1
  78. package/dist/components/cms/inline-field-errors.d.ts +36 -0
  79. package/dist/components/cms/inline-field-errors.js +13 -5
  80. package/dist/components/cms/inline-field-errors.js.map +1 -1
  81. package/dist/components/cms/rich-text/insert.d.ts +6 -3
  82. package/dist/components/cms/rich-text/insert.js +9 -6
  83. package/dist/components/cms/rich-text/insert.js.map +1 -1
  84. package/dist/components/cms/use-document-versions.js +1 -0
  85. package/dist/components/cms/use-document-versions.js.map +1 -1
  86. package/dist/components/cms/use-live-url.d.ts +18 -6
  87. package/dist/components/cms/use-live-url.js +10 -0
  88. package/dist/components/cms/use-live-url.js.map +1 -1
  89. package/dist/components/cms/versions-model.d.ts +0 -12
  90. package/dist/components/cms/versions-model.js +3 -15
  91. package/dist/components/cms/versions-model.js.map +1 -1
  92. package/dist/components/components/component-detail.js +1 -1
  93. package/dist/components/components/component-detail.js.map +1 -1
  94. package/dist/components/components/transfer-dialog.js +2 -4
  95. package/dist/components/components/transfer-dialog.js.map +1 -1
  96. package/dist/components/components/transfer.d.ts +19 -15
  97. package/dist/components/components/transfer.js +28 -25
  98. package/dist/components/components/transfer.js.map +1 -1
  99. package/dist/components/deployment-setup-gate.js +9 -3
  100. package/dist/components/deployment-setup-gate.js.map +1 -1
  101. package/dist/components/project-session.d.ts +9 -0
  102. package/dist/components/project-session.js +1 -1
  103. package/dist/components/project-session.js.map +1 -1
  104. package/dist/components/redirects/redirects-collection-view-source.d.ts +17 -2
  105. package/dist/components/redirects/redirects-collection-view-source.js +20 -6
  106. package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
  107. package/dist/components/section-surface.js +3 -2
  108. package/dist/components/section-surface.js.map +1 -1
  109. package/dist/components/settings-form.js +1 -2
  110. package/dist/components/settings-form.js.map +1 -1
  111. package/dist/components/studio-context.js +1 -18
  112. package/dist/components/studio-context.js.map +1 -1
  113. package/dist/components/studio-project-screen.js +3 -5
  114. package/dist/components/studio-project-screen.js.map +1 -1
  115. package/dist/lib/ai/apply-content.js +73 -29
  116. package/dist/lib/ai/apply-content.js.map +1 -1
  117. package/dist/lib/ai/constraints.d.ts +35 -0
  118. package/dist/lib/ai/constraints.js +155 -72
  119. package/dist/lib/ai/constraints.js.map +1 -1
  120. package/dist/lib/ai/index.d.ts +1 -0
  121. package/dist/lib/ai/index.js +1 -0
  122. package/dist/lib/ai/index.js.map +1 -1
  123. package/dist/lib/ai/route-fetch.d.ts +58 -0
  124. package/dist/lib/ai/route-fetch.js +98 -0
  125. package/dist/lib/ai/route-fetch.js.map +1 -0
  126. package/dist/lib/ai/route-provider.d.ts +5 -22
  127. package/dist/lib/ai/route-provider.js +23 -1
  128. package/dist/lib/ai/route-provider.js.map +1 -1
  129. package/dist/lib/ai/route.js +4 -1
  130. package/dist/lib/ai/route.js.map +1 -1
  131. package/dist/lib/ai/shape.d.ts +3 -2
  132. package/dist/lib/ai/shape.js +16 -24
  133. package/dist/lib/ai/shape.js.map +1 -1
  134. package/dist/lib/ai/types.d.ts +8 -0
  135. package/dist/lib/ai/types.js.map +1 -1
  136. package/dist/lib/ai/update-fields.js +33 -12
  137. package/dist/lib/ai/update-fields.js.map +1 -1
  138. package/dist/lib/assets/asset-placements.d.ts +35 -0
  139. package/dist/lib/assets/asset-placements.js +74 -0
  140. package/dist/lib/assets/asset-placements.js.map +1 -0
  141. package/dist/lib/assets/backfill-thumbs.d.ts +9 -8
  142. package/dist/lib/assets/backfill-thumbs.js +13 -25
  143. package/dist/lib/assets/backfill-thumbs.js.map +1 -1
  144. package/dist/lib/clipboard/paste-issues.d.ts +17 -15
  145. package/dist/lib/clipboard/paste-issues.js +8 -45
  146. package/dist/lib/clipboard/paste-issues.js.map +1 -1
  147. package/dist/lib/clipboard/paste-target.d.ts +47 -1
  148. package/dist/lib/clipboard/paste-target.js +19 -11
  149. package/dist/lib/clipboard/paste-target.js.map +1 -1
  150. package/dist/lib/cms/document-gate.d.ts +19 -9
  151. package/dist/lib/cms/document-gate.js +25 -4
  152. package/dist/lib/cms/document-gate.js.map +1 -1
  153. package/dist/lib/cms/published-documents.js +1 -0
  154. package/dist/lib/cms/published-documents.js.map +1 -1
  155. package/dist/lib/cms-views/view-model.d.ts +3 -3
  156. package/dist/lib/data/index.d.ts +0 -3
  157. package/dist/lib/data/index.js +4 -6
  158. package/dist/lib/data/index.js.map +1 -1
  159. package/dist/lib/dnd/drop-rules.d.ts +45 -2
  160. package/dist/lib/dnd/drop-rules.js +64 -9
  161. package/dist/lib/dnd/drop-rules.js.map +1 -1
  162. package/dist/lib/engine-version.d.ts +16 -0
  163. package/dist/lib/engine-version.js +17 -0
  164. package/dist/lib/engine-version.js.map +1 -0
  165. package/dist/lib/i18n/messages.de.js +8 -56
  166. package/dist/lib/i18n/messages.de.js.map +1 -1
  167. package/dist/lib/i18n/messages.en.d.ts +8 -53
  168. package/dist/lib/i18n/messages.en.js +15 -57
  169. package/dist/lib/i18n/messages.en.js.map +1 -1
  170. package/dist/lib/locale/fallback.js +8 -2
  171. package/dist/lib/locale/fallback.js.map +1 -1
  172. package/dist/lib/locale/index.d.ts +0 -1
  173. package/dist/lib/locale/index.js +0 -1
  174. package/dist/lib/locale/index.js.map +1 -1
  175. package/dist/lib/locale/locale-store.d.ts +10 -3
  176. package/dist/lib/locale/locale-store.js +12 -5
  177. package/dist/lib/locale/locale-store.js.map +1 -1
  178. package/dist/lib/operations/session-hydration.d.ts +9 -5
  179. package/dist/lib/operations/session-hydration.js +10 -10
  180. package/dist/lib/operations/session-hydration.js.map +1 -1
  181. package/dist/lib/operations/studio-operations.js +4 -2
  182. package/dist/lib/operations/studio-operations.js.map +1 -1
  183. package/dist/lib/persistence/convex-adapter.js +63 -75
  184. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  185. package/dist/lib/persistence/deployment-readiness.d.ts +8 -1
  186. package/dist/lib/persistence/deployment-readiness.js +4 -0
  187. package/dist/lib/persistence/deployment-readiness.js.map +1 -1
  188. package/dist/lib/persona/labels.d.ts +0 -2
  189. package/dist/lib/persona/labels.js +1 -7
  190. package/dist/lib/persona/labels.js.map +1 -1
  191. package/dist/lib/registry/index.d.ts +4 -2
  192. package/dist/lib/registry/index.js +26 -21
  193. package/dist/lib/registry/index.js.map +1 -1
  194. package/dist/lib/registry/project-components.js +0 -3
  195. package/dist/lib/registry/project-components.js.map +1 -1
  196. package/dist/lib/routes/route-issues.js +4 -5
  197. package/dist/lib/routes/route-issues.js.map +1 -1
  198. package/dist/lib/theme.js +3 -2
  199. package/dist/lib/theme.js.map +1 -1
  200. package/dist/lib/webhooks/tags.js +11 -14
  201. package/dist/lib/webhooks/tags.js.map +1 -1
  202. package/dist/lib/workspace/index.d.ts +1 -1
  203. package/dist/lib/workspace/index.js +1 -1
  204. package/dist/lib/workspace/index.js.map +1 -1
  205. package/dist/previews/enumerate.d.ts +27 -9
  206. package/dist/previews/enumerate.js +27 -6
  207. package/dist/previews/enumerate.js.map +1 -1
  208. package/dist/previews/manifest-format.d.ts +16 -7
  209. package/dist/previews/manifest-format.js +0 -21
  210. package/dist/previews/manifest-format.js.map +1 -1
  211. package/dist/router/match.d.ts +0 -2
  212. package/dist/router/match.js +0 -2
  213. package/dist/router/match.js.map +1 -1
  214. package/dist/ui/badge.d.ts +1 -1
  215. package/package.json +2 -2
  216. package/src/styles/studio.css +5 -4
  217. package/src/styles/tokens.css +72 -55
  218. package/convex/content.ts +0 -291
  219. package/dist/components/collab/collab-context.d.ts +0 -27
  220. package/dist/components/collab/collab-context.js +0 -39
  221. package/dist/components/collab/collab-context.js.map +0 -1
  222. package/dist/components/collab/collab-seed.d.ts +0 -15
  223. package/dist/components/collab/collab-seed.js +0 -20
  224. package/dist/components/collab/collab-seed.js.map +0 -1
  225. package/dist/components/extensions/extensions-view.d.ts +0 -7
  226. package/dist/components/extensions/extensions-view.js +0 -127
  227. package/dist/components/extensions/extensions-view.js.map +0 -1
  228. package/dist/lib/bindings/section-bindings.d.ts +0 -24
  229. package/dist/lib/bindings/section-bindings.js +0 -9
  230. package/dist/lib/bindings/section-bindings.js.map +0 -1
  231. package/dist/lib/data/data-store.d.ts +0 -98
  232. package/dist/lib/data/data-store.js +0 -211
  233. package/dist/lib/data/data-store.js.map +0 -1
  234. package/dist/lib/data/providers.d.ts +0 -143
  235. package/dist/lib/data/providers.js +0 -255
  236. package/dist/lib/data/providers.js.map +0 -1
  237. package/dist/lib/data/sample-capture.d.ts +0 -30
  238. package/dist/lib/data/sample-capture.js +0 -70
  239. package/dist/lib/data/sample-capture.js.map +0 -1
  240. package/dist/lib/data/sources-store.d.ts +0 -42
  241. package/dist/lib/data/sources-store.js +0 -120
  242. package/dist/lib/data/sources-store.js.map +0 -1
  243. package/dist/lib/locale/binding-fallback.d.ts +0 -35
  244. package/dist/lib/locale/binding-fallback.js +0 -73
  245. package/dist/lib/locale/binding-fallback.js.map +0 -1
  246. package/dist/lib/starters/kit-session.d.ts +0 -5
  247. package/dist/lib/starters/kit-session.js +0 -17
  248. package/dist/lib/starters/kit-session.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"studio-config.js","sourceRoot":"","sources":["../../../src/components/app-views/studio-config.ts"],"names":[],"mappings":"AA+JA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,UAAU,YAAY,CAAC,MAAoB;IAC/C,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["import type { ReactNode } from 'react'\nimport type { FieldDef } from '@elytracms/core/cms-core'\nimport type { StudioI18nConfig } from '@elytracms/studio/lib/i18n'\nimport type { StudioAiConfig } from '@elytracms/studio/lib/ai'\nimport type { GeneratedPreviewLookup } from '../builder/blocks-model'\nimport type { StudioReuseConfig } from '../builder/reuse'\nimport type { SidebarNode } from './module'\n\n/**\n * EC-236 — the studio customization config: the single, studio-only home for\n * everything a consuming repo customizes about the builder UI.\n *\n * This is the \"separate studio-only config solution\": it lives in the consumer's\n * `studio.config.tsx`, is `defineStudio({...})`-typed, and is passed to\n * `createStudio(config, studioConfig)` at the mount — NEVER mixed into the shared\n * `elytra.config.ts`, so the live React it carries (views, icons, field renderers,\n * future branding) never reaches the `frontend/` delivery bundle.\n *\n * The customization PRINCIPLE it embodies: customization is **code passed\n * directly**, not data referenced by a string key. Every field here holds live\n * values/components by reference — `sidebar` is built from `nav.*` view objects,\n * `fieldRenderers` maps a control name to a component. The only string indirection\n * in the whole surface is `fieldRenderers`' key, and ONLY because its other end\n * (`form.control` on a field) lives in the delivery-shared serializable schema and\n * cannot import studio code. This config grows by adding direct-value keys\n * (branding/logo/panels/theme), never by adding registries.\n */\nexport interface StudioConfig {\n /**\n * The section rail (EC-237) as an ordered list of `nav.*` nodes — views, groups,\n * and separators. Omitted → the studio derives the default rail from its built-in\n * views. A rail node carries its view object directly; there are no string ids to\n * keep in sync.\n */\n sidebar?: SidebarNode[]\n /**\n * Custom field renderers (EC-238) keyed by CONTROL name (`form.control`), each a\n * React component replacing the built-in document-editor control. A field opts in\n * per-field via `form: { control: '<name>' }` — so a renderer targets exactly the\n * fields that ask for it, not every field of a datatype. This is the one\n * boundary-crossing seam: the field names its control by string in the shared\n * schema, this map binds that name to a studio-only component.\n */\n fieldRenderers?: Record<string, CustomFieldRenderer>\n /**\n * Studio-UI localization (EC-241): the default locale + imported translation\n * catalogs. English is the built-in source; a consumer adds e.g.\n * `i18n: { locales: { de: deMessages } }` and the studio chrome renders in the\n * browser's preferred locale (no persisted selector in v1). Studio-only — never\n * reaches the delivery bundle. Schema labels localize separately, via a\n * `LocalizedString` on the field/collection def.\n */\n i18n?: StudioI18nConfig\n /**\n * AI platform (EC-331): provider, project knowledge, and soft limits. The block's\n * PRESENCE enables the AI feature; its ABSENCE means the feature is fully absent —\n * no AI UI and no AI dependencies on the critical path (the runtime lives behind a\n * lazy boundary in `@elytracms/studio/lib/ai`, only reached when configured).\n * `enabled: false` is a temporary kill-switch. Studio-only live code (holds the\n * provider adapter + knowledge functions) — never reaches the delivery bundle.\n * Validated at load by `resolveAiConfig` (capability gate: tool calling + structured\n * output required, fail-loud).\n */\n ai?: StudioAiConfig\n /**\n * Canvas page-surface theming (EC-389): make the preview iframe wear the SITE's\n * chrome. Direct values (no registries): body classes + raw CSS, injected LAST\n * into the canvas iframe — after the studio defaults and an instantiated starter\n * kit's `tokensCss` — so consumer values win the cascade. This themes the canvas\n * PAGE surface only, never the studio chrome (branding/logo theming is a future,\n * separate key).\n */\n canvas?: StudioCanvasTheme\n /**\n * Insert-menu preview artifacts (EC-439, the seam EC-440 fills): where the\n * studio's blocks panel finds GENERATED preview images for component\n * fixtures. Studio-only (from `defineStudio`, never the delivery bundle) and\n * fully optional — absent, tiles fall back from a curated\n * `manifest.previewImage` straight to the icon+name placeholder. The EC-440\n * CLI (`elytra previews`) renders fixtures headlessly against the consumer\n * theme and writes a previews manifest; the consumer adapts that manifest\n * into `previews.generated` here.\n */\n previews?: StudioPreviewsConfig\n /**\n * Reusable elements (EC-467, AD-b): WHICH component is this project's\n * reusable-element embed. One line, and only when needed —\n * `reuse: { component: 'project.Reusable' }`.\n *\n * **ABSENCE MEANS INFER, not off.** Unlike the `ai?` precedent, omitting this\n * block does not disable anything: the studio derives the embed from config as\n * it always has (a slot-less component with one cardinality-one `relation` prop\n * whose target collection holds a `blocks` field), so the zero-config promise is\n * untouched and no existing consumer needs to change anything. Declare it only\n * when the inference cannot decide — the studio then says so, by name, in the\n * rail's issue list rather than silently switching the reuse gestures off.\n *\n * **Reusable elements are singular per project (AD-a):** exactly one embed\n * component and exactly one reusable collection. This is one value, never a\n * list. The collection and the composition field DERIVE from the declared\n * component (its sole single-document relation prop → that prop's target\n * collection → that collection's `blocks` field), which is why the declaration\n * stays a single identifier.\n *\n * Studio-only, like every key here — the whole feature is studio-side (canvas\n * gestures + publish integrity), so it has zero delivery-wire surface and never\n * reaches the `frontend/` bundle. A declaration that cannot resolve is a visible\n * validation issue, never a silent fallback to guessing.\n */\n reuse?: StudioReuseConfig\n}\n\n/**\n * The generated-preview seam (EC-439 AD-2). `generated` is a PURE lookup —\n * component id + fixture name → image URL (`undefined` = no artifact). A\n * fixture-less component's single implicit variant is looked up under the\n * fixture name `'Default'`. Curated `manifest.previewImage` always wins over\n * this lookup; the lookup wins over the placeholder.\n */\nexport interface StudioPreviewsConfig {\n generated?: GeneratedPreviewLookup\n}\n\n/**\n * The canvas page-surface theme (EC-389). Both knobs target the preview iframe\n * document — the isolated page the builder renders into — so a site whose theme\n * styles `body` (background, color, font) previews exactly as it delivers.\n */\nexport interface StudioCanvasTheme {\n /**\n * REPLACES the studio's default canvas body classes\n * (`bg-background text-foreground font-sans`). The default's utility classes\n * out-cascade a site theme's `@layer base body` rules (Tailwind layers lose to\n * later layers/un-layered styles), which is exactly how a site's page background\n * used to paint white in the canvas — pass `''` to drop the default entirely and\n * let the site's own `body` rules (from the canvas stylesheet) take over, or\n * pass your own classes. Omitted → the studio default stays.\n */\n bodyClassName?: string\n /**\n * Raw CSS injected into the canvas iframe as the LAST style element — after the\n * canvas stylesheet, the studio editing chrome, and a kit's `tokensCss` — so it\n * wins the cascade over all of them (an un-layered `body { background: … }` or a\n * `:root { --token: … }` override both work). The kit seam keeps working: a kit\n * provides, this overrides.\n */\n tokensCss?: string\n}\n\n/** The props a custom field renderer receives — identical to the built-in control. */\nexport interface CustomFieldRendererProps {\n field: FieldDef\n value: unknown\n onChange: (value: unknown) => void\n readOnly: boolean\n id: string\n}\nexport type CustomFieldRenderer = (props: CustomFieldRendererProps) => ReactNode\n\n/**\n * Typed identity helper for `studio.config.tsx` — keeps the config declarative\n * (like {@link import('@elytracms/core/config').defineElytraConfig}). No\n * validation here; the studio resolves + validates the sidebar and field renderers\n * through the EC-236 seam at mount, surfacing every issue visibly (never throws).\n *\n * @example\n * ```tsx\n * // studio.config.tsx\n * import { defineStudio, nav } from '@elytracms/studio/customization'\n * import { ColorField } from './fields/color'\n *\n * export default defineStudio({\n * sidebar: [\n * nav.cms({ label: 'Content' }),\n * nav.group('Catalog', [\n * nav.cms({ label: 'Catalog', collections: ['product', 'productCategory'] }),\n * nav.cms({ label: 'Categories', collection: 'productCategory' }),\n * ]),\n * nav.separator(),\n * nav.assets(),\n * nav.settings(),\n * ],\n * fieldRenderers: { color: ColorField },\n * })\n * ```\n */\nexport function defineStudio(config: StudioConfig): StudioConfig {\n return config\n}\n"]}
1
+ {"version":3,"file":"studio-config.js","sourceRoot":"","sources":["../../../src/components/app-views/studio-config.ts"],"names":[],"mappings":"AA8JA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,UAAU,YAAY,CAAC,MAAoB;IAC/C,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["import type { ReactNode } from 'react'\nimport type { FieldDef } from '@elytracms/core/cms-core'\nimport type { StudioI18nConfig } from '@elytracms/studio/lib/i18n'\nimport type { StudioAiConfig } from '@elytracms/studio/lib/ai'\nimport type { GeneratedPreviewLookup } from '../builder/blocks-model'\nimport type { StudioReuseConfig } from '../builder/reuse'\nimport type { SidebarNode } from './module'\n\n/**\n * EC-236 — the studio customization config: the single, studio-only home for\n * everything a consuming repo customizes about the builder UI.\n *\n * This is the \"separate studio-only config solution\": it lives in the consumer's\n * `studio.config.tsx`, is `defineStudio({...})`-typed, and is passed to\n * `createStudio(config, studioConfig)` at the mount — NEVER mixed into the shared\n * `elytra.config.ts`, so the live React it carries (views, icons, field renderers,\n * future branding) never reaches the `frontend/` delivery bundle.\n *\n * The customization PRINCIPLE it embodies: customization is **code passed\n * directly**, not data referenced by a string key. Every field here holds live\n * values/components by reference — `sidebar` is built from `nav.*` view objects,\n * `fieldRenderers` maps a control name to a component. The only string indirection\n * in the whole surface is `fieldRenderers`' key, and ONLY because its other end\n * (`form.control` on a field) lives in the delivery-shared serializable schema and\n * cannot import studio code. This config grows by adding direct-value keys\n * (branding/logo/panels/theme), never by adding registries.\n */\nexport interface StudioConfig {\n /**\n * The section rail (EC-237) as an ordered list of `nav.*` nodes — views, groups,\n * and separators. Omitted → the studio derives the default rail from its built-in\n * views. A rail node carries its view object directly; there are no string ids to\n * keep in sync.\n */\n sidebar?: SidebarNode[]\n /**\n * Custom field renderers (EC-238) keyed by CONTROL name (`form.control`), each a\n * React component replacing the built-in document-editor control. A field opts in\n * per-field via `form: { control: '<name>' }` — so a renderer targets exactly the\n * fields that ask for it, not every field of a datatype. This is the one\n * boundary-crossing seam: the field names its control by string in the shared\n * schema, this map binds that name to a studio-only component.\n */\n fieldRenderers?: Record<string, CustomFieldRenderer>\n /**\n * Studio-UI localization (EC-241): the default locale + imported translation\n * catalogs. English is the built-in source; a consumer adds e.g.\n * `i18n: { locales: { de: deMessages } }` and the studio chrome renders in the\n * browser's preferred locale (no persisted selector in v1). Studio-only — never\n * reaches the delivery bundle. Schema labels localize separately, via a\n * `LocalizedString` on the field/collection def.\n */\n i18n?: StudioI18nConfig\n /**\n * AI platform (EC-331): provider, project knowledge, and soft limits. The block's\n * PRESENCE enables the AI feature; its ABSENCE means the feature is fully absent —\n * no AI UI and no AI dependencies on the critical path (the runtime lives behind a\n * lazy boundary in `@elytracms/studio/lib/ai`, only reached when configured).\n * `enabled: false` is a temporary kill-switch. Studio-only live code (holds the\n * provider adapter + knowledge functions) — never reaches the delivery bundle.\n * Validated at load by `resolveAiConfig` (capability gate: tool calling + structured\n * output required, fail-loud).\n */\n ai?: StudioAiConfig\n /**\n * Canvas page-surface theming (EC-389): make the preview iframe wear the SITE's\n * chrome. Direct values (no registries): body classes + raw CSS, injected LAST\n * into the canvas iframe — after the studio defaults — so consumer values win\n * the cascade. This themes the canvas\n * PAGE surface only, never the studio chrome (branding/logo theming is a future,\n * separate key).\n */\n canvas?: StudioCanvasTheme\n /**\n * Insert-menu preview artifacts (EC-439, the seam EC-440 fills): where the\n * studio's blocks panel finds GENERATED preview images for component\n * fixtures. Studio-only (from `defineStudio`, never the delivery bundle) and\n * fully optional — absent, tiles fall back from a curated\n * `manifest.previewImage` straight to the icon+name placeholder. The EC-440\n * CLI (`elytra previews`) renders fixtures headlessly against the consumer\n * theme and writes a previews manifest; the consumer adapts that manifest\n * into `previews.generated` here.\n */\n previews?: StudioPreviewsConfig\n /**\n * Reusable elements (EC-467, AD-b): WHICH component is this project's\n * reusable-element embed. One line, and only when needed —\n * `reuse: { component: 'project.Reusable' }`.\n *\n * **ABSENCE MEANS INFER, not off.** Unlike the `ai?` precedent, omitting this\n * block does not disable anything: the studio derives the embed from config as\n * it always has (a slot-less component with one cardinality-one `relation` prop\n * whose target collection holds a `blocks` field), so the zero-config promise is\n * untouched and no existing consumer needs to change anything. Declare it only\n * when the inference cannot decide — the studio then says so, by name, in the\n * rail's issue list rather than silently switching the reuse gestures off.\n *\n * **Reusable elements are singular per project (AD-a):** exactly one embed\n * component and exactly one reusable collection. This is one value, never a\n * list. The collection and the composition field DERIVE from the declared\n * component (its sole single-document relation prop → that prop's target\n * collection → that collection's `blocks` field), which is why the declaration\n * stays a single identifier.\n *\n * Studio-only, like every key here — the whole feature is studio-side (canvas\n * gestures + publish integrity), so it has zero delivery-wire surface and never\n * reaches the `frontend/` bundle. A declaration that cannot resolve is a visible\n * validation issue, never a silent fallback to guessing.\n */\n reuse?: StudioReuseConfig\n}\n\n/**\n * The generated-preview seam (EC-439 AD-2). `generated` is a PURE lookup —\n * component id + fixture name → image URL (`undefined` = no artifact). A\n * fixture-less component's single implicit variant is looked up under the\n * fixture name `'Default'`. Curated `manifest.previewImage` always wins over\n * this lookup; the lookup wins over the placeholder.\n */\nexport interface StudioPreviewsConfig {\n generated?: GeneratedPreviewLookup\n}\n\n/**\n * The canvas page-surface theme (EC-389). Both knobs target the preview iframe\n * document — the isolated page the builder renders into — so a site whose theme\n * styles `body` (background, color, font) previews exactly as it delivers.\n */\nexport interface StudioCanvasTheme {\n /**\n * REPLACES the studio's default canvas body classes\n * (`bg-background text-foreground font-sans`). The default's utility classes\n * out-cascade a site theme's `@layer base body` rules (Tailwind layers lose to\n * later layers/un-layered styles), which is exactly how a site's page background\n * used to paint white in the canvas — pass `''` to drop the default entirely and\n * let the site's own `body` rules (from the canvas stylesheet) take over, or\n * pass your own classes. Omitted → the studio default stays.\n */\n bodyClassName?: string\n /**\n * Raw CSS injected into the canvas iframe as the LAST style element — after the\n * canvas stylesheet and the studio editing chrome — so it wins the cascade over\n * both (an un-layered `body { background: … }` or a `:root { --token: … }`\n * override both work).\n */\n tokensCss?: string\n}\n\n/** The props a custom field renderer receives — identical to the built-in control. */\nexport interface CustomFieldRendererProps {\n field: FieldDef\n value: unknown\n onChange: (value: unknown) => void\n readOnly: boolean\n id: string\n}\nexport type CustomFieldRenderer = (props: CustomFieldRendererProps) => ReactNode\n\n/**\n * Typed identity helper for `studio.config.tsx` — keeps the config declarative\n * (like {@link import('@elytracms/core/config').defineElytraConfig}). No\n * validation here; the studio resolves + validates the sidebar and field renderers\n * through the EC-236 seam at mount, surfacing every issue visibly (never throws).\n *\n * @example\n * ```tsx\n * // studio.config.tsx\n * import { defineStudio, nav } from '@elytracms/studio/customization'\n * import { ColorField } from './fields/color'\n *\n * export default defineStudio({\n * sidebar: [\n * nav.cms({ label: 'Content' }),\n * nav.group('Catalog', [\n * nav.cms({ label: 'Catalog', collections: ['product', 'productCategory'] }),\n * nav.cms({ label: 'Categories', collection: 'productCategory' }),\n * ]),\n * nav.separator(),\n * nav.assets(),\n * nav.settings(),\n * ],\n * fieldRenderers: { color: ColorField },\n * })\n * ```\n */\nexport function defineStudio(config: StudioConfig): StudioConfig {\n return config\n}\n"]}
@@ -1,5 +1,5 @@
1
1
  import type { CollectionDef, FieldDef } from '@elytracms/core/cms-core';
2
- import type { AssetRecord, AssetRepository, NewAssetInput } from '@elytracms/core/persistence';
2
+ import type { AssetRecord, AssetRepository, AssetWriteInput } from '@elytracms/core/persistence';
3
3
  /**
4
4
  * Asset metadata editing (EC-152). A patch is applied through the
5
5
  * {@link AssetRepository} interface — in the studio that is the EC-141
@@ -25,18 +25,19 @@ export declare function assetMetaFieldsOf(collection: CollectionDef): FieldDef[]
25
25
  export declare function pruneMeta(meta: Record<string, unknown>): Record<string, unknown>;
26
26
  /** Human-readable byte size (`1536` → `1.5 KB`). Shared by the asset grid + detail. */
27
27
  export declare function formatBytes(byteSize: number): string;
28
+ /**
29
+ * An editor's metadata change, in the EC-577 write rule: an ABSENT key leaves the
30
+ * stored value untouched, `null` clears it, a value sets it.
31
+ */
28
32
  export interface AssetMetadataPatch {
29
- /**
30
- * New alt text. A present key overwrites; a trimmed-empty string clears
31
- * the field. An absent key leaves the current value untouched.
32
- */
33
- alt?: string;
34
- /** New display title; same present/empty/absent semantics as `alt`. */
35
- title?: string;
33
+ /** New alt text: a value sets (trimmed), `null` or a trimmed-empty string clears. */
34
+ alt?: string | null;
35
+ /** New display title; same semantics as `alt`. */
36
+ title?: string | null;
36
37
  /**
37
38
  * New focal point (EC-248), the normalized 0–1 hotspot threaded to delivery's
38
- * `object-position` (EC-230). A present `{x,y}` sets it, `null` clears it, an
39
- * absent key leaves the current value untouched.
39
+ * `object-position` (EC-230). `{x,y}` sets it, `null` clears it ("Reset to
40
+ * centre").
40
41
  */
41
42
  focalPoint?: {
42
43
  x: number;
@@ -44,14 +45,19 @@ export interface AssetMetadataPatch {
44
45
  } | null;
45
46
  /**
46
47
  * Schema-driven asset metadata bag (EC-301): alt, copyright, credit, … keyed by
47
- * the asset collection's `fields`. A present object **replaces** the whole bag
48
- * (the image options dialog holds the complete edited meta); an absent key
49
- * leaves the current bag untouched.
48
+ * the asset collection's `fields`. An object **replaces** the whole bag (the
49
+ * image options dialog holds the complete edited meta); `null` clears it.
50
50
  */
51
- meta?: Record<string, unknown>;
51
+ meta?: Record<string, unknown> | null;
52
52
  }
53
- /** The full upsert input that re-states `current` with the patch applied. */
54
- export declare function assetMetadataUpsertInput(current: AssetRecord, patch: AssetMetadataPatch): NewAssetInput;
53
+ /**
54
+ * The asset write for a metadata edit (EC-577): ONLY the keys the editor changed,
55
+ * with `null` for a clear. It deliberately restates nothing else — not the
56
+ * upload-owned columns, and never `thumb`/`lqip`: a stale copy of those would
57
+ * overwrite a preview backfill that finished while the dialog was open, and a
58
+ * clear expressed as an omitted key is exactly what never persisted on Convex.
59
+ */
60
+ export declare function assetMetadataWrite(assetId: string, patch: AssetMetadataPatch): AssetWriteInput;
55
61
  export type UpdateAssetMetadataResult = {
56
62
  ok: true;
57
63
  asset: AssetRecord;
@@ -60,9 +66,12 @@ export type UpdateAssetMetadataResult = {
60
66
  message: string;
61
67
  };
62
68
  /**
63
- * Load the current record, apply the patch, and persist it via the
64
- * repository (the operations facade in the studio). Failures come back as a
65
- * structured result — never a throw the UI has to guess about.
69
+ * Persist a metadata edit via the repository (the operations facade in the
70
+ * studio). No read first: the write carries only the change, and the adapter
71
+ * resolves it against the stored record inside its own transaction (EC-577). An
72
+ * asset that no longer exists is refused there (a metadata write cannot create
73
+ * one). Failures come back as a structured result — never a throw the UI has to
74
+ * guess about.
66
75
  */
67
76
  export declare function updateAssetMetadata(deps: {
68
77
  assets: AssetRepository;
@@ -42,45 +42,38 @@ export function formatBytes(byteSize) {
42
42
  return `${(byteSize / 1024).toFixed(1)} KB`;
43
43
  return `${(byteSize / (1024 * 1024)).toFixed(1)} MB`;
44
44
  }
45
- /** Merge one patch field: present+empty clears, present+value sets, absent keeps. */
46
- function mergeField(patchValue, patchHasKey, currentValue) {
47
- if (!patchHasKey)
48
- return currentValue;
49
- const trimmed = patchValue?.trim() ?? '';
50
- return trimmed.length > 0 ? trimmed : undefined;
45
+ /** A text column's write: a trimmed value sets, `null`/empty clears. */
46
+ function textWrite(value) {
47
+ const trimmed = value?.trim() ?? '';
48
+ return trimmed.length > 0 ? trimmed : null;
51
49
  }
52
- /** The full upsert input that re-states `current` with the patch applied. */
53
- export function assetMetadataUpsertInput(current, patch) {
54
- const alt = mergeField(patch.alt, 'alt' in patch, current.alt);
55
- const title = mergeField(patch.title, 'title' in patch, current.title);
56
- // present `{x,y}` sets, present `null` clears, absent keeps the current point.
57
- const focalPoint = 'focalPoint' in patch ? (patch.focalPoint ?? undefined) : current.focalPoint;
58
- // present object replaces the whole bag; absent keeps the current one (EC-301).
59
- const meta = 'meta' in patch ? patch.meta : current.meta;
50
+ /**
51
+ * The asset write for a metadata edit (EC-577): ONLY the keys the editor changed,
52
+ * with `null` for a clear. It deliberately restates nothing else — not the
53
+ * upload-owned columns, and never `thumb`/`lqip`: a stale copy of those would
54
+ * overwrite a preview backfill that finished while the dialog was open, and a
55
+ * clear expressed as an omitted key is exactly what never persisted on Convex.
56
+ */
57
+ export function assetMetadataWrite(assetId, patch) {
60
58
  return {
61
- id: current.id,
62
- filename: current.filename,
63
- contentType: current.contentType,
64
- byteSize: current.byteSize,
65
- storageKey: current.storageKey,
66
- ...(current.width !== undefined ? { width: current.width } : {}),
67
- ...(current.height !== undefined ? { height: current.height } : {}),
68
- ...(current.url !== undefined ? { url: current.url } : {}),
69
- ...(alt !== undefined ? { alt } : {}),
70
- ...(title !== undefined ? { title } : {}),
71
- ...(focalPoint !== undefined ? { focalPoint } : {}),
72
- ...(meta !== undefined ? { meta } : {}),
59
+ id: assetId,
60
+ ...(patch.alt !== undefined ? { alt: textWrite(patch.alt) } : {}),
61
+ ...(patch.title !== undefined ? { title: textWrite(patch.title) } : {}),
62
+ ...(patch.focalPoint !== undefined ? { focalPoint: patch.focalPoint } : {}),
63
+ ...(patch.meta !== undefined ? { meta: patch.meta } : {}),
73
64
  };
74
65
  }
75
66
  /**
76
- * Load the current record, apply the patch, and persist it via the
77
- * repository (the operations facade in the studio). Failures come back as a
78
- * structured result — never a throw the UI has to guess about.
67
+ * Persist a metadata edit via the repository (the operations facade in the
68
+ * studio). No read first: the write carries only the change, and the adapter
69
+ * resolves it against the stored record inside its own transaction (EC-577). An
70
+ * asset that no longer exists is refused there (a metadata write cannot create
71
+ * one). Failures come back as a structured result — never a throw the UI has to
72
+ * guess about.
79
73
  */
80
74
  export async function updateAssetMetadata(deps, projectId, assetId, patch) {
81
75
  try {
82
- const current = await deps.assets.getAsset(projectId, assetId);
83
- const asset = await deps.assets.upsertAsset(projectId, assetMetadataUpsertInput(current, patch));
76
+ const asset = await deps.assets.upsertAsset(projectId, assetMetadataWrite(assetId, patch));
84
77
  return { ok: true, asset };
85
78
  }
86
79
  catch (cause) {
@@ -1 +1 @@
1
- {"version":3,"file":"asset-metadata.js","sourceRoot":"","sources":["../../../src/components/assets/asset-metadata.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CollectionDef, FieldDef } from '@elytracms/core/cms-core'\nimport type { AssetRecord, AssetRepository, NewAssetInput } from '@elytracms/core/persistence'\n\n/**\n * Asset metadata editing (EC-152). A patch is applied through the\n * {@link AssetRepository} interface — in the studio that is the EC-141\n * operations facade, so every edit runs the `assets.upsert` command\n * (validate → authorize → mutate → event). Pure orchestration; injectable\n * for tests.\n */\n\n/**\n * System columns the upload pipeline owns — never editable metadata, so they're\n * excluded from the asset collection's schema-driven forms: the image field's\n * options dialog (EC-301, `field-control.tsx`) and the assets rail's detail\n * surface (EC-319, `asset-detail.tsx`) share this ONE reserved set so neither\n * drifts from the other. Everything else a project declares on the `kind:'asset'`\n * collection round-trips through the record's open `meta` bag.\n */\nexport const SYSTEM_ASSET_FIELDS: ReadonlySet<string> = new Set([\n 'filename',\n 'url',\n 'contentType',\n 'byteSize',\n 'width',\n 'height',\n 'storageKey',\n 'title',\n])\n\n/**\n * The asset collection's EDITABLE metadata fields — declared fields minus the\n * system columns above and anything explicitly `readOnly` (EC-301/EC-319).\n */\nexport function assetMetaFieldsOf(collection: CollectionDef): FieldDef[] {\n return collection.fields.filter((f) => !SYSTEM_ASSET_FIELDS.has(f.name) && !f.form?.readOnly)\n}\n\n/** Drop `undefined` values so a persisted meta bag stays JSON/Convex-clean. */\nexport function pruneMeta(meta: Record<string, unknown>): Record<string, unknown> {\n return Object.fromEntries(Object.entries(meta).filter(([, v]) => v !== undefined))\n}\n\n/** Human-readable byte size (`1536` → `1.5 KB`). Shared by the asset grid + detail. */\nexport function formatBytes(byteSize: number): string {\n if (byteSize < 1024) return `${byteSize} B`\n if (byteSize < 1024 * 1024) return `${(byteSize / 1024).toFixed(1)} KB`\n return `${(byteSize / (1024 * 1024)).toFixed(1)} MB`\n}\n\nexport interface AssetMetadataPatch {\n /**\n * New alt text. A present key overwrites; a trimmed-empty string clears\n * the field. An absent key leaves the current value untouched.\n */\n alt?: string\n /** New display title; same present/empty/absent semantics as `alt`. */\n title?: string\n /**\n * New focal point (EC-248), the normalized 0–1 hotspot threaded to delivery's\n * `object-position` (EC-230). A present `{x,y}` sets it, `null` clears it, an\n * absent key leaves the current value untouched.\n */\n focalPoint?: { x: number; y: number } | null\n /**\n * Schema-driven asset metadata bag (EC-301): alt, copyright, credit, … keyed by\n * the asset collection's `fields`. A present object **replaces** the whole bag\n * (the image options dialog holds the complete edited meta); an absent key\n * leaves the current bag untouched.\n */\n meta?: Record<string, unknown>\n}\n\n/** Merge one patch field: present+empty clears, present+value sets, absent keeps. */\nfunction mergeField(\n patchValue: string | undefined,\n patchHasKey: boolean,\n currentValue: string | undefined,\n): string | undefined {\n if (!patchHasKey) return currentValue\n const trimmed = patchValue?.trim() ?? ''\n return trimmed.length > 0 ? trimmed : undefined\n}\n\n/** The full upsert input that re-states `current` with the patch applied. */\nexport function assetMetadataUpsertInput(\n current: AssetRecord,\n patch: AssetMetadataPatch,\n): NewAssetInput {\n const alt = mergeField(patch.alt, 'alt' in patch, current.alt)\n const title = mergeField(patch.title, 'title' in patch, current.title)\n // present `{x,y}` sets, present `null` clears, absent keeps the current point.\n const focalPoint = 'focalPoint' in patch ? (patch.focalPoint ?? undefined) : current.focalPoint\n // present object replaces the whole bag; absent keeps the current one (EC-301).\n const meta = 'meta' in patch ? patch.meta : current.meta\n return {\n id: current.id,\n filename: current.filename,\n contentType: current.contentType,\n byteSize: current.byteSize,\n storageKey: current.storageKey,\n ...(current.width !== undefined ? { width: current.width } : {}),\n ...(current.height !== undefined ? { height: current.height } : {}),\n ...(current.url !== undefined ? { url: current.url } : {}),\n ...(alt !== undefined ? { alt } : {}),\n ...(title !== undefined ? { title } : {}),\n ...(focalPoint !== undefined ? { focalPoint } : {}),\n ...(meta !== undefined ? { meta } : {}),\n }\n}\n\nexport type UpdateAssetMetadataResult =\n | { ok: true; asset: AssetRecord }\n | { ok: false; message: string }\n\n/**\n * Load the current record, apply the patch, and persist it via the\n * repository (the operations facade in the studio). Failures come back as a\n * structured result — never a throw the UI has to guess about.\n */\nexport async function updateAssetMetadata(\n deps: { assets: AssetRepository },\n projectId: string,\n assetId: string,\n patch: AssetMetadataPatch,\n): Promise<UpdateAssetMetadataResult> {\n try {\n const current = await deps.assets.getAsset(projectId, assetId)\n const asset = await deps.assets.upsertAsset(projectId, assetMetadataUpsertInput(current, patch))\n return { ok: true, asset }\n } catch (cause) {\n return { ok: false, message: cause instanceof Error ? cause.message : String(cause) }\n }\n}\n"]}
1
+ {"version":3,"file":"asset-metadata.js","sourceRoot":"","sources":["../../../src/components/assets/asset-metadata.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CollectionDef, FieldDef } from '@elytracms/core/cms-core'\nimport type { AssetRecord, AssetRepository, AssetWriteInput } from '@elytracms/core/persistence'\n\n/**\n * Asset metadata editing (EC-152). A patch is applied through the\n * {@link AssetRepository} interface — in the studio that is the EC-141\n * operations facade, so every edit runs the `assets.upsert` command\n * (validate → authorize → mutate → event). Pure orchestration; injectable\n * for tests.\n */\n\n/**\n * System columns the upload pipeline owns — never editable metadata, so they're\n * excluded from the asset collection's schema-driven forms: the image field's\n * options dialog (EC-301, `field-control.tsx`) and the assets rail's detail\n * surface (EC-319, `asset-detail.tsx`) share this ONE reserved set so neither\n * drifts from the other. Everything else a project declares on the `kind:'asset'`\n * collection round-trips through the record's open `meta` bag.\n */\nexport const SYSTEM_ASSET_FIELDS: ReadonlySet<string> = new Set([\n 'filename',\n 'url',\n 'contentType',\n 'byteSize',\n 'width',\n 'height',\n 'storageKey',\n 'title',\n])\n\n/**\n * The asset collection's EDITABLE metadata fields — declared fields minus the\n * system columns above and anything explicitly `readOnly` (EC-301/EC-319).\n */\nexport function assetMetaFieldsOf(collection: CollectionDef): FieldDef[] {\n return collection.fields.filter((f) => !SYSTEM_ASSET_FIELDS.has(f.name) && !f.form?.readOnly)\n}\n\n/** Drop `undefined` values so a persisted meta bag stays JSON/Convex-clean. */\nexport function pruneMeta(meta: Record<string, unknown>): Record<string, unknown> {\n return Object.fromEntries(Object.entries(meta).filter(([, v]) => v !== undefined))\n}\n\n/** Human-readable byte size (`1536` → `1.5 KB`). Shared by the asset grid + detail. */\nexport function formatBytes(byteSize: number): string {\n if (byteSize < 1024) return `${byteSize} B`\n if (byteSize < 1024 * 1024) return `${(byteSize / 1024).toFixed(1)} KB`\n return `${(byteSize / (1024 * 1024)).toFixed(1)} MB`\n}\n\n/**\n * An editor's metadata change, in the EC-577 write rule: an ABSENT key leaves the\n * stored value untouched, `null` clears it, a value sets it.\n */\nexport interface AssetMetadataPatch {\n /** New alt text: a value sets (trimmed), `null` or a trimmed-empty string clears. */\n alt?: string | null\n /** New display title; same semantics as `alt`. */\n title?: string | null\n /**\n * New focal point (EC-248), the normalized 0–1 hotspot threaded to delivery's\n * `object-position` (EC-230). `{x,y}` sets it, `null` clears it (\"Reset to\n * centre\").\n */\n focalPoint?: { x: number; y: number } | null\n /**\n * Schema-driven asset metadata bag (EC-301): alt, copyright, credit, … keyed by\n * the asset collection's `fields`. An object **replaces** the whole bag (the\n * image options dialog holds the complete edited meta); `null` clears it.\n */\n meta?: Record<string, unknown> | null\n}\n\n/** A text column's write: a trimmed value sets, `null`/empty clears. */\nfunction textWrite(value: string | null): string | null {\n const trimmed = value?.trim() ?? ''\n return trimmed.length > 0 ? trimmed : null\n}\n\n/**\n * The asset write for a metadata edit (EC-577): ONLY the keys the editor changed,\n * with `null` for a clear. It deliberately restates nothing else — not the\n * upload-owned columns, and never `thumb`/`lqip`: a stale copy of those would\n * overwrite a preview backfill that finished while the dialog was open, and a\n * clear expressed as an omitted key is exactly what never persisted on Convex.\n */\nexport function assetMetadataWrite(assetId: string, patch: AssetMetadataPatch): AssetWriteInput {\n return {\n id: assetId,\n ...(patch.alt !== undefined ? { alt: textWrite(patch.alt) } : {}),\n ...(patch.title !== undefined ? { title: textWrite(patch.title) } : {}),\n ...(patch.focalPoint !== undefined ? { focalPoint: patch.focalPoint } : {}),\n ...(patch.meta !== undefined ? { meta: patch.meta } : {}),\n }\n}\n\nexport type UpdateAssetMetadataResult =\n | { ok: true; asset: AssetRecord }\n | { ok: false; message: string }\n\n/**\n * Persist a metadata edit via the repository (the operations facade in the\n * studio). No read first: the write carries only the change, and the adapter\n * resolves it against the stored record inside its own transaction (EC-577). An\n * asset that no longer exists is refused there (a metadata write cannot create\n * one). Failures come back as a structured result — never a throw the UI has to\n * guess about.\n */\nexport async function updateAssetMetadata(\n deps: { assets: AssetRepository },\n projectId: string,\n assetId: string,\n patch: AssetMetadataPatch,\n): Promise<UpdateAssetMetadataResult> {\n try {\n const asset = await deps.assets.upsertAsset(projectId, assetMetadataWrite(assetId, patch))\n return { ok: true, asset }\n } catch (cause) {\n return { ok: false, message: cause instanceof Error ? cause.message : String(cause) }\n }\n}\n"]}
@@ -1,11 +1,30 @@
1
+ import type { AssetFieldDef } from '@elytracms/core/cms-core';
2
+ import type { NewPlacementForm } from '@elytracms/studio/lib/assets/asset-placements';
1
3
  /** The id list a `many` asset field currently holds (unreadable entries dropped). */
2
4
  export declare function assetIdsOf(value: unknown): string[];
3
5
  /** The single id a `one` asset field currently holds, if any. */
4
6
  export declare function singleAssetIdOf(value: unknown): string | null;
5
7
  /**
6
- * Apply a picker selection to the stored field value.
7
- * - `one`: the picked id replaces the value; `null` clears it (`undefined`).
8
- * - `many`: the picked id is appended once (already-present ids are a
9
- * no-op); `null` leaves the list untouched.
8
+ * How a picker writes a NEW placement on this field (EC-301 → EC-558): an envelope
9
+ * when the field declares per-use sub-fields (so the placement's sub-field form has
10
+ * something to write into), otherwise a bare id (self-typed on save). The table cell
11
+ * and the detail editor both read it here, so they cannot disagree.
10
12
  */
11
- export declare function applyAssetSelection(current: unknown, picked: string | null, multiple: boolean): unknown;
13
+ export declare function assetPickForm(field: Pick<AssetFieldDef, 'fields'>): NewPlacementForm;
14
+ /**
15
+ * Apply a picker selection to the stored field value, keeping every placement's
16
+ * per-use sub-fields (EC-558).
17
+ * - `one`: the picked id replaces the image and keeps the placement's sub-fields.
18
+ * Picking the id it already shows is a no-op, and `null` clears it (`undefined`).
19
+ * - `many`: the picked id is appended once, and held entries stay as they are.
20
+ * An already-present id and `null` are no-ops.
21
+ *
22
+ * A no-op returns `current` itself, so a caller can skip the write.
23
+ */
24
+ export declare function applyAssetSelection(current: unknown, picked: string | null, multiple: boolean, form: NewPlacementForm): unknown;
25
+ /**
26
+ * Attach several picked ids to a `many` field at once: an in-picker or drop upload
27
+ * batch (EC-460). Each id not already held is appended once, in arrival order, and
28
+ * held entries stay as they are. Returns `current` itself when nothing is new.
29
+ */
30
+ export declare function appendAssetSelections(current: unknown, picked: readonly string[], form: NewPlacementForm): unknown;
@@ -4,8 +4,14 @@
4
4
  * normalize stored values and apply a picker selection without the UI owning the
5
5
  * rules. EC-277: reads tolerate self-typed `{ type:'asset', id }` envelopes AND
6
6
  * legacy bare ids (the field is self-typed on save by the operations write lens).
7
+ *
8
+ * EC-558: the pick operations below are the ones the table cell and the detail
9
+ * editor BOTH commit. They work out which ids the field holds next, and hand the
10
+ * per-use sub-field carry to the one rule, `carryAssetPlacements` (in `lib/`,
11
+ * because AI `updateFields` uses it too). Neither writer rebuilds the value itself.
7
12
  */
8
13
  import { readAssetId } from '@elytracms/core/cms-core';
14
+ import { carryAssetPlacements } from '@elytracms/studio/lib/assets/asset-placements';
9
15
  /** The id list a `many` asset field currently holds (unreadable entries dropped). */
10
16
  export function assetIdsOf(value) {
11
17
  if (!Array.isArray(value))
@@ -22,17 +28,41 @@ export function singleAssetIdOf(value) {
22
28
  return null;
23
29
  }
24
30
  /**
25
- * Apply a picker selection to the stored field value.
26
- * - `one`: the picked id replaces the value; `null` clears it (`undefined`).
27
- * - `many`: the picked id is appended once (already-present ids are a
28
- * no-op); `null` leaves the list untouched.
31
+ * How a picker writes a NEW placement on this field (EC-301 → EC-558): an envelope
32
+ * when the field declares per-use sub-fields (so the placement's sub-field form has
33
+ * something to write into), otherwise a bare id (self-typed on save). The table cell
34
+ * and the detail editor both read it here, so they cannot disagree.
29
35
  */
30
- export function applyAssetSelection(current, picked, multiple) {
36
+ export function assetPickForm(field) {
37
+ return (field.fields?.length ?? 0) > 0 ? 'envelope' : 'bare';
38
+ }
39
+ /**
40
+ * Apply a picker selection to the stored field value, keeping every placement's
41
+ * per-use sub-fields (EC-558).
42
+ * - `one`: the picked id replaces the image and keeps the placement's sub-fields.
43
+ * Picking the id it already shows is a no-op, and `null` clears it (`undefined`).
44
+ * - `many`: the picked id is appended once, and held entries stay as they are.
45
+ * An already-present id and `null` are no-ops.
46
+ *
47
+ * A no-op returns `current` itself, so a caller can skip the write.
48
+ */
49
+ export function applyAssetSelection(current, picked, multiple, form) {
31
50
  if (!multiple)
32
- return picked ?? undefined;
51
+ return carryAssetPlacements(current, { multiple: false, id: picked }, form);
52
+ if (picked === null)
53
+ return current;
54
+ return appendAssetSelections(current, [picked], form);
55
+ }
56
+ /**
57
+ * Attach several picked ids to a `many` field at once: an in-picker or drop upload
58
+ * batch (EC-460). Each id not already held is appended once, in arrival order, and
59
+ * held entries stay as they are. Returns `current` itself when nothing is new.
60
+ */
61
+ export function appendAssetSelections(current, picked, form) {
33
62
  const ids = assetIdsOf(current);
34
- if (picked === null || ids.includes(picked))
35
- return ids;
36
- return [...ids, picked];
63
+ const fresh = [...new Set(picked)].filter((id) => !ids.includes(id));
64
+ if (fresh.length === 0)
65
+ return current;
66
+ return carryAssetPlacements(current, { multiple: true, ids: [...ids, ...fresh] }, form);
37
67
  }
38
68
  //# sourceMappingURL=asset-selection.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"asset-selection.js","sourceRoot":"","sources":["../../../src/components/assets/asset-selection.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAEtD,qFAAqF;AACrF,MAAM,UAAU,UAAU,CAAC,KAAc;IACvC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,EAAE,CAAA;IACpC,OAAO,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,EAAgB,EAAE,CAAC,EAAE,KAAK,SAAS,CAAC,CAAA;AAC9E,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,MAAM,MAAM,GAAG,WAAW,CAAC,KAAK,CAAC,CAAA;IACjC,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAA;IACvC,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,CAAA;IAC9D,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CACjC,OAAgB,EAChB,MAAqB,EACrB,QAAiB;IAEjB,IAAI,CAAC,QAAQ;QAAE,OAAO,MAAM,IAAI,SAAS,CAAA;IACzC,MAAM,GAAG,GAAG,UAAU,CAAC,OAAO,CAAC,CAAA;IAC/B,IAAI,MAAM,KAAK,IAAI,IAAI,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC;QAAE,OAAO,GAAG,CAAA;IACvD,OAAO,CAAC,GAAG,GAAG,EAAE,MAAM,CAAC,CAAA;AACzB,CAAC","sourcesContent":["/**\n * Pure value logic for CMS `asset` fields (EC-075 → EC-152): a field stores the\n * asset *id* (cardinality `one`) or an array of ids (`many`). These helpers\n * normalize stored values and apply a picker selection without the UI owning the\n * rules. EC-277: reads tolerate self-typed `{ type:'asset', id }` envelopes AND\n * legacy bare ids (the field is self-typed on save by the operations write lens).\n */\nimport { readAssetId } from '@elytracms/core/cms-core'\n\n/** The id list a `many` asset field currently holds (unreadable entries dropped). */\nexport function assetIdsOf(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n return value.map(readAssetId).filter((id): id is string => id !== undefined)\n}\n\n/** The single id a `one` asset field currently holds, if any. */\nexport function singleAssetIdOf(value: unknown): string | null {\n const direct = readAssetId(value)\n if (direct !== undefined) return direct\n if (Array.isArray(value)) return readAssetId(value[0]) ?? null\n return null\n}\n\n/**\n * Apply a picker selection to the stored field value.\n * - `one`: the picked id replaces the value; `null` clears it (`undefined`).\n * - `many`: the picked id is appended once (already-present ids are a\n * no-op); `null` leaves the list untouched.\n */\nexport function applyAssetSelection(\n current: unknown,\n picked: string | null,\n multiple: boolean,\n): unknown {\n if (!multiple) return picked ?? undefined\n const ids = assetIdsOf(current)\n if (picked === null || ids.includes(picked)) return ids\n return [...ids, picked]\n}\n"]}
1
+ {"version":3,"file":"asset-selection.js","sourceRoot":"","sources":["../../../src/components/assets/asset-selection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAEtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,+CAA+C,CAAA;AAGpF,qFAAqF;AACrF,MAAM,UAAU,UAAU,CAAC,KAAc;IACvC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,EAAE,CAAA;IACpC,OAAO,KAAK,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,EAAgB,EAAE,CAAC,EAAE,KAAK,SAAS,CAAC,CAAA;AAC9E,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,MAAM,MAAM,GAAG,WAAW,CAAC,KAAK,CAAC,CAAA;IACjC,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAA;IACvC,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,CAAA;IAC9D,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,KAAoC;IAChE,OAAO,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,MAAM,CAAA;AAC9D,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,mBAAmB,CACjC,OAAgB,EAChB,MAAqB,EACrB,QAAiB,EACjB,IAAsB;IAEtB,IAAI,CAAC,QAAQ;QAAE,OAAO,oBAAoB,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,CAAA;IAC1F,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,OAAO,CAAA;IACnC,OAAO,qBAAqB,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,CAAA;AACvD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CACnC,OAAgB,EAChB,MAAyB,EACzB,IAAsB;IAEtB,MAAM,GAAG,GAAG,UAAU,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,KAAK,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAA;IACpE,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,OAAO,CAAA;IACtC,OAAO,oBAAoB,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,GAAG,GAAG,EAAE,GAAG,KAAK,CAAC,EAAE,EAAE,IAAI,CAAC,CAAA;AACzF,CAAC","sourcesContent":["/**\n * Pure value logic for CMS `asset` fields (EC-075 → EC-152): a field stores the\n * asset *id* (cardinality `one`) or an array of ids (`many`). These helpers\n * normalize stored values and apply a picker selection without the UI owning the\n * rules. EC-277: reads tolerate self-typed `{ type:'asset', id }` envelopes AND\n * legacy bare ids (the field is self-typed on save by the operations write lens).\n *\n * EC-558: the pick operations below are the ones the table cell and the detail\n * editor BOTH commit. They work out which ids the field holds next, and hand the\n * per-use sub-field carry to the one rule, `carryAssetPlacements` (in `lib/`,\n * because AI `updateFields` uses it too). Neither writer rebuilds the value itself.\n */\nimport { readAssetId } from '@elytracms/core/cms-core'\nimport type { AssetFieldDef } from '@elytracms/core/cms-core'\nimport { carryAssetPlacements } from '@elytracms/studio/lib/assets/asset-placements'\nimport type { NewPlacementForm } from '@elytracms/studio/lib/assets/asset-placements'\n\n/** The id list a `many` asset field currently holds (unreadable entries dropped). */\nexport function assetIdsOf(value: unknown): string[] {\n if (!Array.isArray(value)) return []\n return value.map(readAssetId).filter((id): id is string => id !== undefined)\n}\n\n/** The single id a `one` asset field currently holds, if any. */\nexport function singleAssetIdOf(value: unknown): string | null {\n const direct = readAssetId(value)\n if (direct !== undefined) return direct\n if (Array.isArray(value)) return readAssetId(value[0]) ?? null\n return null\n}\n\n/**\n * How a picker writes a NEW placement on this field (EC-301 → EC-558): an envelope\n * when the field declares per-use sub-fields (so the placement's sub-field form has\n * something to write into), otherwise a bare id (self-typed on save). The table cell\n * and the detail editor both read it here, so they cannot disagree.\n */\nexport function assetPickForm(field: Pick<AssetFieldDef, 'fields'>): NewPlacementForm {\n return (field.fields?.length ?? 0) > 0 ? 'envelope' : 'bare'\n}\n\n/**\n * Apply a picker selection to the stored field value, keeping every placement's\n * per-use sub-fields (EC-558).\n * - `one`: the picked id replaces the image and keeps the placement's sub-fields.\n * Picking the id it already shows is a no-op, and `null` clears it (`undefined`).\n * - `many`: the picked id is appended once, and held entries stay as they are.\n * An already-present id and `null` are no-ops.\n *\n * A no-op returns `current` itself, so a caller can skip the write.\n */\nexport function applyAssetSelection(\n current: unknown,\n picked: string | null,\n multiple: boolean,\n form: NewPlacementForm,\n): unknown {\n if (!multiple) return carryAssetPlacements(current, { multiple: false, id: picked }, form)\n if (picked === null) return current\n return appendAssetSelections(current, [picked], form)\n}\n\n/**\n * Attach several picked ids to a `many` field at once: an in-picker or drop upload\n * batch (EC-460). Each id not already held is appended once, in arrival order, and\n * held entries stay as they are. Returns `current` itself when nothing is new.\n */\nexport function appendAssetSelections(\n current: unknown,\n picked: readonly string[],\n form: NewPlacementForm,\n): unknown {\n const ids = assetIdsOf(current)\n const fresh = [...new Set(picked)].filter((id) => !ids.includes(id))\n if (fresh.length === 0) return current\n return carryAssetPlacements(current, { multiple: true, ids: [...ids, ...fresh] }, form)\n}\n"]}
@@ -23,24 +23,27 @@ import type { CollectionViewSource, CollectionViewCapabilities } from '@elytracm
23
23
  */
24
24
  export declare const ASSET_COLLECTION_CAPABILITIES: CollectionViewCapabilities;
25
25
  /**
26
- * Project a stored asset record into the lens's `values` shape (EC-319), reading
27
- * only the schema's DECLARED fields — the same "schema is a lens" projection the
28
- * redirects/users rails use. A system column (`filename`, `url`, dimensions, …)
29
- * reads straight off the record; everything else — the built-in `alt`, a
30
- * project's own `copyright`/`credit`, or an EC-311 additive field — round-trips
31
- * through the record's open `meta` bag (EC-301). `alt` is schema-driven but ALSO
32
- * has a legacy record column: the meta value wins, falling back to the column
33
- * (matches delivery's `meta.alt ?? record.alt` read and the image field's
34
- * options dialog).
26
+ * Project a stored asset record into the lens's `values` shape (EC-319) — the
27
+ * value-keyed projection the redirects/users rails use (EC-577, RESIDUE-3d): EVERY
28
+ * key of the record's open `meta` bag (EC-301) rides in `values`, declared by the
29
+ * schema or not, so a save hands the bag back whole (D22). Declared fields then
30
+ * take their canonical reading on top — a column never loses to a same-named meta
31
+ * key: a system column (`filename`, `url`, dimensions, …) reads straight off the
32
+ * record, and `alt`, schema-driven but ALSO a legacy record column, reads the meta
33
+ * value falling back to the column (matches delivery's `meta.alt ?? record.alt`
34
+ * read and the image field's options dialog).
35
35
  */
36
36
  export declare function assetToDocument(collection: CollectionDef, record: AssetRecord): CmsDocument;
37
37
  /**
38
- * The inverse of {@link assetToDocument}: the schema-driven meta bag a save
39
- * writes back — every declared field OUTSIDE the system set (EC-301/EC-311),
40
- * keyed exactly as the record stores it. System columns are upload-managed and
41
- * never written here.
38
+ * The inverse of {@link assetToDocument}: the meta bag a save writes back — every
39
+ * value outside the system set (EC-301/EC-311), declared or not, through the ONE
40
+ * value-keyed projection the users/redirects rails share (EC-577, RESIDUE-3d).
41
+ * `meta` is written whole (it replaces the stored bag), so a key the lens never
42
+ * declared must be in it: the schema-driven projection this replaced dropped them
43
+ * on every rail save (D22), while the options dialog and asset detail kept them.
44
+ * System columns are upload-managed and never written here.
42
45
  */
43
- export declare function documentToAssetMetaPatch(collection: CollectionDef, doc: CmsDocument): Record<string, unknown>;
46
+ export declare function documentToAssetMetaPatch(doc: CmsDocument): Record<string, unknown>;
44
47
  /** Assets' `remove()` returns an `outcome`-discriminated {@link DeleteAssetResult}
45
48
  * (not the `{ ok }` shape users/redirects use, EC-327). `'error'` surfaces as-is;
46
49
  * `'blocked'` (the unconfirmed usage gate fired — nothing was removed) surfaces
@@ -2,8 +2,8 @@ import { useCallback, useMemo } from 'react';
2
2
  import { createCollectionRegistry, validateDocument } from '@elytracms/core/cms-core';
3
3
  import { studioPath } from '@elytracms/studio/router';
4
4
  import { useAssets } from './assets-store.js';
5
- import { SYSTEM_ASSET_FIELDS } from './asset-metadata.js';
6
- import { normalizeOkResult, useRailViewSource } from '../rail-view-source.js';
5
+ import { SYSTEM_ASSET_FIELDS, pruneMeta } from './asset-metadata.js';
6
+ import { metaBagProjection, normalizeOkResult, useRailViewSource } from '../rail-view-source.js';
7
7
  /** The assets rail is not localized in v1 (EC-301: asset metadata is non-localized). */
8
8
  const ASSETS_LOCALE = 'en';
9
9
  const ASSETS_LOCALE_CONFIG = {
@@ -66,46 +66,40 @@ function systemFieldValue(record, name) {
66
66
  }
67
67
  }
68
68
  /**
69
- * Project a stored asset record into the lens's `values` shape (EC-319), reading
70
- * only the schema's DECLARED fields — the same "schema is a lens" projection the
71
- * redirects/users rails use. A system column (`filename`, `url`, dimensions, …)
72
- * reads straight off the record; everything else — the built-in `alt`, a
73
- * project's own `copyright`/`credit`, or an EC-311 additive field — round-trips
74
- * through the record's open `meta` bag (EC-301). `alt` is schema-driven but ALSO
75
- * has a legacy record column: the meta value wins, falling back to the column
76
- * (matches delivery's `meta.alt ?? record.alt` read and the image field's
77
- * options dialog).
69
+ * Project a stored asset record into the lens's `values` shape (EC-319) — the
70
+ * value-keyed projection the redirects/users rails use (EC-577, RESIDUE-3d): EVERY
71
+ * key of the record's open `meta` bag (EC-301) rides in `values`, declared by the
72
+ * schema or not, so a save hands the bag back whole (D22). Declared fields then
73
+ * take their canonical reading on top — a column never loses to a same-named meta
74
+ * key: a system column (`filename`, `url`, dimensions, …) reads straight off the
75
+ * record, and `alt`, schema-driven but ALSO a legacy record column, reads the meta
76
+ * value falling back to the column (matches delivery's `meta.alt ?? record.alt`
77
+ * read and the image field's options dialog).
78
78
  */
79
79
  export function assetToDocument(collection, record) {
80
- const values = {};
80
+ const values = { ...(record.meta ?? {}) };
81
81
  for (const field of collection.fields) {
82
82
  if (field.name === 'alt') {
83
83
  const metaAlt = typeof record.meta?.alt === 'string' ? record.meta.alt : undefined;
84
84
  values.alt = metaAlt ?? record.alt;
85
- continue;
86
85
  }
87
- values[field.name] = SYSTEM_ASSET_FIELDS.has(field.name)
88
- ? systemFieldValue(record, field.name)
89
- : record.meta?.[field.name];
86
+ else if (SYSTEM_ASSET_FIELDS.has(field.name)) {
87
+ values[field.name] = systemFieldValue(record, field.name);
88
+ }
90
89
  }
91
90
  return { collection: collection.id, id: record.id, values };
92
91
  }
93
92
  /**
94
- * The inverse of {@link assetToDocument}: the schema-driven meta bag a save
95
- * writes back — every declared field OUTSIDE the system set (EC-301/EC-311),
96
- * keyed exactly as the record stores it. System columns are upload-managed and
97
- * never written here.
93
+ * The inverse of {@link assetToDocument}: the meta bag a save writes back — every
94
+ * value outside the system set (EC-301/EC-311), declared or not, through the ONE
95
+ * value-keyed projection the users/redirects rails share (EC-577, RESIDUE-3d).
96
+ * `meta` is written whole (it replaces the stored bag), so a key the lens never
97
+ * declared must be in it: the schema-driven projection this replaced dropped them
98
+ * on every rail save (D22), while the options dialog and asset detail kept them.
99
+ * System columns are upload-managed and never written here.
98
100
  */
99
- export function documentToAssetMetaPatch(collection, doc) {
100
- const values = doc.values ?? {};
101
- const meta = {};
102
- for (const field of collection.fields) {
103
- if (SYSTEM_ASSET_FIELDS.has(field.name))
104
- continue;
105
- if (field.name in values)
106
- meta[field.name] = values[field.name];
107
- }
108
- return meta;
101
+ export function documentToAssetMetaPatch(doc) {
102
+ return pruneMeta(metaBagProjection(doc.values ?? {}, SYSTEM_ASSET_FIELDS));
109
103
  }
110
104
  /** `create` when `capabilities.create === false` — never reached from the UI
111
105
  * (list/detail never call it); an explicit throw rather than a silent no-op. */
@@ -170,7 +164,7 @@ export function useAssetsCollectionViewSource(projectId, schema) {
170
164
  return record?.title ?? record?.filename ?? ref.id;
171
165
  }, [store.assets]);
172
166
  const validate = useCallback((doc) => validateDocument(registry, doc, { localeConfig: ASSETS_LOCALE_CONFIG }), [registry]);
173
- const save = useCallback((doc) => store.updateMetadata(doc.id, { meta: documentToAssetMetaPatch(schema, doc) }), [schema, store]);
167
+ const save = useCallback((doc) => store.updateMetadata(doc.id, { meta: documentToAssetMetaPatch(doc) }), [store]);
174
168
  // The unconfirmed delete (the store's usage gate still applies — a referenced
175
169
  // asset comes back `blocked`, surfaced via `lastError`). The UI's delete paths
176
170
  // never land here: row/bulk deletes confirm through the reference dialog and
@@ -1 +1 @@
1
- {"version":3,"file":"assets-collection-view-source.js","sourceRoot":"","sources":["../../../src/components/assets/assets-collection-view-source.ts"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useMemo } from 'react'\nimport type { CmsDocument, CollectionDef, DocumentRef, Locale, LocaleConfig } from '@elytracms/core/cms-core'\nimport { createCollectionRegistry, validateDocument } from '@elytracms/core/cms-core'\nimport type { AssetRecord, ReferenceTarget } from '@elytracms/core/persistence'\nimport type { OperationError } from '@elytracms/core/operations'\nimport { studioPath } from '@elytracms/studio/router'\nimport type { DeleteAssetResult } from '@elytracms/studio/lib/assets'\nimport type { CollectionViewSource, CollectionViewCapabilities } from '@elytracms/studio/lib/collection-view/collection-view-source'\nimport { useAssets } from './assets-store'\nimport { SYSTEM_ASSET_FIELDS } from './asset-metadata'\nimport { normalizeOkResult, useRailViewSource } from '../rail-view-source'\n\n/** The assets rail is not localized in v1 (EC-301: asset metadata is non-localized). */\nconst ASSETS_LOCALE: Locale = 'en'\nconst ASSETS_LOCALE_CONFIG: LocaleConfig = {\n default: ASSETS_LOCALE,\n locales: [ASSETS_LOCALE],\n}\n\n/**\n * The capability profile of the assets rail (EC-319, EC-433). Hoisted to a\n * module-level constant (no prop/state dependency) so it's both the value the\n * hook returns AND independently unit-testable.\n *\n * `create` is off — a new asset comes from an upload, never a blank draft.\n * `remove` + `bulk` are ON since EC-433: the list's row/bulk delete used to be\n * off deliberately because the generic `ReferenceConfirmDialog` enumerated\n * inbound references as `{ type: 'document' }` targets — the wrong index shape\n * for an asset — which would have shown \"not referenced\" for an asset that IS\n * referenced. That pre-flight now queries the shape THIS source declares via\n * {@link assetReferenceTarget} (`{ type: 'asset' }`, the same lookup the\n * detail surface runs), and the confirmed delete dispatches the SAME checked\n * store delete the detail surface uses (`deleteAsset`: original + EC-400 thumb\n * blobs, then the record — EC-420 lqips ride on the record). Nothing else\n * applies to assets: no publish/versions/locales/inline-edit/live-URL/\n * hierarchy/import-export.\n */\nexport const ASSET_COLLECTION_CAPABILITIES: CollectionViewCapabilities = {\n publish: false,\n versions: false,\n locales: false,\n inlineEdit: false,\n create: false,\n duplicate: false, // EC-442: blob duplication is a different feature, out of scope\n remove: true,\n bulk: true,\n liveUrl: false,\n hierarchy: false,\n importExport: false,\n adminOnly: false,\n}\n\n/** Read one of the record's fixed, upload-managed columns by field name. */\nfunction systemFieldValue(record: AssetRecord, name: string): unknown {\n switch (name) {\n case 'filename':\n return record.filename\n case 'url':\n return record.url\n case 'contentType':\n return record.contentType\n case 'byteSize':\n return record.byteSize\n case 'width':\n return record.width\n case 'height':\n return record.height\n case 'storageKey':\n return record.storageKey\n case 'title':\n return record.title\n default:\n return undefined\n }\n}\n\n/**\n * Project a stored asset record into the lens's `values` shape (EC-319), reading\n * only the schema's DECLARED fields — the same \"schema is a lens\" projection the\n * redirects/users rails use. A system column (`filename`, `url`, dimensions, …)\n * reads straight off the record; everything else — the built-in `alt`, a\n * project's own `copyright`/`credit`, or an EC-311 additive field — round-trips\n * through the record's open `meta` bag (EC-301). `alt` is schema-driven but ALSO\n * has a legacy record column: the meta value wins, falling back to the column\n * (matches delivery's `meta.alt ?? record.alt` read and the image field's\n * options dialog).\n */\nexport function assetToDocument(collection: CollectionDef, record: AssetRecord): CmsDocument {\n const values: Record<string, unknown> = {}\n for (const field of collection.fields) {\n if (field.name === 'alt') {\n const metaAlt = typeof record.meta?.alt === 'string' ? record.meta.alt : undefined\n values.alt = metaAlt ?? record.alt\n continue\n }\n values[field.name] = SYSTEM_ASSET_FIELDS.has(field.name)\n ? systemFieldValue(record, field.name)\n : record.meta?.[field.name]\n }\n return { collection: collection.id, id: record.id, values }\n}\n\n/**\n * The inverse of {@link assetToDocument}: the schema-driven meta bag a save\n * writes back — every declared field OUTSIDE the system set (EC-301/EC-311),\n * keyed exactly as the record stores it. System columns are upload-managed and\n * never written here.\n */\nexport function documentToAssetMetaPatch(\n collection: CollectionDef,\n doc: CmsDocument,\n): Record<string, unknown> {\n const values = doc.values ?? {}\n const meta: Record<string, unknown> = {}\n for (const field of collection.fields) {\n if (SYSTEM_ASSET_FIELDS.has(field.name)) continue\n if (field.name in values) meta[field.name] = values[field.name]\n }\n return meta\n}\n\n/** `create` when `capabilities.create === false` — never reached from the UI\n * (list/detail never call it); an explicit throw rather than a silent no-op. */\nfunction createUnsupported(): DocumentRef {\n throw new Error('assets are created by upload, not the assets rail')\n}\n\n/** Assets' `remove()` returns an `outcome`-discriminated {@link DeleteAssetResult}\n * (not the `{ ok }` shape users/redirects use, EC-327). `'error'` surfaces as-is;\n * `'blocked'` (the unconfirmed usage gate fired — nothing was removed) surfaces\n * as an explicit failure too (EC-433): a delete that left the asset in place\n * must NEVER look like success. It is unreachable from this rail's UI — every\n * delete path confirms through the reference dialog and dispatches with\n * `confirmInUse` — but stays honest for any direct caller. `'not-found'` maps\n * to success (idempotent delete: the desired end state — gone — holds). */\nexport function normalizeAssetRemoveResult(result: DeleteAssetResult): OperationError | null {\n if (result.outcome === 'error') return { code: 'unknown', message: result.message }\n if (result.outcome === 'blocked') {\n return {\n code: 'unknown',\n message: `Asset is referenced in ${result.usageCount} ${result.usageCount === 1 ? 'place' : 'places'} and was not deleted.`,\n }\n }\n return null\n}\n\n/**\n * How an asset row is addressed in the EC-155 reference index (EC-433):\n * `{ type: 'asset' }`, never the document shape — so the delete confirmation's\n * inbound pre-flight counts the SAME references `loadInboundAssetReferenceViews`\n * (the detail surface's check) does.\n */\nexport function assetReferenceTarget(ref: DocumentRef): ReferenceTarget {\n return { type: 'asset', id: ref.id }\n}\n\n/**\n * Assemble a {@link CollectionViewSource} for the **assets rail** (EC-319) — the\n * portable list surface over the existing asset library store instead of a\n * bespoke listing. `schema` is the DEPLOYMENT-resolved `kind:'asset'` collection\n * (EC-311: a project's own declared fields, or a mount's `nav.assets({ fields })`\n * override merged over it — see `resolveAssetsSchema` in `app-views/default-modules.tsx`),\n * never a hardcoded engine default — so a project's `copyright`/`credit`/… fields\n * (and any EC-311 override) drive the list's columns/filters exactly as they\n * drive the image field's options dialog.\n *\n * Must be mounted under an `<AssetsProvider>` (for {@link useAssets}) — already\n * ambient at the project level (EC-195), so callers rarely mount their own.\n *\n * The shared scaffold (`useRailViewSource`, EC-327) owns `viewState`/`lastError`\n * plumbing and the assembled source; this hook supplies only the asset-store\n * reads/writes, the capability profile, and the projections above.\n */\nexport function useAssetsCollectionViewSource(\n projectId: string,\n schema: CollectionDef,\n): CollectionViewSource {\n const store = useAssets()\n const registry = useMemo(() => createCollectionRegistry([schema]), [schema])\n\n const documents = useCallback(\n () => store.assets.map((record) => assetToDocument(schema, record)),\n [store.assets, schema],\n )\n const get = useCallback(\n (ref: DocumentRef): CmsDocument | undefined => {\n const record = store.assets.find((a) => a.id === ref.id)\n return record ? assetToDocument(schema, record) : undefined\n },\n [store.assets, schema],\n )\n const titleOf = useCallback(\n (ref: DocumentRef): string => {\n const record = store.assets.find((a) => a.id === ref.id)\n return record?.title ?? record?.filename ?? ref.id\n },\n [store.assets],\n )\n const validate = useCallback(\n (doc: CmsDocument) => validateDocument(registry, doc, { localeConfig: ASSETS_LOCALE_CONFIG }),\n [registry],\n )\n const save = useCallback(\n (doc: CmsDocument) => store.updateMetadata(doc.id, { meta: documentToAssetMetaPatch(schema, doc) }),\n [schema, store],\n )\n // The unconfirmed delete (the store's usage gate still applies — a referenced\n // asset comes back `blocked`, surfaced via `lastError`). The UI's delete paths\n // never land here: row/bulk deletes confirm through the reference dialog and\n // dispatch `bulkRemove` below.\n const remove = useCallback((ref: DocumentRef) => store.remove(ref.id), [store])\n // The CONFIRMED delete the bulk/row path dispatches (EC-433): the reference\n // dialog has already enumerated inbound usages (asset-shaped, see\n // `assetReferenceTarget`), so this passes `confirmInUse` exactly like the\n // detail surface's confirmed delete — same blob + thumb + cache handling.\n const bulkRemove = useCallback(\n (ref: DocumentRef) => store.remove(ref.id, { confirmInUse: true }),\n [store],\n )\n\n return useRailViewSource({\n projectId,\n schema,\n capabilities: ASSET_COLLECTION_CAPABILITIES,\n localeConfig: ASSETS_LOCALE_CONFIG,\n documents,\n get,\n titleOf,\n // No `referenceCandidates`/`collections`/`isFieldReadOnly` override: assets carry\n // no relation/asset fields of their own (v1) — nothing cross-collection to\n // resolve — and read-only follows the factory's default `form.readOnly` check.\n detailPath: (id: string) => studioPath.asset(id),\n listPath: studioPath.assets(),\n // EC-433: the delete confirmation must enumerate ASSET-shaped inbound\n // references — the document-shaped default would report \"not referenced\"\n // for a referenced asset.\n referenceTarget: assetReferenceTarget,\n validate,\n create: createUnsupported,\n save,\n remove,\n bulkRemove,\n normalizeSaveResult: normalizeOkResult,\n normalizeRemoveResult: normalizeAssetRemoveResult,\n })\n}\n"]}
1
+ {"version":3,"file":"assets-collection-view-source.js","sourceRoot":"","sources":["../../../src/components/assets/assets-collection-view-source.ts"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useMemo } from 'react'\nimport type { CmsDocument, CollectionDef, DocumentRef, Locale, LocaleConfig } from '@elytracms/core/cms-core'\nimport { createCollectionRegistry, validateDocument } from '@elytracms/core/cms-core'\nimport type { AssetRecord, ReferenceTarget } from '@elytracms/core/persistence'\nimport type { OperationError } from '@elytracms/core/operations'\nimport { studioPath } from '@elytracms/studio/router'\nimport type { DeleteAssetResult } from '@elytracms/studio/lib/assets'\nimport type { CollectionViewSource, CollectionViewCapabilities } from '@elytracms/studio/lib/collection-view/collection-view-source'\nimport { useAssets } from './assets-store'\nimport { SYSTEM_ASSET_FIELDS, pruneMeta } from './asset-metadata'\nimport { metaBagProjection, normalizeOkResult, useRailViewSource } from '../rail-view-source'\n\n/** The assets rail is not localized in v1 (EC-301: asset metadata is non-localized). */\nconst ASSETS_LOCALE: Locale = 'en'\nconst ASSETS_LOCALE_CONFIG: LocaleConfig = {\n default: ASSETS_LOCALE,\n locales: [ASSETS_LOCALE],\n}\n\n/**\n * The capability profile of the assets rail (EC-319, EC-433). Hoisted to a\n * module-level constant (no prop/state dependency) so it's both the value the\n * hook returns AND independently unit-testable.\n *\n * `create` is off — a new asset comes from an upload, never a blank draft.\n * `remove` + `bulk` are ON since EC-433: the list's row/bulk delete used to be\n * off deliberately because the generic `ReferenceConfirmDialog` enumerated\n * inbound references as `{ type: 'document' }` targets — the wrong index shape\n * for an asset — which would have shown \"not referenced\" for an asset that IS\n * referenced. That pre-flight now queries the shape THIS source declares via\n * {@link assetReferenceTarget} (`{ type: 'asset' }`, the same lookup the\n * detail surface runs), and the confirmed delete dispatches the SAME checked\n * store delete the detail surface uses (`deleteAsset`: original + EC-400 thumb\n * blobs, then the record — EC-420 lqips ride on the record). Nothing else\n * applies to assets: no publish/versions/locales/inline-edit/live-URL/\n * hierarchy/import-export.\n */\nexport const ASSET_COLLECTION_CAPABILITIES: CollectionViewCapabilities = {\n publish: false,\n versions: false,\n locales: false,\n inlineEdit: false,\n create: false,\n duplicate: false, // EC-442: blob duplication is a different feature, out of scope\n remove: true,\n bulk: true,\n liveUrl: false,\n hierarchy: false,\n importExport: false,\n adminOnly: false,\n}\n\n/** Read one of the record's fixed, upload-managed columns by field name. */\nfunction systemFieldValue(record: AssetRecord, name: string): unknown {\n switch (name) {\n case 'filename':\n return record.filename\n case 'url':\n return record.url\n case 'contentType':\n return record.contentType\n case 'byteSize':\n return record.byteSize\n case 'width':\n return record.width\n case 'height':\n return record.height\n case 'storageKey':\n return record.storageKey\n case 'title':\n return record.title\n default:\n return undefined\n }\n}\n\n/**\n * Project a stored asset record into the lens's `values` shape (EC-319) — the\n * value-keyed projection the redirects/users rails use (EC-577, RESIDUE-3d): EVERY\n * key of the record's open `meta` bag (EC-301) rides in `values`, declared by the\n * schema or not, so a save hands the bag back whole (D22). Declared fields then\n * take their canonical reading on top — a column never loses to a same-named meta\n * key: a system column (`filename`, `url`, dimensions, …) reads straight off the\n * record, and `alt`, schema-driven but ALSO a legacy record column, reads the meta\n * value falling back to the column (matches delivery's `meta.alt ?? record.alt`\n * read and the image field's options dialog).\n */\nexport function assetToDocument(collection: CollectionDef, record: AssetRecord): CmsDocument {\n const values: Record<string, unknown> = { ...(record.meta ?? {}) }\n for (const field of collection.fields) {\n if (field.name === 'alt') {\n const metaAlt = typeof record.meta?.alt === 'string' ? record.meta.alt : undefined\n values.alt = metaAlt ?? record.alt\n } else if (SYSTEM_ASSET_FIELDS.has(field.name)) {\n values[field.name] = systemFieldValue(record, field.name)\n }\n }\n return { collection: collection.id, id: record.id, values }\n}\n\n/**\n * The inverse of {@link assetToDocument}: the meta bag a save writes back — every\n * value outside the system set (EC-301/EC-311), declared or not, through the ONE\n * value-keyed projection the users/redirects rails share (EC-577, RESIDUE-3d).\n * `meta` is written whole (it replaces the stored bag), so a key the lens never\n * declared must be in it: the schema-driven projection this replaced dropped them\n * on every rail save (D22), while the options dialog and asset detail kept them.\n * System columns are upload-managed and never written here.\n */\nexport function documentToAssetMetaPatch(doc: CmsDocument): Record<string, unknown> {\n return pruneMeta(metaBagProjection(doc.values ?? {}, SYSTEM_ASSET_FIELDS))\n}\n\n/** `create` when `capabilities.create === false` — never reached from the UI\n * (list/detail never call it); an explicit throw rather than a silent no-op. */\nfunction createUnsupported(): DocumentRef {\n throw new Error('assets are created by upload, not the assets rail')\n}\n\n/** Assets' `remove()` returns an `outcome`-discriminated {@link DeleteAssetResult}\n * (not the `{ ok }` shape users/redirects use, EC-327). `'error'` surfaces as-is;\n * `'blocked'` (the unconfirmed usage gate fired — nothing was removed) surfaces\n * as an explicit failure too (EC-433): a delete that left the asset in place\n * must NEVER look like success. It is unreachable from this rail's UI — every\n * delete path confirms through the reference dialog and dispatches with\n * `confirmInUse` — but stays honest for any direct caller. `'not-found'` maps\n * to success (idempotent delete: the desired end state — gone — holds). */\nexport function normalizeAssetRemoveResult(result: DeleteAssetResult): OperationError | null {\n if (result.outcome === 'error') return { code: 'unknown', message: result.message }\n if (result.outcome === 'blocked') {\n return {\n code: 'unknown',\n message: `Asset is referenced in ${result.usageCount} ${result.usageCount === 1 ? 'place' : 'places'} and was not deleted.`,\n }\n }\n return null\n}\n\n/**\n * How an asset row is addressed in the EC-155 reference index (EC-433):\n * `{ type: 'asset' }`, never the document shape — so the delete confirmation's\n * inbound pre-flight counts the SAME references `loadInboundAssetReferenceViews`\n * (the detail surface's check) does.\n */\nexport function assetReferenceTarget(ref: DocumentRef): ReferenceTarget {\n return { type: 'asset', id: ref.id }\n}\n\n/**\n * Assemble a {@link CollectionViewSource} for the **assets rail** (EC-319) — the\n * portable list surface over the existing asset library store instead of a\n * bespoke listing. `schema` is the DEPLOYMENT-resolved `kind:'asset'` collection\n * (EC-311: a project's own declared fields, or a mount's `nav.assets({ fields })`\n * override merged over it — see `resolveAssetsSchema` in `app-views/default-modules.tsx`),\n * never a hardcoded engine default — so a project's `copyright`/`credit`/… fields\n * (and any EC-311 override) drive the list's columns/filters exactly as they\n * drive the image field's options dialog.\n *\n * Must be mounted under an `<AssetsProvider>` (for {@link useAssets}) — already\n * ambient at the project level (EC-195), so callers rarely mount their own.\n *\n * The shared scaffold (`useRailViewSource`, EC-327) owns `viewState`/`lastError`\n * plumbing and the assembled source; this hook supplies only the asset-store\n * reads/writes, the capability profile, and the projections above.\n */\nexport function useAssetsCollectionViewSource(\n projectId: string,\n schema: CollectionDef,\n): CollectionViewSource {\n const store = useAssets()\n const registry = useMemo(() => createCollectionRegistry([schema]), [schema])\n\n const documents = useCallback(\n () => store.assets.map((record) => assetToDocument(schema, record)),\n [store.assets, schema],\n )\n const get = useCallback(\n (ref: DocumentRef): CmsDocument | undefined => {\n const record = store.assets.find((a) => a.id === ref.id)\n return record ? assetToDocument(schema, record) : undefined\n },\n [store.assets, schema],\n )\n const titleOf = useCallback(\n (ref: DocumentRef): string => {\n const record = store.assets.find((a) => a.id === ref.id)\n return record?.title ?? record?.filename ?? ref.id\n },\n [store.assets],\n )\n const validate = useCallback(\n (doc: CmsDocument) => validateDocument(registry, doc, { localeConfig: ASSETS_LOCALE_CONFIG }),\n [registry],\n )\n const save = useCallback(\n (doc: CmsDocument) => store.updateMetadata(doc.id, { meta: documentToAssetMetaPatch(doc) }),\n [store],\n )\n // The unconfirmed delete (the store's usage gate still applies — a referenced\n // asset comes back `blocked`, surfaced via `lastError`). The UI's delete paths\n // never land here: row/bulk deletes confirm through the reference dialog and\n // dispatch `bulkRemove` below.\n const remove = useCallback((ref: DocumentRef) => store.remove(ref.id), [store])\n // The CONFIRMED delete the bulk/row path dispatches (EC-433): the reference\n // dialog has already enumerated inbound usages (asset-shaped, see\n // `assetReferenceTarget`), so this passes `confirmInUse` exactly like the\n // detail surface's confirmed delete — same blob + thumb + cache handling.\n const bulkRemove = useCallback(\n (ref: DocumentRef) => store.remove(ref.id, { confirmInUse: true }),\n [store],\n )\n\n return useRailViewSource({\n projectId,\n schema,\n capabilities: ASSET_COLLECTION_CAPABILITIES,\n localeConfig: ASSETS_LOCALE_CONFIG,\n documents,\n get,\n titleOf,\n // No `referenceCandidates`/`collections`/`isFieldReadOnly` override: assets carry\n // no relation/asset fields of their own (v1) — nothing cross-collection to\n // resolve — and read-only follows the factory's default `form.readOnly` check.\n detailPath: (id: string) => studioPath.asset(id),\n listPath: studioPath.assets(),\n // EC-433: the delete confirmation must enumerate ASSET-shaped inbound\n // references — the document-shaped default would report \"not referenced\"\n // for a referenced asset.\n referenceTarget: assetReferenceTarget,\n validate,\n create: createUnsupported,\n save,\n remove,\n bulkRemove,\n normalizeSaveResult: normalizeOkResult,\n normalizeRemoveResult: normalizeAssetRemoveResult,\n })\n}\n"]}