@elytracms/studio 0.0.100 → 0.0.101

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 (124) hide show
  1. package/convex/analytics-cost.test.ts +1 -1
  2. package/convex/redirect-migration.test.ts +1 -1
  3. package/convex/redirects.ts +2 -2
  4. package/convex/shared-capture.convex.test.ts +165 -0
  5. package/dist/components/ai/ai-chat-panel-lazy.js +7 -2
  6. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
  7. package/dist/components/ai/proposal-card.js +2 -1
  8. package/dist/components/ai/proposal-card.js.map +1 -1
  9. package/dist/components/ai/proposal-unfilled.d.ts +14 -0
  10. package/dist/components/ai/proposal-unfilled.js +33 -0
  11. package/dist/components/ai/proposal-unfilled.js.map +1 -0
  12. package/dist/components/ai/translate-document-action.js +8 -6
  13. package/dist/components/ai/translate-document-action.js.map +1 -1
  14. package/dist/components/app-views/studio-config.d.ts +12 -12
  15. package/dist/components/app-views/studio-config.js.map +1 -1
  16. package/dist/components/builder/builder-store.d.ts +37 -1
  17. package/dist/components/builder/builder-store.js +21 -6
  18. package/dist/components/builder/builder-store.js.map +1 -1
  19. package/dist/components/builder/canvas-chrome-css.d.ts +1 -1
  20. package/dist/components/builder/canvas-chrome-css.js +1 -1
  21. package/dist/components/builder/canvas-chrome-css.js.map +1 -1
  22. package/dist/components/builder/canvas-chrome.d.ts +1 -1
  23. package/dist/components/builder/canvas-chrome.js +1 -1
  24. package/dist/components/builder/canvas-chrome.js.map +1 -1
  25. package/dist/components/builder/canvas-rebase.d.ts +4 -4
  26. package/dist/components/builder/canvas-rebase.js +3 -2
  27. package/dist/components/builder/canvas-rebase.js.map +1 -1
  28. package/dist/components/builder/canvas-render-context.d.ts +1 -1
  29. package/dist/components/builder/canvas-render-context.js +1 -1
  30. package/dist/components/builder/canvas-render-context.js.map +1 -1
  31. package/dist/components/builder/composition-field-editor-lazy.js +2 -1
  32. package/dist/components/builder/composition-field-editor-lazy.js.map +1 -1
  33. package/dist/components/builder/reuse-context.d.ts +8 -2
  34. package/dist/components/builder/reuse-context.js +62 -7
  35. package/dist/components/builder/reuse-context.js.map +1 -1
  36. package/dist/components/builder/reuse.d.ts +106 -70
  37. package/dist/components/builder/reuse.js +125 -108
  38. package/dist/components/builder/reuse.js.map +1 -1
  39. package/dist/components/cms/cms-store.d.ts +9 -1
  40. package/dist/components/cms/cms-store.js +1 -0
  41. package/dist/components/cms/cms-store.js.map +1 -1
  42. package/dist/components/cms/detail-editor.js +13 -13
  43. package/dist/components/cms/detail-editor.js.map +1 -1
  44. package/dist/components/cms/prop-fields.js +1 -1
  45. package/dist/components/cms/prop-fields.js.map +1 -1
  46. package/dist/components/cms/rich-text/lazy.js +2 -1
  47. package/dist/components/cms/rich-text/lazy.js.map +1 -1
  48. package/dist/components/cms/rich-text/toolbar-state.d.ts +1 -1
  49. package/dist/components/cms/rich-text/toolbar-state.js +1 -1
  50. package/dist/components/cms/rich-text/toolbar-state.js.map +1 -1
  51. package/dist/components/cms/use-field-focus.js +3 -3
  52. package/dist/components/cms/use-field-focus.js.map +1 -1
  53. package/dist/components/cms/version-compare-lazy.js +2 -1
  54. package/dist/components/cms/version-compare-lazy.js.map +1 -1
  55. package/dist/components/components/components-index.js +1 -1
  56. package/dist/components/components/components-index.js.map +1 -1
  57. package/dist/components/deployment-setup-gate.d.ts +1 -1
  58. package/dist/components/deployment-setup-gate.js +1 -1
  59. package/dist/components/deployment-setup-gate.js.map +1 -1
  60. package/dist/components/load-failure.d.ts +62 -0
  61. package/dist/components/load-failure.js +110 -0
  62. package/dist/components/load-failure.js.map +1 -0
  63. package/dist/components/notifications/shell-findings.js +3 -3
  64. package/dist/components/notifications/shell-findings.js.map +1 -1
  65. package/dist/customization.d.ts +3 -3
  66. package/dist/customization.js.map +1 -1
  67. package/dist/lib/ai/apply-content.js +99 -28
  68. package/dist/lib/ai/apply-content.js.map +1 -1
  69. package/dist/lib/ai/chat-context.d.ts +73 -7
  70. package/dist/lib/ai/chat-context.js +133 -14
  71. package/dist/lib/ai/chat-context.js.map +1 -1
  72. package/dist/lib/ai/chat-state.d.ts +7 -1
  73. package/dist/lib/ai/chat-state.js.map +1 -1
  74. package/dist/lib/ai/constraints.d.ts +5 -0
  75. package/dist/lib/ai/constraints.js +21 -6
  76. package/dist/lib/ai/constraints.js.map +1 -1
  77. package/dist/lib/ai/fake-provider.d.ts +76 -26
  78. package/dist/lib/ai/fake-provider.js +210 -66
  79. package/dist/lib/ai/fake-provider.js.map +1 -1
  80. package/dist/lib/ai/index.d.ts +1 -0
  81. package/dist/lib/ai/index.js +1 -0
  82. package/dist/lib/ai/index.js.map +1 -1
  83. package/dist/lib/ai/keyless-assistant.d.ts +37 -0
  84. package/dist/lib/ai/keyless-assistant.js +193 -0
  85. package/dist/lib/ai/keyless-assistant.js.map +1 -0
  86. package/dist/lib/ai/shape.d.ts +25 -2
  87. package/dist/lib/ai/shape.js +58 -7
  88. package/dist/lib/ai/shape.js.map +1 -1
  89. package/dist/lib/ai/tools.d.ts +11 -1
  90. package/dist/lib/ai/tools.js +51 -6
  91. package/dist/lib/ai/tools.js.map +1 -1
  92. package/dist/lib/ai/translate-document.js +6 -5
  93. package/dist/lib/ai/translate-document.js.map +1 -1
  94. package/dist/lib/ai/types.d.ts +22 -7
  95. package/dist/lib/ai/types.js.map +1 -1
  96. package/dist/lib/ai/update-fields.js +9 -6
  97. package/dist/lib/ai/update-fields.js.map +1 -1
  98. package/dist/lib/ai/writer-form.d.ts +11 -3
  99. package/dist/lib/ai/writer-form.js +105 -18
  100. package/dist/lib/ai/writer-form.js.map +1 -1
  101. package/dist/lib/auth-errors.d.ts +1 -1
  102. package/dist/lib/auth-errors.js +1 -1
  103. package/dist/lib/auth-errors.js.map +1 -1
  104. package/dist/lib/engine-version.d.ts +1 -1
  105. package/dist/lib/engine-version.js +1 -1
  106. package/dist/lib/engine-version.js.map +1 -1
  107. package/dist/lib/i18n/messages.de.d.ts +1 -1
  108. package/dist/lib/i18n/messages.de.js +23 -7
  109. package/dist/lib/i18n/messages.de.js.map +1 -1
  110. package/dist/lib/i18n/messages.en.d.ts +18 -3
  111. package/dist/lib/i18n/messages.en.js +29 -9
  112. package/dist/lib/i18n/messages.en.js.map +1 -1
  113. package/dist/lib/operations/cms-persistence.d.ts +18 -0
  114. package/dist/lib/operations/cms-persistence.js +47 -0
  115. package/dist/lib/operations/cms-persistence.js.map +1 -1
  116. package/dist/lib/workspace/index.d.ts +3 -3
  117. package/dist/lib/workspace/index.js +3 -3
  118. package/dist/lib/workspace/index.js.map +1 -1
  119. package/dist/router/navigate-once.d.ts +1 -1
  120. package/dist/router/navigate-once.js.map +1 -1
  121. package/dist/router/root.d.ts +3 -2
  122. package/dist/router/root.js +25 -4
  123. package/dist/router/root.js.map +1 -1
  124. package/package.json +3 -3
@@ -485,7 +485,7 @@ function pick<T>(random: () => number, weighted: readonly (readonly [T, number])
485
485
  }
486
486
 
487
487
  const SLUGS = [
488
- 'sommerfest-im-garten-der-konditorei',
488
+ 'sommerfest-im-garten-der-stadthalle',
489
489
  'unsere-torten-fuer-hochzeiten',
490
490
  'ueber-uns',
491
491
  'kontakt-und-anfahrt',
@@ -14,7 +14,7 @@ afterEach(() => {
14
14
 
15
15
  /**
16
16
  * EC-616 — the redirect move from the `redirects` table into `records` (kind `redirect`)
17
- * is a STORED-DATA move (Konditorei and Naturkinder hold real redirects), done the EC-587
17
+ * is a STORED-DATA move (both production consumers hold real redirects), done the EC-587
18
18
  * way: writes to the new home, a marked tolerant read of the old one, an idempotent
19
19
  * per-deployment migration. These run the REAL functions on convex-test over legacy rows
20
20
  * inserted as a pre-EC-616 deployment holds them, and pin "no gap": a deployment with the
@@ -22,7 +22,7 @@ import { recordEntryFromWire } from '@elytracms/core/persistence'
22
22
  * `export * from '@elytracms/studio/convex/redirects'` line are unchanged. Content-gated
23
23
  * (a viewer reads, an editor writes), NOT admin-only — redirects are content.
24
24
  *
25
- * ## The move from the `redirects` table (stored data — KON and NK hold real redirects)
25
+ * ## The move from the `redirects` table (stored data — both production consumers hold real redirects)
26
26
  *
27
27
  * The EC-587 precedent, with no gap:
28
28
  *
@@ -92,7 +92,7 @@ export async function loadRedirectEntries(ctx: QueryCtx, projectId: string): Pro
92
92
  * identical before and after by construction (the tolerant read), so the move needs no
93
93
  * revalidation — through the triggers it would schedule two sweeps per row (an insert
94
94
  * into `records`, a delete from `redirects`). One transaction: Convex bounds it at 8192
95
- * written rows, far above any editorial redirect set (KON's import holds 192).
95
+ * written rows, far above any editorial redirect set (a production site's import holds 192).
96
96
  */
97
97
  export const migrateLegacy = (internalMutationGeneric as MutationBuilder<DataModel, 'internal'>)({
98
98
  args: {},
@@ -0,0 +1,165 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { convexTest } from 'convex-test'
3
+ import type { ConvexHttpClient } from 'convex/browser'
4
+ import { getFunctionName } from 'convex/server'
5
+ import type { FunctionReference } from 'convex/server'
6
+ import { z } from 'zod'
7
+ import { UNCOPYABLE_VALUE, createOperations } from '@elytracms/core/operations'
8
+ import type { AuthorizationHook, OperationEvent, OperationIssue } from '@elytracms/core/operations'
9
+ import { defineRecordKind } from '@elytracms/core/persistence'
10
+ import { ConvexPersistenceAdapter, UNCARRIABLE_VALUE } from '../src/lib/persistence/convex-adapter'
11
+ import schema from './schema'
12
+ import { defineRecordFunctions } from './record-functions'
13
+
14
+ /**
15
+ * EC-713 (Codex round 7 F32 residual), the Convex half — Codex's 24 shared/private cases through
16
+ * `operations.records(kind)` over the real `ConvexPersistenceAdapter` (convex-test, signed-in
17
+ * editor). This project runs in the edge runtime, whose `structuredClone` shares a
18
+ * `SharedArrayBuffer` too (measured, wave-0102): the capture's walk of the clone is what refuses it.
19
+ *
20
+ * Before (measured on the wave base 188e35bf): a shared input was captured, AUTHORIZED, and only then
21
+ * refused by the record repository's wire check (`uncarriable-value`, `failed`) — so Codex called it
22
+ * no Convex bypass, but authorization had judged memory the caller could still change. Now it is the
23
+ * capture's `uncopyable-value` (`rejected`) before any step: the hook never sees it, no mutation runs.
24
+ * The private controls keep their outcome: an `ArrayBuffer` crosses the wire and the authorized byte
25
+ * is stored; a typed array, a `DataView` or a `Map` is still the wire's located refusal.
26
+ *
27
+ * The in-memory half is `core/src/operations/shared-capture.test.ts`.
28
+ */
29
+
30
+ type Binary = ArrayBuffer | SharedArrayBuffer | Uint8Array | DataView
31
+ type Segment = string | number
32
+
33
+ const PROJECT = 'site'
34
+ const STAMP = '2026-10-31T00:00:00.000Z'
35
+
36
+ beforeEach(() => {
37
+ vi.useFakeTimers()
38
+ vi.setSystemTime(STAMP)
39
+ })
40
+ afterEach(() => vi.useRealTimers())
41
+
42
+ const byteOf = (value: Binary): number =>
43
+ value instanceof Uint8Array ? Number(value[0]) : value instanceof DataView ? value.getUint8(0) : Number(new Uint8Array(value)[0])
44
+
45
+ const PLACES = [
46
+ { name: 'column', put: (v: Binary): unknown => v, get: (v: unknown) => v as Binary, path: ['value'] as Segment[] },
47
+ {
48
+ name: 'object',
49
+ put: (v: Binary): unknown => ({ nested: { bytes: v } }),
50
+ get: (v: unknown) => (v as { nested: { bytes: Binary } }).nested.bytes,
51
+ path: ['value', 'nested', 'bytes'] as Segment[],
52
+ },
53
+ { name: 'list', put: (v: Binary): unknown => [v], get: (v: unknown) => (v as Binary[])[0]!, path: ['value', 0] as Segment[] },
54
+ {
55
+ name: 'map',
56
+ put: (v: Binary): unknown => new Map([['bytes', v]]),
57
+ get: (v: unknown) => (v as Map<string, Binary>).get('bytes')!,
58
+ path: ['value'] as Segment[],
59
+ },
60
+ ] as const
61
+ const SHAPES = ['buffer', 'uint8', 'dataView'] as const
62
+
63
+ function issuesOf(result: { ok: boolean; error?: unknown }): readonly OperationIssue[] {
64
+ return !result.ok && (result.error as { code: string }).code === 'validation' ? (result.error as { issues: readonly OperationIssue[] }).issues : []
65
+ }
66
+
67
+ /** The real client adapter over convex-test, signed in as an editor; the operations over it. */
68
+ async function connected(read: (value: unknown) => number, authorize: AuthorizationHook) {
69
+ const kind = defineRecordKind({ kind: 'byte', module: 'bytes', fields: { value: z.unknown().transform(read) } })
70
+ const t = convexTest(schema, {
71
+ './_generated/api.ts': async () => ({}),
72
+ [`./${kind.module}.ts`]: async () => defineRecordFunctions(kind),
73
+ })
74
+ await t.run(async (ctx) => {
75
+ await ctx.db.insert('members', { userId: 'editor', role: 'editor', createdAt: STAMP, updatedAt: STAMP })
76
+ })
77
+ const editor = t.withIdentity({ subject: 'editor', issuer: 'https://convex.test' })
78
+ const calls: string[] = []
79
+ const client = {
80
+ query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => editor.query(ref, args ?? {}),
81
+ mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => {
82
+ calls.push(getFunctionName(ref))
83
+ return editor.mutation(ref, args ?? {})
84
+ },
85
+ } as unknown as ConvexHttpClient
86
+ const adapter = new ConvexPersistenceAdapter(client, PROJECT)
87
+ const ops = createOperations({ adapter, collections: () => [], validateDocument: () => [], authorize })
88
+ const events: OperationEvent[] = []
89
+ ops.events.subscribe((event) => events.push(event))
90
+ return { t, direct: adapter.records(kind), records: ops.records(kind), events, calls }
91
+ }
92
+
93
+ describe('EC-713 — Codex round 7 F32 residual on Convex: shared memory is refused at the capture, before authorization', () => {
94
+ for (const shape of SHAPES)
95
+ for (const place of PLACES)
96
+ for (const shared of [false, true]) {
97
+ // What the wire carries (Convex's own `convexToJson`): an `ArrayBuffer`, in a column, an object or a list.
98
+ const carriable = shape === 'buffer' && place.name !== 'map'
99
+ const label = shared
100
+ ? 'shared: `uncopyable-value` before authorization, no mutation'
101
+ : carriable
102
+ ? 'private control: the authorized 1 is stored'
103
+ : 'private control: still the wire’s located refusal, after authorization'
104
+ it(`${shape}/${place.name}/${label}`, async () => {
105
+ const read = (v: unknown) => byteOf(place.get(v))
106
+ let reached!: () => void
107
+ let resume!: () => void
108
+ const atHook = new Promise<void>((resolve) => (reached = resolve))
109
+ const gate = new Promise<void>((resolve) => (resume = resolve))
110
+ const judged: number[] = []
111
+ const { t, records, events, calls } = await connected(read, async (request) => {
112
+ const value = read((request.input as { value: unknown }).value)
113
+ judged.push(value)
114
+ if (value !== 1) return { allow: false, reason: 'only byte 1' }
115
+ reached()
116
+ await gate
117
+ return { allow: true }
118
+ })
119
+ const carry = (buffer: ArrayBuffer | SharedArrayBuffer) =>
120
+ place.put(shape === 'buffer' ? buffer : shape === 'uint8' ? new Uint8Array(buffer) : new DataView(buffer))
121
+
122
+ // The instrument: the hook denies byte 9, before any request.
123
+ const denied = new ArrayBuffer(1)
124
+ new Uint8Array(denied)[0] = 9
125
+ expect(await records.save({ projectId: PROJECT, id: 'denied', value: carry(denied) })).toMatchObject({
126
+ ok: false,
127
+ error: { code: 'unauthorized' },
128
+ })
129
+
130
+ const buffer = shared ? new SharedArrayBuffer(1) : new ArrayBuffer(1)
131
+ new Uint8Array(buffer)[0] = 1
132
+ const pending = records.save({ projectId: PROJECT, id: 'r', value: carry(buffer) })
133
+ const first = await Promise.race([pending.then(() => 'returned' as const), atHook.then(() => 'authorizing' as const)])
134
+ if (first === 'authorizing') {
135
+ new Uint8Array(buffer)[0] = 9
136
+ resume()
137
+ }
138
+ const result = await pending
139
+ const issues = issuesOf(result)
140
+ const stored = await t.run(async (ctx) => ctx.db.query('records').collect())
141
+
142
+ if (shared) {
143
+ expect(issues.map((issue) => [issue.code, issue.path])).toEqual([[UNCOPYABLE_VALUE, place.path]])
144
+ expect(issues[0]!.message).toContain('shared memory')
145
+ expect(judged).toEqual([9])
146
+ expect(events.map((event) => event.status)).toEqual(['denied', 'rejected'])
147
+ expect(calls).toEqual([])
148
+ expect(stored).toEqual([])
149
+ return
150
+ }
151
+ expect(first).toBe('authorizing')
152
+ expect(judged).toEqual([9, 1])
153
+ if (carriable) {
154
+ expect(result).toMatchObject({ ok: true, value: { id: 'r', value: 1 } })
155
+ expect(calls).toEqual(['bytes:save'])
156
+ expect(stored).toHaveLength(1)
157
+ return
158
+ }
159
+ expect(issues.map((issue) => [issue.code, issue.path?.[0]])).toEqual([[UNCARRIABLE_VALUE, 'value']])
160
+ expect(events.map((event) => event.status)).toEqual(['denied', 'failed'])
161
+ expect(calls).toEqual([])
162
+ expect(stored).toEqual([])
163
+ })
164
+ }
165
+ })
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Suspense, lazy } from 'react';
3
3
  import { Text } from '@elytracms/studio/ui';
4
4
  import { translate } from '../../lib/i18n/index.js';
5
+ import { LazySurfaceBoundary } from '../load-failure.js';
5
6
  /**
6
7
  * Lazy boundary for the AI chat panel (EC-199, EC-331/EC-334 bundle discipline). The
7
8
  * panel and everything it imports — `useChat` + the AI SDK (`ai` / `@ai-sdk/react`),
@@ -12,10 +13,14 @@ import { translate } from '../../lib/i18n/index.js';
12
13
  * `version-compare-lazy` / `rich-text/lazy`.
13
14
  */
14
15
  const AiChatPanelImpl = lazy(() => import('./ai-chat-panel.js').then((module) => ({ default: module.AiChatPanel })));
16
+ /** The panel's aside — shared by the loading state and a failed load (EC-717). */
17
+ function AiPanelFrame({ children }) {
18
+ return (_jsx("aside", { className: "flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card", children: children }));
19
+ }
15
20
  function AiPanelFallback() {
16
- return (_jsx("aside", { className: "flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card", children: _jsx("div", { className: "grid min-h-0 flex-1 place-items-center p-6", children: _jsx(Text, { size: "sm", tone: "muted", children: translate('ai.panel.loading') }) }) }));
21
+ return (_jsx(AiPanelFrame, { children: _jsx("div", { className: "grid min-h-0 flex-1 place-items-center p-6", children: _jsx(Text, { size: "sm", tone: "muted", children: translate('ai.panel.loading') }) }) }));
17
22
  }
18
23
  export function AiChatPanel() {
19
- return (_jsx(Suspense, { fallback: _jsx(AiPanelFallback, {}), children: _jsx(AiChatPanelImpl, {}) }));
24
+ return (_jsx(LazySurfaceBoundary, { frame: (notice) => _jsx(AiPanelFrame, { children: notice }), children: _jsx(Suspense, { fallback: _jsx(AiPanelFallback, {}), children: _jsx(AiChatPanelImpl, {}) }) }));
20
25
  }
21
26
  //# sourceMappingURL=ai-chat-panel-lazy.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ai-chat-panel-lazy.js","sourceRoot":"","sources":["../../../src/components/ai/ai-chat-panel-lazy.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAE1C;;;;;;;;GAQG;AACH,MAAM,eAAe,GAAG,IAAI,CAAC,GAAG,EAAE,CAChC,MAAM,CAAC,iBAAiB,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC,CAC9E,CAAA;AAED,SAAS,eAAe;IACtB,OAAO,CACL,gBAAO,SAAS,EAAC,gFAAgF,YAC/F,cAAK,SAAS,EAAC,4CAA4C,YACzD,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,kBAAkB,CAAC,GACzB,GACH,GACA,CACT,CAAA;AACH,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,OAAO,CACL,KAAC,QAAQ,IAAC,QAAQ,EAAE,KAAC,eAAe,KAAG,YACrC,KAAC,eAAe,KAAG,GACV,CACZ,CAAA;AACH,CAAC","sourcesContent":["import { Suspense, lazy } from 'react'\nimport { Text } from '@elytracms/studio/ui'\nimport { translate } from '../../lib/i18n'\n\n/**\n * Lazy boundary for the AI chat panel (EC-199, EC-331/EC-334 bundle discipline). The\n * panel and everything it imports — `useChat` + the AI SDK (`ai` / `@ai-sdk/react`),\n * the client tool executor, `applyContentWithShape`, the provider adapters — load\n * ONLY when a configured studio actually opens the panel. An unconfigured studio never\n * renders the toggle, never mounts this, and so pulls none of the AI chunk onto the CMS\n * critical path (pinned by the `lib/ai` bundle test). Same pattern as\n * `version-compare-lazy` / `rich-text/lazy`.\n */\nconst AiChatPanelImpl = lazy(() =>\n import('./ai-chat-panel').then((module) => ({ default: module.AiChatPanel })),\n)\n\nfunction AiPanelFallback() {\n return (\n <aside className=\"flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card\">\n <div className=\"grid min-h-0 flex-1 place-items-center p-6\">\n <Text size=\"sm\" tone=\"muted\">\n {translate('ai.panel.loading')}\n </Text>\n </div>\n </aside>\n )\n}\n\nexport function AiChatPanel() {\n return (\n <Suspense fallback={<AiPanelFallback />}>\n <AiChatPanelImpl />\n </Suspense>\n )\n}\n"]}
1
+ {"version":3,"file":"ai-chat-panel-lazy.js","sourceRoot":"","sources":["../../../src/components/ai/ai-chat-panel-lazy.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,OAAO,CAAA;AAEtC,OAAO,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAErD;;;;;;;;GAQG;AACH,MAAM,eAAe,GAAG,IAAI,CAAC,GAAG,EAAE,CAChC,MAAM,CAAC,iBAAiB,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC,CAC9E,CAAA;AAED,kFAAkF;AAClF,SAAS,YAAY,CAAC,EAAE,QAAQ,EAA2B;IACzD,OAAO,CACL,gBAAO,SAAS,EAAC,gFAAgF,YAC9F,QAAQ,GACH,CACT,CAAA;AACH,CAAC;AAED,SAAS,eAAe;IACtB,OAAO,CACL,KAAC,YAAY,cACX,cAAK,SAAS,EAAC,4CAA4C,YACzD,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,kBAAkB,CAAC,GACzB,GACH,GACO,CAChB,CAAA;AACH,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,OAAO,CACL,KAAC,mBAAmB,IAAC,KAAK,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,KAAC,YAAY,cAAE,MAAM,GAAgB,YAC3E,KAAC,QAAQ,IAAC,QAAQ,EAAE,KAAC,eAAe,KAAG,YACrC,KAAC,eAAe,KAAG,GACV,GACS,CACvB,CAAA;AACH,CAAC","sourcesContent":["import { Suspense, lazy } from 'react'\nimport type { ReactNode } from 'react'\nimport { Text } from '@elytracms/studio/ui'\nimport { translate } from '../../lib/i18n'\nimport { LazySurfaceBoundary } from '../load-failure'\n\n/**\n * Lazy boundary for the AI chat panel (EC-199, EC-331/EC-334 bundle discipline). The\n * panel and everything it imports — `useChat` + the AI SDK (`ai` / `@ai-sdk/react`),\n * the client tool executor, `applyContentWithShape`, the provider adapters — load\n * ONLY when a configured studio actually opens the panel. An unconfigured studio never\n * renders the toggle, never mounts this, and so pulls none of the AI chunk onto the CMS\n * critical path (pinned by the `lib/ai` bundle test). Same pattern as\n * `version-compare-lazy` / `rich-text/lazy`.\n */\nconst AiChatPanelImpl = lazy(() =>\n import('./ai-chat-panel').then((module) => ({ default: module.AiChatPanel })),\n)\n\n/** The panel's aside — shared by the loading state and a failed load (EC-717). */\nfunction AiPanelFrame({ children }: { children: ReactNode }) {\n return (\n <aside className=\"flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card\">\n {children}\n </aside>\n )\n}\n\nfunction AiPanelFallback() {\n return (\n <AiPanelFrame>\n <div className=\"grid min-h-0 flex-1 place-items-center p-6\">\n <Text size=\"sm\" tone=\"muted\">\n {translate('ai.panel.loading')}\n </Text>\n </div>\n </AiPanelFrame>\n )\n}\n\nexport function AiChatPanel() {\n return (\n <LazySurfaceBoundary frame={(notice) => <AiPanelFrame>{notice}</AiPanelFrame>}>\n <Suspense fallback={<AiPanelFallback />}>\n <AiChatPanelImpl />\n </Suspense>\n </LazySurfaceBoundary>\n )\n}\n"]}
@@ -11,6 +11,7 @@ import { useCmsCollectionViewSource } from '../collection-view/cms-collection-vi
11
11
  import { CollectionViewProvider } from '../collection-view/collection-view-context.js';
12
12
  import { DocumentDiffView } from '../cms/document-diff-view.js';
13
13
  import { ProposalIssues } from './proposal-issues.js';
14
+ import { ProposalUnfilled } from './proposal-unfilled.js';
14
15
  import { TranslateSkipped } from './translate-skipped.js';
15
16
  /**
16
17
  * The AI proposal review (EC-199, extended by EC-332/EC-378): a valid,
@@ -102,7 +103,7 @@ export function ProposalCardView({ proposal, collection, source, onApply, onDisc
102
103
  // roomy full-height sheet is offered for a large proposal (never a modal form).
103
104
  const [reviewOpen, setReviewOpen] = useState(false);
104
105
  const reviewable = diff !== null && source !== null;
105
- return (_jsxs(Stack, { gap: "2", className: "overflow-hidden rounded-xl border border-border bg-card", children: [_jsxs(Row, { gap: "2", align: "center", className: "border-b border-border/60 bg-muted/30 px-3 py-2", children: [_jsx(Sparkles, { className: "size-4 shrink-0 text-primary" }), _jsx(Text, { size: "sm", weight: "medium", className: "min-w-0 truncate", children: captions.title }), _jsx(Spacer, {}), applied ? _jsx(Badge, { variant: "success", children: translate('ai.proposal.applied') }) : null, rejected ? _jsx(Badge, { variant: "outline", children: translate('ai.proposal.discarded') }) : null, reviewable ? (_jsx(Button, { size: "icon-sm", variant: "ghost", onClick: () => setReviewOpen(true), title: translate('ai.proposal.expandReview'), "aria-label": translate('ai.proposal.expandReview'), children: _jsx(Maximize2, {}) })) : null] }), _jsx("div", { className: "px-3", children: reviewable ? (_jsx(CollectionViewProvider, { source: source, children: _jsx(DocumentDiffView, { diff: diff, collection: collection, columnHeaders: columnHeaders, layout: "stacked" }) })) : (_jsx(Text, { size: "xs", tone: "muted", className: "py-2", children: translate('ai.proposal.unavailable') })) }), _jsx(ProposalIssues, { issues: proposal.issues, className: "px-3" }), proposal.skipped && proposal.skipped.length > 0 ? (_jsx("div", { className: "px-3", children: _jsx(TranslateSkipped, { skipped: proposal.skipped, collection: collection }) })) : null, _jsx(Row, { gap: "2", align: "center", className: "border-t border-border/60 px-3 py-2", children: proposal.status === 'pending' ? (_jsxs(_Fragment, { children: [_jsx(Text, { size: "xs", tone: "muted", className: "min-w-0 flex-1", children: translate('ai.proposal.reviewHint') }), _jsxs(Button, { size: "sm", variant: "ghost", onClick: onDiscard, children: [_jsx(X, {}), " ", translate('ai.proposal.discard')] }), _jsxs(Button, { size: "sm", onClick: onApply, children: [_jsx(Check, {}), " ", translate('ai.proposal.apply')] })] })) : applied ? (_jsxs(_Fragment, { children: [_jsx(Spacer, {}), _jsxs(Button, { size: "sm", variant: "outline", onClick: onOpen, children: [_jsx(ArrowUpRight, {}), " ", translate('ai.proposal.open')] })] })) : (_jsx(Text, { size: "xs", tone: "muted", children: translate('ai.proposal.discardedNote') })) }), reviewable && reviewOpen ? (_jsx(ProposalReviewSheet, { title: captions.title, diff: diff, collection: collection, source: source, columnHeaders: columnHeaders, issues: proposal.issues, status: proposal.status, onApply: onApply, onDiscard: onDiscard, onClose: () => setReviewOpen(false) })) : null] }));
106
+ return (_jsxs(Stack, { gap: "2", className: "overflow-hidden rounded-xl border border-border bg-card", children: [_jsxs(Row, { gap: "2", align: "center", className: "border-b border-border/60 bg-muted/30 px-3 py-2", children: [_jsx(Sparkles, { className: "size-4 shrink-0 text-primary" }), _jsx(Text, { size: "sm", weight: "medium", className: "min-w-0 truncate", children: captions.title }), _jsx(Spacer, {}), applied ? _jsx(Badge, { variant: "success", children: translate('ai.proposal.applied') }) : null, rejected ? _jsx(Badge, { variant: "outline", children: translate('ai.proposal.discarded') }) : null, reviewable ? (_jsx(Button, { size: "icon-sm", variant: "ghost", onClick: () => setReviewOpen(true), title: translate('ai.proposal.expandReview'), "aria-label": translate('ai.proposal.expandReview'), children: _jsx(Maximize2, {}) })) : null] }), _jsx("div", { className: "px-3", children: reviewable ? (_jsx(CollectionViewProvider, { source: source, children: _jsx(DocumentDiffView, { diff: diff, collection: collection, columnHeaders: columnHeaders, layout: "stacked" }) })) : (_jsx(Text, { size: "xs", tone: "muted", className: "py-2", children: translate('ai.proposal.unavailable') })) }), _jsx(ProposalIssues, { issues: proposal.issues, className: "px-3" }), proposal.skipped && proposal.skipped.length > 0 ? (_jsx("div", { className: "px-3", children: _jsx(TranslateSkipped, { skipped: proposal.skipped, collection: collection }) })) : null, proposal.unfilled && proposal.unfilled.length > 0 ? (_jsx("div", { className: "px-3", children: _jsx(ProposalUnfilled, { unfilled: proposal.unfilled, collection: collection }) })) : null, _jsx(Row, { gap: "2", align: "center", className: "border-t border-border/60 px-3 py-2", children: proposal.status === 'pending' ? (_jsxs(_Fragment, { children: [_jsx(Text, { size: "xs", tone: "muted", className: "min-w-0 flex-1", children: translate('ai.proposal.reviewHint') }), _jsxs(Button, { size: "sm", variant: "ghost", onClick: onDiscard, children: [_jsx(X, {}), " ", translate('ai.proposal.discard')] }), _jsxs(Button, { size: "sm", onClick: onApply, children: [_jsx(Check, {}), " ", translate('ai.proposal.apply')] })] })) : applied ? (_jsxs(_Fragment, { children: [_jsx(Spacer, {}), _jsxs(Button, { size: "sm", variant: "outline", onClick: onOpen, children: [_jsx(ArrowUpRight, {}), " ", translate('ai.proposal.open')] })] })) : (_jsx(Text, { size: "xs", tone: "muted", children: translate('ai.proposal.discardedNote') })) }), reviewable && reviewOpen ? (_jsx(ProposalReviewSheet, { title: captions.title, diff: diff, collection: collection, source: source, columnHeaders: columnHeaders, issues: proposal.issues, status: proposal.status, onApply: onApply, onDiscard: onDiscard, onClose: () => setReviewOpen(false) })) : null] }));
106
107
  }
107
108
  /**
108
109
  * The expandable review surface (EC-382): the proposal's diff promoted into a
@@ -1 +1 @@
1
- {"version":3,"file":"proposal-card.js","sourceRoot":"","sources":["../../../src/components/ai/proposal-card.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useMemo, useState } from 'react'\nimport { ArrowUpRight, Check, Maximize2, Sparkles, X } from 'lucide-react'\nimport { diffDocuments } from '@elytracms/core/content'\nimport type { DocumentDiff } from '@elytracms/core/content'\nimport { resolveLabel } from '@elytracms/core/cms-core'\nimport type { CmsValidationIssue, CollectionDef } from '@elytracms/core/cms-core'\nimport { Badge, Button, ExpandableSurface, Row, Spacer, Stack, Text } from '@elytracms/studio/ui'\nimport { translate } from '../../lib/i18n'\nimport { proposalDocuments } from '../../lib/ai'\nimport type { ProposalOrigin, ProposalReview } from '../../lib/ai'\nimport type { CollectionViewSource } from '../../lib/collection-view/collection-view-source'\nimport { useCms } from '../cms/cms-store'\nimport { useCmsCollectionViewSource } from '../collection-view/cms-collection-view-source'\nimport { CollectionViewProvider } from '../collection-view/collection-view-context'\nimport { DocumentDiffView } from '../cms/document-diff-view'\nimport { ProposalIssues } from './proposal-issues'\nimport { TranslateSkipped } from './translate-skipped'\n\n/**\n * The AI proposal review (EC-199, extended by EC-332/EC-378): a valid,\n * leash-compliant proposal the editor reviews as a DIFF (the shared EC-317\n * {@link DocumentDiffView} — never a second renderer) before applying it as a\n * draft or discarding it. Create proposals diff `empty-before` vs\n * `proposed-after`; translate/update proposals diff the EXISTING document\n * (old → new). Apply/discard are handled by the panel (the operations write +\n * provenance snapshot happen there); this card is the surface.\n *\n * {@link ProposalCard} is the CMS-wired shell; {@link ProposalCardView} is the\n * presentational card over an injected collection + view source (node-smoked\n * with `renderToStaticMarkup`, house pattern).\n *\n * ## EC-496 — the proposal's own findings\n *\n * The diff answers *what changes*; {@link ProposalIssues} answers *what the engine\n * flagged about it*, immediately above the Apply/Discard row so it is read while the\n * decision is being made. Two calls made here, both deliberate:\n *\n * **An error on the card does NOT block apply.** Grounded before deciding\n * (`file:line`, read 2026-08-01): every producer already filters this list to\n * non-error findings (`lib/ai/apply-content.ts:259`, `lib/ai/update-fields.ts:442`,\n * `lib/ai/tools.ts:764`), so a card-blocking gate would be dead code today. More\n * importantly the real gate already exists one layer up and is strictly better\n * placed: EC-473 made apply RE-VALIDATE against the session as it is NOW\n * (`ai-chat-panel.tsx:371`) and refuse with `ai.error.applyInvalid` when the write\n * would INTRODUCE an error. A build-time snapshot cannot see an edit the editor made\n * since, and gating on pre-existing brokenness is exactly what EC-473 argued makes an\n * assistant unusable on real content. So this surface REPORTS truthfully — errors\n * render as errors and sort first ({@link orderedProposalIssues}) — and the gate\n * stays where it can see the truth.\n *\n * **`attempts` is not rendered** (the EC-496 scope-2 owner call). The repair-loop\n * count is provenance about the MODEL, not about the content: a proposal repaired on\n * the second attempt passed the identical validation gate as a first-shot one, so the\n * number changes nothing an editor can act on — and spending sidebar space plus a new\n * catalog string on it would trade a real signal (the findings) for a trivia line. The\n * field stays on `ChatProposal` (it is deterministic, cheap, and the natural input for\n * a future AI-diagnostics surface); it simply has no place on the editor's card.\n */\n\n/** The card's origin-dependent captions (title + diff column headers). */\nexport function proposalCaptions(\n origin: ProposalOrigin,\n collectionLabel: string,\n): { title: string; unified: string; before: string; after: string } {\n if (origin === 'translate') {\n return {\n title: translate('ai.translate.proposalTitle', { collection: collectionLabel }),\n unified: translate('ai.proposal.diffUnified'),\n before: translate('ai.translate.diffBefore'),\n after: translate('ai.translate.diffAfter'),\n }\n }\n if (origin === 'update') {\n return {\n title: translate('ai.update.proposalTitle', { collection: collectionLabel }),\n unified: translate('ai.proposal.diffUnified'),\n before: translate('ai.update.diffBefore'),\n after: translate('ai.update.diffAfter'),\n }\n }\n return {\n title: translate('ai.proposal.title', { collection: collectionLabel }),\n unified: translate('ai.proposal.diffUnified'),\n before: translate('ai.proposal.diffBefore'),\n after: translate('ai.proposal.diffAfter'),\n }\n}\n\nexport function ProposalCard({\n proposal,\n onApply,\n onDiscard,\n onOpen,\n}: {\n proposal: ProposalReview\n onApply: () => void\n onDiscard: () => void\n onOpen: () => void\n}) {\n const cms = useCms()\n const collection = cms.getCollection(proposal.collection)\n const source = useCmsCollectionViewSource(proposal.collection)\n return (\n <ProposalCardView\n proposal={proposal}\n collection={collection}\n source={source}\n onApply={onApply}\n onDiscard={onDiscard}\n onOpen={onOpen}\n />\n )\n}\n\n/** The presentational proposal card over an injected collection + view source. */\nexport function ProposalCardView({\n proposal,\n collection,\n source,\n onApply,\n onDiscard,\n onOpen,\n}: {\n proposal: ProposalReview\n collection: CollectionDef | undefined\n source: CollectionViewSource | null\n onApply: () => void\n onDiscard: () => void\n onOpen: () => void\n}) {\n const diff = useMemo(() => {\n if (!collection) return null\n const { before, after } = proposalDocuments(proposal.patch, proposal.before)\n return diffDocuments(collection, before, after)\n }, [collection, proposal.patch, proposal.before])\n\n const collectionLabel =\n (collection?.form?.labelSingular && resolveLabel(collection.form.labelSingular)) ??\n (collection?.form?.label && resolveLabel(collection.form.label)) ??\n proposal.collection\n\n const captions = proposalCaptions(proposal.origin, collectionLabel)\n const applied = proposal.status === 'applied'\n const rejected = proposal.status === 'rejected'\n const columnHeaders = { unified: captions.unified, before: captions.before, after: captions.after }\n\n // EC-382 — the expandable review surface: the sidebar card stays stacked; a\n // roomy full-height sheet is offered for a large proposal (never a modal form).\n const [reviewOpen, setReviewOpen] = useState(false)\n const reviewable = diff !== null && source !== null\n\n return (\n <Stack\n gap=\"2\"\n className=\"overflow-hidden rounded-xl border border-border bg-card\"\n >\n <Row gap=\"2\" align=\"center\" className=\"border-b border-border/60 bg-muted/30 px-3 py-2\">\n <Sparkles className=\"size-4 shrink-0 text-primary\" />\n <Text size=\"sm\" weight=\"medium\" className=\"min-w-0 truncate\">\n {captions.title}\n </Text>\n <Spacer />\n {applied ? <Badge variant=\"success\">{translate('ai.proposal.applied')}</Badge> : null}\n {rejected ? <Badge variant=\"outline\">{translate('ai.proposal.discarded')}</Badge> : null}\n {reviewable ? (\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n onClick={() => setReviewOpen(true)}\n title={translate('ai.proposal.expandReview')}\n aria-label={translate('ai.proposal.expandReview')}\n >\n <Maximize2 />\n </Button>\n ) : null}\n </Row>\n\n <div className=\"px-3\">\n {reviewable ? (\n <CollectionViewProvider source={source!}>\n <DocumentDiffView\n diff={diff!}\n collection={collection!}\n columnHeaders={columnHeaders}\n layout=\"stacked\"\n />\n </CollectionViewProvider>\n ) : (\n <Text size=\"xs\" tone=\"muted\" className=\"py-2\">\n {translate('ai.proposal.unavailable')}\n </Text>\n )}\n </div>\n\n {/* EC-496 — the engine's surviving findings, last thing before the actions. */}\n <ProposalIssues issues={proposal.issues} className=\"px-3\" />\n {/* EC-593 — what a translation could not translate, listed (never skipped silently). */}\n {proposal.skipped && proposal.skipped.length > 0 ? (\n <div className=\"px-3\">\n <TranslateSkipped skipped={proposal.skipped} collection={collection} />\n </div>\n ) : null}\n\n <Row gap=\"2\" align=\"center\" className=\"border-t border-border/60 px-3 py-2\">\n {proposal.status === 'pending' ? (\n <>\n <Text size=\"xs\" tone=\"muted\" className=\"min-w-0 flex-1\">\n {translate('ai.proposal.reviewHint')}\n </Text>\n <Button size=\"sm\" variant=\"ghost\" onClick={onDiscard}>\n <X /> {translate('ai.proposal.discard')}\n </Button>\n <Button size=\"sm\" onClick={onApply}>\n <Check /> {translate('ai.proposal.apply')}\n </Button>\n </>\n ) : applied ? (\n <>\n <Spacer />\n <Button size=\"sm\" variant=\"outline\" onClick={onOpen}>\n <ArrowUpRight /> {translate('ai.proposal.open')}\n </Button>\n </>\n ) : (\n <Text size=\"xs\" tone=\"muted\">\n {translate('ai.proposal.discardedNote')}\n </Text>\n )}\n </Row>\n\n {reviewable && reviewOpen ? (\n <ProposalReviewSheet\n title={captions.title}\n diff={diff!}\n collection={collection!}\n source={source!}\n columnHeaders={columnHeaders}\n issues={proposal.issues}\n status={proposal.status}\n onApply={onApply}\n onDiscard={onDiscard}\n onClose={() => setReviewOpen(false)}\n />\n ) : null}\n </Stack>\n )\n}\n\n/**\n * The expandable review surface (EC-382): the proposal's diff promoted into a\n * roomy, full-height right-anchored sheet where the wide `grid` layout is\n * comfortable — for a large proposal the 320px sidebar can only cramp. It is a\n * REVIEW surface, not a form, so it stays lightly dismissible (backdrop click /\n * Escape / close button) rather than a trapping modal — the forms-never-modal\n * spirit, applied. Presentation only: Apply/Discard call the same handlers the\n * card does; it changes no proposal data. Since EC-392 it renders through the\n * shared {@link ExpandableSurface} primitive (portal-to-body, backdrop, header\n * close, layer-stacked Escape) at the shipped 720px width, so it escapes the\n * narrow AI panel; the card default keeps it closed, so the SSR smoke path\n * never mounts the portal.\n */\nfunction ProposalReviewSheet({\n title,\n diff,\n collection,\n source,\n columnHeaders,\n issues,\n status,\n onApply,\n onDiscard,\n onClose,\n}: {\n title: string\n diff: DocumentDiff\n collection: CollectionDef\n source: CollectionViewSource\n columnHeaders: { unified: string; before: string; after: string }\n /** EC-496 — the same findings the card shows; the roomy surface must not hide them. */\n issues: readonly CmsValidationIssue[]\n status: ProposalReview['status']\n onApply: () => void\n onDiscard: () => void\n onClose: () => void\n}) {\n return (\n <ExpandableSurface\n title={title}\n closeLabel={translate('ai.proposal.collapseReview')}\n width=\"min(720px, 92vw)\"\n onDismiss={onClose}\n headerLeading={<Sparkles className=\"size-4 shrink-0 text-primary\" />}\n >\n <div className=\"min-h-0 flex-1 overflow-y-auto p-4 sm:p-6\">\n <div className=\"mx-auto w-full max-w-3xl\">\n <CollectionViewProvider source={source}>\n <DocumentDiffView diff={diff} collection={collection} columnHeaders={columnHeaders} />\n </CollectionViewProvider>\n <ProposalIssues issues={issues} className=\"mt-4\" />\n </div>\n </div>\n\n {status === 'pending' ? (\n <Row gap=\"2\" align=\"center\" className=\"shrink-0 border-t border-border px-4 py-3\">\n <Text size=\"xs\" tone=\"muted\" className=\"min-w-0 flex-1\">\n {translate('ai.proposal.reviewHint')}\n </Text>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => {\n onDiscard()\n onClose()\n }}\n >\n <X /> {translate('ai.proposal.discard')}\n </Button>\n <Button\n size=\"sm\"\n onClick={() => {\n onApply()\n onClose()\n }}\n >\n <Check /> {translate('ai.proposal.apply')}\n </Button>\n </Row>\n ) : null}\n </ExpandableSurface>\n )\n}\n"]}
1
+ {"version":3,"file":"proposal-card.js","sourceRoot":"","sources":["../../../src/components/ai/proposal-card.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useMemo, useState } from 'react'\nimport { ArrowUpRight, Check, Maximize2, Sparkles, X } from 'lucide-react'\nimport { diffDocuments } from '@elytracms/core/content'\nimport type { DocumentDiff } from '@elytracms/core/content'\nimport { resolveLabel } from '@elytracms/core/cms-core'\nimport type { CmsValidationIssue, CollectionDef } from '@elytracms/core/cms-core'\nimport { Badge, Button, ExpandableSurface, Row, Spacer, Stack, Text } from '@elytracms/studio/ui'\nimport { translate } from '../../lib/i18n'\nimport { proposalDocuments } from '../../lib/ai'\nimport type { ProposalOrigin, ProposalReview } from '../../lib/ai'\nimport type { CollectionViewSource } from '../../lib/collection-view/collection-view-source'\nimport { useCms } from '../cms/cms-store'\nimport { useCmsCollectionViewSource } from '../collection-view/cms-collection-view-source'\nimport { CollectionViewProvider } from '../collection-view/collection-view-context'\nimport { DocumentDiffView } from '../cms/document-diff-view'\nimport { ProposalIssues } from './proposal-issues'\nimport { ProposalUnfilled } from './proposal-unfilled'\nimport { TranslateSkipped } from './translate-skipped'\n\n/**\n * The AI proposal review (EC-199, extended by EC-332/EC-378): a valid,\n * leash-compliant proposal the editor reviews as a DIFF (the shared EC-317\n * {@link DocumentDiffView} — never a second renderer) before applying it as a\n * draft or discarding it. Create proposals diff `empty-before` vs\n * `proposed-after`; translate/update proposals diff the EXISTING document\n * (old → new). Apply/discard are handled by the panel (the operations write +\n * provenance snapshot happen there); this card is the surface.\n *\n * {@link ProposalCard} is the CMS-wired shell; {@link ProposalCardView} is the\n * presentational card over an injected collection + view source (node-smoked\n * with `renderToStaticMarkup`, house pattern).\n *\n * ## EC-496 — the proposal's own findings\n *\n * The diff answers *what changes*; {@link ProposalIssues} answers *what the engine\n * flagged about it*, immediately above the Apply/Discard row so it is read while the\n * decision is being made. Two calls made here, both deliberate:\n *\n * **An error on the card does NOT block apply.** Grounded before deciding\n * (`file:line`, read 2026-08-01): every producer already filters this list to\n * non-error findings (`lib/ai/apply-content.ts:259`, `lib/ai/update-fields.ts:442`,\n * `lib/ai/tools.ts:764`), so a card-blocking gate would be dead code today. More\n * importantly the real gate already exists one layer up and is strictly better\n * placed: EC-473 made apply RE-VALIDATE against the session as it is NOW\n * (`ai-chat-panel.tsx:371`) and refuse with `ai.error.applyInvalid` when the write\n * would INTRODUCE an error. A build-time snapshot cannot see an edit the editor made\n * since, and gating on pre-existing brokenness is exactly what EC-473 argued makes an\n * assistant unusable on real content. So this surface REPORTS truthfully — errors\n * render as errors and sort first ({@link orderedProposalIssues}) — and the gate\n * stays where it can see the truth.\n *\n * **`attempts` is not rendered** (the EC-496 scope-2 owner call). The repair-loop\n * count is provenance about the MODEL, not about the content: a proposal repaired on\n * the second attempt passed the identical validation gate as a first-shot one, so the\n * number changes nothing an editor can act on — and spending sidebar space plus a new\n * catalog string on it would trade a real signal (the findings) for a trivia line. The\n * field stays on `ChatProposal` (it is deterministic, cheap, and the natural input for\n * a future AI-diagnostics surface); it simply has no place on the editor's card.\n */\n\n/** The card's origin-dependent captions (title + diff column headers). */\nexport function proposalCaptions(\n origin: ProposalOrigin,\n collectionLabel: string,\n): { title: string; unified: string; before: string; after: string } {\n if (origin === 'translate') {\n return {\n title: translate('ai.translate.proposalTitle', { collection: collectionLabel }),\n unified: translate('ai.proposal.diffUnified'),\n before: translate('ai.translate.diffBefore'),\n after: translate('ai.translate.diffAfter'),\n }\n }\n if (origin === 'update') {\n return {\n title: translate('ai.update.proposalTitle', { collection: collectionLabel }),\n unified: translate('ai.proposal.diffUnified'),\n before: translate('ai.update.diffBefore'),\n after: translate('ai.update.diffAfter'),\n }\n }\n return {\n title: translate('ai.proposal.title', { collection: collectionLabel }),\n unified: translate('ai.proposal.diffUnified'),\n before: translate('ai.proposal.diffBefore'),\n after: translate('ai.proposal.diffAfter'),\n }\n}\n\nexport function ProposalCard({\n proposal,\n onApply,\n onDiscard,\n onOpen,\n}: {\n proposal: ProposalReview\n onApply: () => void\n onDiscard: () => void\n onOpen: () => void\n}) {\n const cms = useCms()\n const collection = cms.getCollection(proposal.collection)\n const source = useCmsCollectionViewSource(proposal.collection)\n return (\n <ProposalCardView\n proposal={proposal}\n collection={collection}\n source={source}\n onApply={onApply}\n onDiscard={onDiscard}\n onOpen={onOpen}\n />\n )\n}\n\n/** The presentational proposal card over an injected collection + view source. */\nexport function ProposalCardView({\n proposal,\n collection,\n source,\n onApply,\n onDiscard,\n onOpen,\n}: {\n proposal: ProposalReview\n collection: CollectionDef | undefined\n source: CollectionViewSource | null\n onApply: () => void\n onDiscard: () => void\n onOpen: () => void\n}) {\n const diff = useMemo(() => {\n if (!collection) return null\n const { before, after } = proposalDocuments(proposal.patch, proposal.before)\n return diffDocuments(collection, before, after)\n }, [collection, proposal.patch, proposal.before])\n\n const collectionLabel =\n (collection?.form?.labelSingular && resolveLabel(collection.form.labelSingular)) ??\n (collection?.form?.label && resolveLabel(collection.form.label)) ??\n proposal.collection\n\n const captions = proposalCaptions(proposal.origin, collectionLabel)\n const applied = proposal.status === 'applied'\n const rejected = proposal.status === 'rejected'\n const columnHeaders = { unified: captions.unified, before: captions.before, after: captions.after }\n\n // EC-382 — the expandable review surface: the sidebar card stays stacked; a\n // roomy full-height sheet is offered for a large proposal (never a modal form).\n const [reviewOpen, setReviewOpen] = useState(false)\n const reviewable = diff !== null && source !== null\n\n return (\n <Stack\n gap=\"2\"\n className=\"overflow-hidden rounded-xl border border-border bg-card\"\n >\n <Row gap=\"2\" align=\"center\" className=\"border-b border-border/60 bg-muted/30 px-3 py-2\">\n <Sparkles className=\"size-4 shrink-0 text-primary\" />\n <Text size=\"sm\" weight=\"medium\" className=\"min-w-0 truncate\">\n {captions.title}\n </Text>\n <Spacer />\n {applied ? <Badge variant=\"success\">{translate('ai.proposal.applied')}</Badge> : null}\n {rejected ? <Badge variant=\"outline\">{translate('ai.proposal.discarded')}</Badge> : null}\n {reviewable ? (\n <Button\n size=\"icon-sm\"\n variant=\"ghost\"\n onClick={() => setReviewOpen(true)}\n title={translate('ai.proposal.expandReview')}\n aria-label={translate('ai.proposal.expandReview')}\n >\n <Maximize2 />\n </Button>\n ) : null}\n </Row>\n\n <div className=\"px-3\">\n {reviewable ? (\n <CollectionViewProvider source={source!}>\n <DocumentDiffView\n diff={diff!}\n collection={collection!}\n columnHeaders={columnHeaders}\n layout=\"stacked\"\n />\n </CollectionViewProvider>\n ) : (\n <Text size=\"xs\" tone=\"muted\" className=\"py-2\">\n {translate('ai.proposal.unavailable')}\n </Text>\n )}\n </div>\n\n {/* EC-496 — the engine's surviving findings, last thing before the actions. */}\n <ProposalIssues issues={proposal.issues} className=\"px-3\" />\n {/* EC-593 — what a translation could not translate, listed (never skipped silently). */}\n {proposal.skipped && proposal.skipped.length > 0 ? (\n <div className=\"px-3\">\n <TranslateSkipped skipped={proposal.skipped} collection={collection} />\n </div>\n ) : null}\n {/* EC-715 — what a create left empty, listed (an unfilled image is said, never just missing). */}\n {proposal.unfilled && proposal.unfilled.length > 0 ? (\n <div className=\"px-3\">\n <ProposalUnfilled unfilled={proposal.unfilled} collection={collection} />\n </div>\n ) : null}\n\n <Row gap=\"2\" align=\"center\" className=\"border-t border-border/60 px-3 py-2\">\n {proposal.status === 'pending' ? (\n <>\n <Text size=\"xs\" tone=\"muted\" className=\"min-w-0 flex-1\">\n {translate('ai.proposal.reviewHint')}\n </Text>\n <Button size=\"sm\" variant=\"ghost\" onClick={onDiscard}>\n <X /> {translate('ai.proposal.discard')}\n </Button>\n <Button size=\"sm\" onClick={onApply}>\n <Check /> {translate('ai.proposal.apply')}\n </Button>\n </>\n ) : applied ? (\n <>\n <Spacer />\n <Button size=\"sm\" variant=\"outline\" onClick={onOpen}>\n <ArrowUpRight /> {translate('ai.proposal.open')}\n </Button>\n </>\n ) : (\n <Text size=\"xs\" tone=\"muted\">\n {translate('ai.proposal.discardedNote')}\n </Text>\n )}\n </Row>\n\n {reviewable && reviewOpen ? (\n <ProposalReviewSheet\n title={captions.title}\n diff={diff!}\n collection={collection!}\n source={source!}\n columnHeaders={columnHeaders}\n issues={proposal.issues}\n status={proposal.status}\n onApply={onApply}\n onDiscard={onDiscard}\n onClose={() => setReviewOpen(false)}\n />\n ) : null}\n </Stack>\n )\n}\n\n/**\n * The expandable review surface (EC-382): the proposal's diff promoted into a\n * roomy, full-height right-anchored sheet where the wide `grid` layout is\n * comfortable — for a large proposal the 320px sidebar can only cramp. It is a\n * REVIEW surface, not a form, so it stays lightly dismissible (backdrop click /\n * Escape / close button) rather than a trapping modal — the forms-never-modal\n * spirit, applied. Presentation only: Apply/Discard call the same handlers the\n * card does; it changes no proposal data. Since EC-392 it renders through the\n * shared {@link ExpandableSurface} primitive (portal-to-body, backdrop, header\n * close, layer-stacked Escape) at the shipped 720px width, so it escapes the\n * narrow AI panel; the card default keeps it closed, so the SSR smoke path\n * never mounts the portal.\n */\nfunction ProposalReviewSheet({\n title,\n diff,\n collection,\n source,\n columnHeaders,\n issues,\n status,\n onApply,\n onDiscard,\n onClose,\n}: {\n title: string\n diff: DocumentDiff\n collection: CollectionDef\n source: CollectionViewSource\n columnHeaders: { unified: string; before: string; after: string }\n /** EC-496 — the same findings the card shows; the roomy surface must not hide them. */\n issues: readonly CmsValidationIssue[]\n status: ProposalReview['status']\n onApply: () => void\n onDiscard: () => void\n onClose: () => void\n}) {\n return (\n <ExpandableSurface\n title={title}\n closeLabel={translate('ai.proposal.collapseReview')}\n width=\"min(720px, 92vw)\"\n onDismiss={onClose}\n headerLeading={<Sparkles className=\"size-4 shrink-0 text-primary\" />}\n >\n <div className=\"min-h-0 flex-1 overflow-y-auto p-4 sm:p-6\">\n <div className=\"mx-auto w-full max-w-3xl\">\n <CollectionViewProvider source={source}>\n <DocumentDiffView diff={diff} collection={collection} columnHeaders={columnHeaders} />\n </CollectionViewProvider>\n <ProposalIssues issues={issues} className=\"mt-4\" />\n </div>\n </div>\n\n {status === 'pending' ? (\n <Row gap=\"2\" align=\"center\" className=\"shrink-0 border-t border-border px-4 py-3\">\n <Text size=\"xs\" tone=\"muted\" className=\"min-w-0 flex-1\">\n {translate('ai.proposal.reviewHint')}\n </Text>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => {\n onDiscard()\n onClose()\n }}\n >\n <X /> {translate('ai.proposal.discard')}\n </Button>\n <Button\n size=\"sm\"\n onClick={() => {\n onApply()\n onClose()\n }}\n >\n <Check /> {translate('ai.proposal.apply')}\n </Button>\n </Row>\n ) : null}\n </ExpandableSurface>\n )\n}\n"]}
@@ -0,0 +1,14 @@
1
+ import type { CollectionDef } from '@elytracms/core/cms-core';
2
+ import type { UnfilledPosition } from '../../lib/ai/index.js';
3
+ /** One unfilled position as the editor reads it: where (field label › position) and why. */
4
+ export interface UnfilledLine {
5
+ where: string;
6
+ reason: string;
7
+ }
8
+ /** The where-label: the field's label, then the position below it (an item counted from 1). */
9
+ export declare function unfilledLines(unfilled: readonly UnfilledPosition[], collection: CollectionDef | undefined): UnfilledLine[];
10
+ /** The list; renders nothing when the draft filled every writable position. */
11
+ export declare function ProposalUnfilled({ unfilled, collection, }: {
12
+ unfilled: readonly UnfilledPosition[];
13
+ collection: CollectionDef | undefined;
14
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { resolveLabel } from '@elytracms/core/cms-core';
3
+ import { CircleDashed } from 'lucide-react';
4
+ import { Row, Stack, Text } from '@elytracms/studio/ui';
5
+ import { translate } from '../../lib/i18n/index.js';
6
+ /**
7
+ * EC-715 — what an AI create left EMPTY, shown on its proposal card: a writable field the
8
+ * draft gave no value (a date, an image the library had nothing for), and an id the leash
9
+ * removed. Before, a create that asked for an image left the field empty and said nothing.
10
+ */
11
+ const REASON_KEY = {
12
+ empty: 'ai.create.unfilled.empty',
13
+ 'no-candidates': 'ai.create.unfilled.noCandidates',
14
+ dropped: 'ai.create.unfilled.dropped',
15
+ };
16
+ /** The where-label: the field's label, then the position below it (an item counted from 1). */
17
+ export function unfilledLines(unfilled, collection) {
18
+ return unfilled.map((position) => {
19
+ const field = collection?.fields.find((f) => f.name === position.field);
20
+ const label = resolveLabel(field?.form?.label) ?? position.field;
21
+ const segments = position.at
22
+ .filter((segment) => segment !== 'content' && segment !== 'props')
23
+ .map((segment) => (typeof segment === 'number' ? `#${segment + 1}` : segment));
24
+ return { where: [label, ...segments].join(' › '), reason: translate(REASON_KEY[position.reason]) };
25
+ });
26
+ }
27
+ /** The list; renders nothing when the draft filled every writable position. */
28
+ export function ProposalUnfilled({ unfilled, collection, }) {
29
+ if (unfilled.length === 0)
30
+ return null;
31
+ return (_jsxs(Stack, { gap: "1", "data-testid": "proposal-unfilled", children: [_jsxs(Row, { gap: "1", align: "center", children: [_jsx(CircleDashed, { className: "size-3.5 text-muted-foreground" }), _jsx(Text, { size: "xs", weight: "medium", children: translate('ai.create.unfilledTitle', { count: unfilled.length }) })] }), unfilledLines(unfilled, collection).map((line, index) => (_jsxs(Text, { size: "xs", tone: "muted", "data-proposal-unfilled": true, children: [line.where, " \u2014 ", line.reason] }, index)))] }));
32
+ }
33
+ //# sourceMappingURL=proposal-unfilled.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proposal-unfilled.js","sourceRoot":"","sources":["../../../src/components/ai/proposal-unfilled.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAEvD,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAC3C,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAG1C;;;;GAIG;AAEH,MAAM,UAAU,GAA+C;IAC7D,KAAK,EAAE,0BAA0B;IACjC,eAAe,EAAE,iCAAiC;IAClD,OAAO,EAAE,4BAA4B;CACtC,CAAA;AAQD,+FAA+F;AAC/F,MAAM,UAAU,aAAa,CAC3B,QAAqC,EACrC,UAAqC;IAErC,OAAO,QAAQ,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE;QAC/B,MAAM,KAAK,GAAG,UAAU,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,KAAK,CAAC,CAAA;QACvE,MAAM,KAAK,GAAG,YAAY,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,QAAQ,CAAC,KAAK,CAAA;QAChE,MAAM,QAAQ,GAAG,QAAQ,CAAC,EAAE;aACzB,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,KAAK,SAAS,IAAI,OAAO,KAAK,OAAO,CAAC;aACjE,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,OAAO,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAA;QAChF,OAAO,EAAE,KAAK,EAAE,CAAC,KAAK,EAAE,GAAG,QAAQ,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,UAAU,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,CAAA;IACpG,CAAC,CAAC,CAAA;AACJ,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,gBAAgB,CAAC,EAC/B,QAAQ,EACR,UAAU,GAIX;IACC,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IACtC,OAAO,CACL,MAAC,KAAK,IAAC,GAAG,EAAC,GAAG,iBAAa,mBAAmB,aAC5C,MAAC,GAAG,IAAC,GAAG,EAAC,GAAG,EAAC,KAAK,EAAC,QAAQ,aACzB,KAAC,YAAY,IAAC,SAAS,EAAC,gCAAgC,GAAG,EAC3D,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,MAAM,EAAC,QAAQ,YAC5B,SAAS,CAAC,yBAAyB,EAAE,EAAE,KAAK,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC,GAC5D,IACH,EACL,aAAa,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,CACxD,MAAC,IAAI,IAAa,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,6CACrC,IAAI,CAAC,KAAK,cAAK,IAAI,CAAC,MAAM,KADlB,KAAK,CAET,CACR,CAAC,IACI,CACT,CAAA;AACH,CAAC","sourcesContent":["import { resolveLabel } from '@elytracms/core/cms-core'\nimport type { CollectionDef } from '@elytracms/core/cms-core'\nimport { CircleDashed } from 'lucide-react'\nimport { Row, Stack, Text } from '@elytracms/studio/ui'\nimport { translate } from '../../lib/i18n'\nimport type { UnfilledPosition } from '../../lib/ai'\n\n/**\n * EC-715 — what an AI create left EMPTY, shown on its proposal card: a writable field the\n * draft gave no value (a date, an image the library had nothing for), and an id the leash\n * removed. Before, a create that asked for an image left the field empty and said nothing.\n */\n\nconst REASON_KEY: Record<UnfilledPosition['reason'], string> = {\n empty: 'ai.create.unfilled.empty',\n 'no-candidates': 'ai.create.unfilled.noCandidates',\n dropped: 'ai.create.unfilled.dropped',\n}\n\n/** One unfilled position as the editor reads it: where (field label › position) and why. */\nexport interface UnfilledLine {\n where: string\n reason: string\n}\n\n/** The where-label: the field's label, then the position below it (an item counted from 1). */\nexport function unfilledLines(\n unfilled: readonly UnfilledPosition[],\n collection: CollectionDef | undefined,\n): UnfilledLine[] {\n return unfilled.map((position) => {\n const field = collection?.fields.find((f) => f.name === position.field)\n const label = resolveLabel(field?.form?.label) ?? position.field\n const segments = position.at\n .filter((segment) => segment !== 'content' && segment !== 'props')\n .map((segment) => (typeof segment === 'number' ? `#${segment + 1}` : segment))\n return { where: [label, ...segments].join(' › '), reason: translate(REASON_KEY[position.reason]) }\n })\n}\n\n/** The list; renders nothing when the draft filled every writable position. */\nexport function ProposalUnfilled({\n unfilled,\n collection,\n}: {\n unfilled: readonly UnfilledPosition[]\n collection: CollectionDef | undefined\n}) {\n if (unfilled.length === 0) return null\n return (\n <Stack gap=\"1\" data-testid=\"proposal-unfilled\">\n <Row gap=\"1\" align=\"center\">\n <CircleDashed className=\"size-3.5 text-muted-foreground\" />\n <Text size=\"xs\" weight=\"medium\">\n {translate('ai.create.unfilledTitle', { count: unfilled.length })}\n </Text>\n </Row>\n {unfilledLines(unfilled, collection).map((line, index) => (\n <Text key={index} size=\"xs\" tone=\"muted\" data-proposal-unfilled>\n {line.where} — {line.reason}\n </Text>\n ))}\n </Stack>\n )\n}\n"]}
@@ -1,16 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Suspense, lazy, useMemo, useState } from 'react';
3
3
  import { Languages } from 'lucide-react';
4
- import { isFieldLocalized } from '@elytracms/core/cms-core';
5
- import { Button } from '@elytracms/studio/ui';
4
+ import { isFieldLocalized, isWriterLocked } from '@elytracms/core/cms-core';
5
+ import { Button, Dialog, DialogContent, DialogTitle } from '@elytracms/studio/ui';
6
6
  import { translate } from '../../lib/i18n/index.js';
7
7
  import { resolveDeploymentAi } from '../../lib/workspace/index.js';
8
+ import { LazySurfaceBoundary } from '../load-failure.js';
8
9
  /**
9
10
  * The "Translate document" header action (EC-332), gate side.
10
11
  *
11
12
  * Rendered ALWAYS in the detail-editor header but returns null unless the studio is
12
- * `ai`-configured (EC-331 presence-enable), the collection is localized with a real
13
- * localized field, and there is a target locale to fill. Like the AI panel toggle, the
13
+ * `ai`-configured (EC-331 presence-enable), the collection has a real localized field a
14
+ * writer may fill (EC-716: not `ai.exclude`, not `form.readOnly`), and there is a target
15
+ * locale to fill. Like the AI panel toggle, the
14
16
  * gate reads only the config object + pure core helpers — it never statically imports
15
17
  * the AI engine (`@elytracms/studio/lib/ai`) or the AI SDK. The heavy dialog (which runs
16
18
  * the translation engine + operations write) loads ONLY when the editor opens it, via a
@@ -29,10 +31,10 @@ export function TranslateDocumentAction(props) {
29
31
  const base = localeConfig.default;
30
32
  if (!localeConfig.locales.some((l) => l !== base))
31
33
  return false;
32
- return collection.fields.some((f) => isFieldLocalized(collection, f.name));
34
+ return collection.fields.some((f) => isFieldLocalized(collection, f.name) && !isWriterLocked(f));
33
35
  }, [collection, localeConfig, localesEnabled, projectId]);
34
36
  if (!eligible)
35
37
  return null;
36
- return (_jsxs(_Fragment, { children: [_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setOpen(true), "data-testid": "doc-translate", title: translate('ai.translate.tooltip'), children: [_jsx(Languages, {}), " ", translate('ai.translate.action')] }), open ? (_jsx(Suspense, { fallback: null, children: _jsx(TranslateDocumentDialog, { ...props, open: true, onOpenChange: setOpen }) })) : null] }));
38
+ return (_jsxs(_Fragment, { children: [_jsxs(Button, { size: "sm", variant: "outline", onClick: () => setOpen(true), "data-testid": "doc-translate", title: translate('ai.translate.tooltip'), children: [_jsx(Languages, {}), " ", translate('ai.translate.action')] }), open ? (_jsx(LazySurfaceBoundary, { frame: (notice) => (_jsx(Dialog, { open: true, onOpenChange: setOpen, children: _jsxs(DialogContent, { "aria-describedby": undefined, children: [_jsx(DialogTitle, { children: translate('ai.translate.action') }), notice] }) })), children: _jsx(Suspense, { fallback: null, children: _jsx(TranslateDocumentDialog, { ...props, open: true, onOpenChange: setOpen }) }) })) : null] }));
37
39
  }
38
40
  //# sourceMappingURL=translate-document-action.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"translate-document-action.js","sourceRoot":"","sources":["../../../src/components/ai/translate-document-action.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACzD,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAA;AAE3D,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAA;AAC7C,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAEzD;;;;;;;;;;GAUG;AACH,MAAM,uBAAuB,GAAG,IAAI,CAAC,GAAG,EAAE,CACxC,MAAM,CAAC,6BAA6B,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,uBAAuB,EAAE,CAAC,CAAC,CAC5F,CAAA;AA2BD,MAAM,UAAU,uBAAuB,CAAC,KAAmC;IACzE,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IACvC,MAAM,EAAE,UAAU,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,GAAG,KAAK,CAAA;IAErE,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,EAAE;QAC5B,MAAM,EAAE,GAAG,mBAAmB,EAAE,CAAA;QAChC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,OAAO,KAAK,KAAK;YAAE,OAAO,KAAK,CAAA;QAC7C,IAAI,CAAC,cAAc,IAAI,SAAS,KAAK,SAAS;YAAE,OAAO,KAAK,CAAA;QAC5D,MAAM,IAAI,GAAG,YAAY,CAAC,OAAO,CAAA;QACjC,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC;YAAE,OAAO,KAAK,CAAA;QAC/D,OAAO,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,gBAAgB,CAAC,UAAU,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAA;IAC5E,CAAC,EAAE,CAAC,UAAU,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,CAAC,CAAC,CAAA;IAEzD,IAAI,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAA;IAE1B,OAAO,CACL,8BACE,MAAC,MAAM,IACL,IAAI,EAAC,IAAI,EACT,OAAO,EAAC,SAAS,EACjB,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,iBAChB,eAAe,EAC3B,KAAK,EAAE,SAAS,CAAC,sBAAsB,CAAC,aAExC,KAAC,SAAS,KAAG,OAAE,SAAS,CAAC,qBAAqB,CAAC,IACxC,EACR,IAAI,CAAC,CAAC,CAAC,CACN,KAAC,QAAQ,IAAC,QAAQ,EAAE,IAAI,YACtB,KAAC,uBAAuB,OAAK,KAAK,EAAE,IAAI,QAAC,YAAY,EAAE,OAAO,GAAI,GACzD,CACZ,CAAC,CAAC,CAAC,IAAI,IACP,CACJ,CAAA;AACH,CAAC","sourcesContent":["import { Suspense, lazy, useMemo, useState } from 'react'\nimport { Languages } from 'lucide-react'\nimport { isFieldLocalized } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef, DocumentRef, LocaleConfig } from '@elytracms/core/cms-core'\nimport { Button } from '@elytracms/studio/ui'\nimport { translate } from '../../lib/i18n'\nimport { resolveDeploymentAi } from '../../lib/workspace'\n\n/**\n * The \"Translate document\" header action (EC-332), gate side.\n *\n * Rendered ALWAYS in the detail-editor header but returns null unless the studio is\n * `ai`-configured (EC-331 presence-enable), the collection is localized with a real\n * localized field, and there is a target locale to fill. Like the AI panel toggle, the\n * gate reads only the config object + pure core helpers — it never statically imports\n * the AI engine (`@elytracms/studio/lib/ai`) or the AI SDK. The heavy dialog (which runs\n * the translation engine + operations write) loads ONLY when the editor opens it, via a\n * dynamic `import()`, so an unconfigured studio pulls none of it onto the critical path.\n */\nconst TranslateDocumentDialog = lazy(() =>\n import('./translate-document-dialog').then((m) => ({ default: m.TranslateDocumentDialog })),\n)\n\nexport interface TranslateDocumentActionProps {\n projectId: string | undefined\n collection: CollectionDef\n docRef: DocumentRef\n /** The live editor draft — the translation source + the target for the fills. */\n document: CmsDocument\n /**\n * EC-647 — the revision the draft is at (`DocumentDraft.revision`): the translation is\n * authored on {@link document}, so its fills are written AT this revision — a bucket a\n * colleague changed since is refused on that field, never overwritten.\n */\n revision: number | undefined\n localeConfig: LocaleConfig\n /** Whether the collection view exposes per-locale editing (`source.capabilities.locales`). */\n localesEnabled: boolean\n /**\n * EC-595 A2 — the FORCED FLUSH before the AI reads the draft (the ruling: \"a forced\n * flush before … an AI proposal\"): the editor commits its uncommitted fields and the\n * persister lands them, so the translation's patches are made on the committed draft.\n * The translated buckets reach the open editor through the session (it rebases), so\n * there is no draft to hand back.\n */\n flushDraft: () => Promise<void>\n}\n\nexport function TranslateDocumentAction(props: TranslateDocumentActionProps) {\n const [open, setOpen] = useState(false)\n const { collection, localeConfig, localesEnabled, projectId } = props\n\n const eligible = useMemo(() => {\n const ai = resolveDeploymentAi()\n if (!ai || ai.enabled === false) return false\n if (!localesEnabled || projectId === undefined) return false\n const base = localeConfig.default\n if (!localeConfig.locales.some((l) => l !== base)) return false\n return collection.fields.some((f) => isFieldLocalized(collection, f.name))\n }, [collection, localeConfig, localesEnabled, projectId])\n\n if (!eligible) return null\n\n return (\n <>\n <Button\n size=\"sm\"\n variant=\"outline\"\n onClick={() => setOpen(true)}\n data-testid=\"doc-translate\"\n title={translate('ai.translate.tooltip')}\n >\n <Languages /> {translate('ai.translate.action')}\n </Button>\n {open ? (\n <Suspense fallback={null}>\n <TranslateDocumentDialog {...props} open onOpenChange={setOpen} />\n </Suspense>\n ) : null}\n </>\n )\n}\n"]}
1
+ {"version":3,"file":"translate-document-action.js","sourceRoot":"","sources":["../../../src/components/ai/translate-document-action.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACzD,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAA;AAE3E,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AACjF,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AACzD,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAErD;;;;;;;;;;;GAWG;AACH,MAAM,uBAAuB,GAAG,IAAI,CAAC,GAAG,EAAE,CACxC,MAAM,CAAC,6BAA6B,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,uBAAuB,EAAE,CAAC,CAAC,CAC5F,CAAA;AA2BD,MAAM,UAAU,uBAAuB,CAAC,KAAmC;IACzE,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IACvC,MAAM,EAAE,UAAU,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,GAAG,KAAK,CAAA;IAErE,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,EAAE;QAC5B,MAAM,EAAE,GAAG,mBAAmB,EAAE,CAAA;QAChC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,OAAO,KAAK,KAAK;YAAE,OAAO,KAAK,CAAA;QAC7C,IAAI,CAAC,cAAc,IAAI,SAAS,KAAK,SAAS;YAAE,OAAO,KAAK,CAAA;QAC5D,MAAM,IAAI,GAAG,YAAY,CAAC,OAAO,CAAA;QACjC,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC;YAAE,OAAO,KAAK,CAAA;QAC/D,OAAO,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,gBAAgB,CAAC,UAAU,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,CAAA;IAClG,CAAC,EAAE,CAAC,UAAU,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,CAAC,CAAC,CAAA;IAEzD,IAAI,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAA;IAE1B,OAAO,CACL,8BACE,MAAC,MAAM,IACL,IAAI,EAAC,IAAI,EACT,OAAO,EAAC,SAAS,EACjB,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,iBAChB,eAAe,EAC3B,KAAK,EAAE,SAAS,CAAC,sBAAsB,CAAC,aAExC,KAAC,SAAS,KAAG,OAAE,SAAS,CAAC,qBAAqB,CAAC,IACxC,EACR,IAAI,CAAC,CAAC,CAAC,CAGN,KAAC,mBAAmB,IAClB,KAAK,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,CACjB,KAAC,MAAM,IAAC,IAAI,QAAC,YAAY,EAAE,OAAO,YAChC,MAAC,aAAa,wBAAmB,SAAS,aACxC,KAAC,WAAW,cAAE,SAAS,CAAC,qBAAqB,CAAC,GAAe,EAC5D,MAAM,IACO,GACT,CACV,YAED,KAAC,QAAQ,IAAC,QAAQ,EAAE,IAAI,YACtB,KAAC,uBAAuB,OAAK,KAAK,EAAE,IAAI,QAAC,YAAY,EAAE,OAAO,GAAI,GACzD,GACS,CACvB,CAAC,CAAC,CAAC,IAAI,IACP,CACJ,CAAA;AACH,CAAC","sourcesContent":["import { Suspense, lazy, useMemo, useState } from 'react'\nimport { Languages } from 'lucide-react'\nimport { isFieldLocalized, isWriterLocked } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef, DocumentRef, LocaleConfig } from '@elytracms/core/cms-core'\nimport { Button, Dialog, DialogContent, DialogTitle } from '@elytracms/studio/ui'\nimport { translate } from '../../lib/i18n'\nimport { resolveDeploymentAi } from '../../lib/workspace'\nimport { LazySurfaceBoundary } from '../load-failure'\n\n/**\n * The \"Translate document\" header action (EC-332), gate side.\n *\n * Rendered ALWAYS in the detail-editor header but returns null unless the studio is\n * `ai`-configured (EC-331 presence-enable), the collection has a real localized field a\n * writer may fill (EC-716: not `ai.exclude`, not `form.readOnly`), and there is a target\n * locale to fill. Like the AI panel toggle, the\n * gate reads only the config object + pure core helpers — it never statically imports\n * the AI engine (`@elytracms/studio/lib/ai`) or the AI SDK. The heavy dialog (which runs\n * the translation engine + operations write) loads ONLY when the editor opens it, via a\n * dynamic `import()`, so an unconfigured studio pulls none of it onto the critical path.\n */\nconst TranslateDocumentDialog = lazy(() =>\n import('./translate-document-dialog').then((m) => ({ default: m.TranslateDocumentDialog })),\n)\n\nexport interface TranslateDocumentActionProps {\n projectId: string | undefined\n collection: CollectionDef\n docRef: DocumentRef\n /** The live editor draft — the translation source + the target for the fills. */\n document: CmsDocument\n /**\n * EC-647 — the revision the draft is at (`DocumentDraft.revision`): the translation is\n * authored on {@link document}, so its fills are written AT this revision — a bucket a\n * colleague changed since is refused on that field, never overwritten.\n */\n revision: number | undefined\n localeConfig: LocaleConfig\n /** Whether the collection view exposes per-locale editing (`source.capabilities.locales`). */\n localesEnabled: boolean\n /**\n * EC-595 A2 — the FORCED FLUSH before the AI reads the draft (the ruling: \"a forced\n * flush before … an AI proposal\"): the editor commits its uncommitted fields and the\n * persister lands them, so the translation's patches are made on the committed draft.\n * The translated buckets reach the open editor through the session (it rebases), so\n * there is no draft to hand back.\n */\n flushDraft: () => Promise<void>\n}\n\nexport function TranslateDocumentAction(props: TranslateDocumentActionProps) {\n const [open, setOpen] = useState(false)\n const { collection, localeConfig, localesEnabled, projectId } = props\n\n const eligible = useMemo(() => {\n const ai = resolveDeploymentAi()\n if (!ai || ai.enabled === false) return false\n if (!localesEnabled || projectId === undefined) return false\n const base = localeConfig.default\n if (!localeConfig.locales.some((l) => l !== base)) return false\n return collection.fields.some((f) => isFieldLocalized(collection, f.name) && !isWriterLocked(f))\n }, [collection, localeConfig, localesEnabled, projectId])\n\n if (!eligible) return null\n\n return (\n <>\n <Button\n size=\"sm\"\n variant=\"outline\"\n onClick={() => setOpen(true)}\n data-testid=\"doc-translate\"\n title={translate('ai.translate.tooltip')}\n >\n <Languages /> {translate('ai.translate.action')}\n </Button>\n {open ? (\n // EC-717: a dialog chunk that fails to load shows its notice where the dialog\n // opens, closable like it; never the whole studio.\n <LazySurfaceBoundary\n frame={(notice) => (\n <Dialog open onOpenChange={setOpen}>\n <DialogContent aria-describedby={undefined}>\n <DialogTitle>{translate('ai.translate.action')}</DialogTitle>\n {notice}\n </DialogContent>\n </Dialog>\n )}\n >\n <Suspense fallback={null}>\n <TranslateDocumentDialog {...props} open onOpenChange={setOpen} />\n </Suspense>\n </LazySurfaceBoundary>\n ) : null}\n </>\n )\n}\n"]}
@@ -84,16 +84,16 @@ export interface StudioConfig {
84
84
  previews?: StudioPreviewsConfig;
85
85
  /**
86
86
  * Reusable elements (EC-467, AD-b): WHICH component is this project's
87
- * reusable-element embed. One line, and only when needed —
88
- * `reuse: { component: 'project.Reusable' }`.
87
+ * reusable-element embed — `reuse: { component: 'project.Reusable' }`.
89
88
  *
90
- * **ABSENCE MEANS INFER, not off.** Unlike the `ai?` precedent, omitting this
91
- * block does not disable anything: the studio derives the embed from config as
92
- * it always has (a slot-less component with one cardinality-one `relation` prop
93
- * whose target collection holds a `blocks` field), so the zero-config promise is
94
- * untouched and no existing consumer needs to change anything. Declare it only
95
- * when the inference cannot decide — the studio then says so, by name, in the
96
- * rail's issue list rather than silently switching the reuse gestures off.
89
+ * **ABSENCE MEANS OFF (EC-728, owner ruling "Declare, never infer").** Like the
90
+ * `ai?` precedent, omitting this block means the project has no reusable
91
+ * elements in the studio: no *Make reusable* / *Detach to local copy* gestures,
92
+ * and no canvas placeholder for an embed whose target is a draft or missing (the
93
+ * embed then renders empty in the canvas). The studio never guesses an embed from
94
+ * a component's shape — that guess crowned an ordinary link card and moved a
95
+ * block's content off the page. Transclusion itself (an embed rendering its
96
+ * target's blocks) does not depend on this declaration.
97
97
  *
98
98
  * **Reusable elements are singular per project (AD-a):** exactly one embed
99
99
  * component and exactly one reusable collection. This is one value, never a
@@ -102,10 +102,10 @@ export interface StudioConfig {
102
102
  * collection → that collection's `blocks` field), which is why the declaration
103
103
  * stays a single identifier.
104
104
  *
105
- * Studio-only, like every key here — the whole feature is studio-side (canvas
106
- * gestures + publish integrity), so it has zero delivery-wire surface and never
105
+ * Studio-only, like every key here — what it drives is studio-side (the canvas
106
+ * gestures and placeholder), so it has zero delivery-wire surface and never
107
107
  * reaches the `frontend/` bundle. A declaration that cannot resolve is a visible
108
- * validation issue, never a silent fallback to guessing.
108
+ * validation issue in the header Issues popover, never a silent fallback.
109
109
  */
110
110
  reuse?: StudioReuseConfig;
111
111
  /**