@object-ui/app-shell 11.4.0 → 11.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +9 -0
  3. package/dist/chrome/KeyboardShortcutsDialog.js +2 -1
  4. package/dist/console/AppContent.js +145 -26
  5. package/dist/console/ConsoleShell.js +8 -1
  6. package/dist/context/CommandPaletteProvider.js +2 -1
  7. package/dist/hooks/useObjectActions.js +16 -4
  8. package/dist/layout/AppHeader.js +13 -5
  9. package/dist/layout/AppSidebar.js +10 -4
  10. package/dist/observability/sentry.d.ts +5 -0
  11. package/dist/observability/sentry.js +6 -1
  12. package/dist/preview/DraftChangesPanel.d.ts +29 -1
  13. package/dist/preview/DraftChangesPanel.js +141 -14
  14. package/dist/urlParams.d.ts +68 -0
  15. package/dist/urlParams.js +76 -0
  16. package/dist/utils/appRoute.d.ts +15 -0
  17. package/dist/utils/appRoute.js +22 -0
  18. package/dist/utils/index.d.ts +1 -1
  19. package/dist/utils/index.js +1 -1
  20. package/dist/utils/pageTabsUrlSync.d.ts +32 -0
  21. package/dist/utils/pageTabsUrlSync.js +43 -0
  22. package/dist/utils/recordFormNavigation.d.ts +40 -0
  23. package/dist/utils/recordFormNavigation.js +30 -0
  24. package/dist/views/InterfaceListPage.d.ts +1 -0
  25. package/dist/views/InterfaceListPage.js +1 -1
  26. package/dist/views/ObjectDataPage.d.ts +29 -0
  27. package/dist/views/ObjectDataPage.js +227 -0
  28. package/dist/views/ObjectView.js +4 -3
  29. package/dist/views/RecordDetailView.js +61 -20
  30. package/dist/views/RelatedRecordActionsBridge.d.ts +10 -1
  31. package/dist/views/RelatedRecordActionsBridge.js +49 -16
  32. package/dist/views/metadata-admin/ResourceEditPage.js +39 -0
  33. package/dist/views/metadata-admin/i18n.js +214 -4
  34. package/dist/views/metadata-admin/inspectors/AppNavInspector.d.ts +11 -4
  35. package/dist/views/metadata-admin/inspectors/AppNavInspector.js +141 -7
  36. package/dist/views/metadata-admin/inspectors/FlowReferenceField.d.ts +14 -0
  37. package/dist/views/metadata-admin/inspectors/FlowReferenceField.js +76 -5
  38. package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +35 -19
  39. package/dist/views/metadata-admin/inspectors/flow-node-config.d.ts +8 -1
  40. package/dist/views/metadata-admin/inspectors/flow-node-config.js +3 -2
  41. package/dist/views/metadata-admin/inspectors/nav-target.d.ts +52 -0
  42. package/dist/views/metadata-admin/inspectors/nav-target.js +149 -0
  43. package/dist/views/metadata-admin/nav-selection.d.ts +20 -0
  44. package/dist/views/metadata-admin/nav-selection.js +81 -0
  45. package/dist/views/metadata-admin/previews/AppNavCanvas.js +9 -1
  46. package/dist/views/metadata-admin/previews/AppPreview.js +4 -2
  47. package/dist/views/studio-design/BuilderLanding.d.ts +1 -1
  48. package/dist/views/studio-design/BuilderLanding.js +12 -19
  49. package/dist/views/studio-design/ObjectFormDesigner.d.ts +5 -3
  50. package/dist/views/studio-design/ObjectFormDesigner.js +17 -12
  51. package/dist/views/studio-design/ObjectSettingsPanel.d.ts +1 -1
  52. package/dist/views/studio-design/ObjectSettingsPanel.js +4 -3
  53. package/dist/views/studio-design/ObjectValidationsPanel.js +6 -4
  54. package/dist/views/studio-design/PackageIdInput.d.ts +31 -0
  55. package/dist/views/studio-design/PackageIdInput.js +40 -0
  56. package/dist/views/studio-design/StudioDesignSurface.d.ts +13 -0
  57. package/dist/views/studio-design/StudioDesignSurface.js +227 -57
  58. package/dist/views/studio-design/packageSurfaces.d.ts +49 -0
  59. package/dist/views/studio-design/packageSurfaces.js +34 -0
  60. package/dist/views/studio-design/packages-io.d.ts +11 -0
  61. package/dist/views/studio-design/packages-io.js +12 -0
  62. package/dist/views/studio-design/skeletons.d.ts +16 -0
  63. package/dist/views/studio-design/skeletons.js +51 -0
  64. package/package.json +38 -38
@@ -14,7 +14,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
14
14
  * surface — it is an injected slot (`aiSlot`) the cloud edition fills.
15
15
  */
16
16
  import * as React from 'react';
17
- import { useParams, useNavigate, Link } from 'react-router-dom';
17
+ import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
18
18
  import { SchemaRenderer, useAdapter, SchemaRendererProvider } from '@object-ui/react';
19
19
  import { GridFieldAuthoringProvider } from '@object-ui/components';
20
20
  import { ObjectView as PluginObjectView } from '@object-ui/plugin-view';
@@ -25,7 +25,10 @@ import { getMetadataPreview } from '../metadata-admin/preview-registry';
25
25
  import { PermissionMatrixEditPage } from '../metadata-admin/PermissionMatrixEditor';
26
26
  import { getMetadataInspector } from '../metadata-admin/inspector-registry';
27
27
  import { useMetadataClient } from '../metadata-admin/useMetadata';
28
+ import { DESIGNER_SEL_PARAM, parseNavSelParam, formatNavSelParam, findNavPositionById, navIdAtPosition, } from '../metadata-admin/nav-selection';
28
29
  import { formatMetadataError, formatPublishFailures } from './metadataError';
30
+ import { loadPackageSurfaces } from './packageSurfaces';
31
+ import { buildObjectSkeleton, buildFlowSkeleton, buildAppSkeleton, buildPermissionSkeleton } from './skeletons';
29
32
  import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n';
30
33
  import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas';
31
34
  import { readFields, writeFields, newField, toFieldName, toFieldNameLoose, } from '../metadata-admin/previews/object-fields-io';
@@ -33,6 +36,7 @@ import { ObjectFormDesigner } from './ObjectFormDesigner';
33
36
  import { ObjectValidationsPanel } from './ObjectValidationsPanel';
34
37
  import { ObjectSettingsPanel } from './ObjectSettingsPanel';
35
38
  import { fetchPackages, createBasePackage, PACKAGE_ID_RE } from './packages-io';
39
+ import { PackageIdInput, PackageIdSuggestionHint } from './PackageIdInput';
36
40
  import { DraftChangesPanel } from '../../preview/DraftChangesPanel';
37
41
  import { toast } from 'sonner';
38
42
  const PILLARS = [
@@ -153,21 +157,38 @@ function PackageSwitcher({ packageId, tab }) {
153
157
  void doCreate();
154
158
  if (e.key === 'Escape')
155
159
  setCreating(false);
156
- }, placeholder: t('engine.studio.pkg.namePlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsx("input", { value: newId, onChange: (e) => {
160
+ }, placeholder: t('engine.studio.pkg.namePlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsx(PackageIdSuggestionHint, { show: !idTouched && !!newName.trim() && !newId, locale: locale }), _jsx(PackageIdInput, { value: newId, onChange: (v) => {
157
161
  setIdTouched(true);
158
- setNewId(e.target.value.toLowerCase().replace(/[^a-z0-9_.-]/g, ''));
159
- }, onKeyDown: (e) => {
160
- if (e.key === 'Enter')
161
- void doCreate();
162
- if (e.key === 'Escape')
163
- setCreating(false);
164
- }, placeholder: t('engine.studio.pkg.idPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" }), err && _jsx("p", { className: "text-[10px] text-destructive", children: err }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => void doCreate(), disabled: busy || !newName.trim() || !PACKAGE_ID_RE.test(newId.trim()), className: "inline-flex flex-1 items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [busy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.pkg.createWritable', locale)] }), _jsx("button", { type: "button", onClick: () => setCreating(false), className: "rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted", children: t('engine.studio.cancel', locale) })] })] })) : (_jsxs("button", { type: "button", onClick: () => setCreating(true), className: "flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.pkg.new', locale)] })) })] })] }))] }));
162
+ setNewId(v);
163
+ }, onEnter: () => void doCreate(), onEscape: () => setCreating(false), placeholder: t('engine.studio.pkg.idPlaceholder', locale), locale: locale, testId: "pkg-switcher-id-input" }), err && _jsx("p", { className: "text-[10px] text-destructive", children: err }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => void doCreate(), disabled: busy || !newName.trim() || !PACKAGE_ID_RE.test(newId.trim()), className: "inline-flex flex-1 items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [busy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.pkg.createWritable', locale)] }), _jsx("button", { type: "button", onClick: () => setCreating(false), className: "rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted", children: t('engine.studio.cancel', locale) })] })] })) : (_jsxs("button", { type: "button", onClick: () => setCreating(true), className: "flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.pkg.new', locale)] })) })] })] }))] }));
165
164
  }
166
165
  export function StudioDesignSurface({ aiSlot }) {
167
166
  const params = useParams();
168
167
  const packageId = params.packageId ?? 'com.example.showcase';
169
168
  const tab = params.tab ?? 'interfaces';
170
169
  const locale = useMetadataLocale();
170
+ // Courtesy gate (ADR-0057 D10): a read-only code/installed package refuses
171
+ // authoring server-side (ADR-0070), so don't let the user build up doomed
172
+ // local edits first — disable the authoring affordances up front. Unknown
173
+ // writability (fetch failed / still loading) stays ungated; the server gate
174
+ // remains the authority either way.
175
+ const [pkgWritable, setPkgWritable] = React.useState(null);
176
+ React.useEffect(() => {
177
+ let cancelled = false;
178
+ setPkgWritable(null);
179
+ fetchPackages()
180
+ .then((list) => {
181
+ if (!cancelled)
182
+ setPkgWritable(list.find((p) => p.id === packageId)?.writable ?? null);
183
+ })
184
+ .catch(() => {
185
+ /* unknown — leave ungated */
186
+ });
187
+ return () => {
188
+ cancelled = true;
189
+ };
190
+ }, [packageId]);
191
+ const readOnly = pkgWritable === false;
171
192
  // Package-level publish (ADR-0033/0037/0048): edits accumulate as per-item
172
193
  // drafts STAMPED with this package (each save passes packageId → the draft row's
173
194
  // sys_metadata.package_id). Publishing promotes exactly THIS package's drafts in
@@ -252,6 +273,27 @@ export function StudioDesignSurface({ aiSlot }) {
252
273
  const [appNameTouched, setAppNameTouched] = React.useState(false);
253
274
  const [appBusy, setAppBusy] = React.useState(false);
254
275
  const [appDraftPending, setAppDraftPending] = React.useState(null);
276
+ // Scaffold the new app's navigation from the package's objects (default on) —
277
+ // otherwise a fresh app has zero menu items and every object must be wired by
278
+ // hand in the Interfaces pillar (objectui#2262).
279
+ const [appAddObjects, setAppAddObjects] = React.useState(true);
280
+ const loadPackageObjects = React.useCallback(async () => {
281
+ // Published objects + pending DRAFT objects, merged — a fresh package's
282
+ // objects are usually still drafts (same merge the Data pillar rail does).
283
+ const [list, draftHeaders] = await Promise.all([
284
+ shellClient.list('object', { packageId }),
285
+ shellClient.listDrafts({ packageId, type: 'object' }).catch(() => []),
286
+ ]);
287
+ const items = (list || [])
288
+ .map((o) => ({ name: String(o.name ?? ''), label: String(o.label ?? o.name ?? '') }))
289
+ .filter((o) => o.name);
290
+ const known = new Set(items.map((o) => o.name));
291
+ for (const d of draftHeaders) {
292
+ if (d.name && !known.has(d.name))
293
+ items.push({ name: d.name, label: d.name });
294
+ }
295
+ return items;
296
+ }, [shellClient, packageId]);
255
297
  const doCreateApp = React.useCallback(async () => {
256
298
  const label = appLabel.trim();
257
299
  const name = toFieldName(appName.trim() || label);
@@ -259,7 +301,8 @@ export function StudioDesignSurface({ aiSlot }) {
259
301
  return;
260
302
  setAppBusy(true);
261
303
  try {
262
- await shellClient.save('app', name, { name, label, active: true, navigation: [] }, { mode: 'draft', packageId });
304
+ const navObjects = appAddObjects ? await loadPackageObjects().catch(() => []) : [];
305
+ await shellClient.save('app', name, buildAppSkeleton(name, label, navObjects), { mode: 'draft', packageId });
263
306
  toast.success(tFormat('engine.studio.app.savedDraft', locale, { label }));
264
307
  setAppDraftPending(label);
265
308
  setAppCreating(false);
@@ -274,7 +317,7 @@ export function StudioDesignSurface({ aiSlot }) {
274
317
  finally {
275
318
  setAppBusy(false);
276
319
  }
277
- }, [appLabel, appName, shellClient, packageId]);
320
+ }, [appLabel, appName, appAddObjects, loadPackageObjects, shellClient, packageId]);
278
321
  React.useEffect(() => {
279
322
  let cancelled = false;
280
323
  (async () => {
@@ -298,7 +341,7 @@ export function StudioDesignSurface({ aiSlot }) {
298
341
  return (_jsxs("div", { className: "flex h-screen w-full overflow-hidden bg-background text-foreground", children: [aiSlot ? _jsx("aside", { className: "w-64 shrink-0 overflow-auto border-r bg-muted/40", children: aiSlot }) : null, _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex items-center gap-3 border-b px-3 py-2", children: [_jsx("button", { type: "button", onClick: () => shellNavigate('/home'), title: t('engine.studio.home', locale), className: "rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground", children: _jsx(HomeIcon, { className: "h-4 w-4" }) }), _jsx(PackageSwitcher, { packageId: packageId, tab: tab }), _jsx("span", { className: "text-muted-foreground", children: "\u00B7" }), _jsx("nav", { className: "flex gap-1", children: PILLARS.map((p) => (_jsxs(Link, { to: `/studio/${packageId}/${p.key}`, className: 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs transition-colors ' +
299
342
  (tab === p.key
300
343
  ? 'bg-primary/10 font-medium text-primary'
301
- : 'text-muted-foreground hover:bg-muted hover:text-foreground'), children: [_jsx(p.Icon, { className: "h-3.5 w-3.5" }), t(`engine.studio.pillar.${p.key}`, locale)] }, p.key))) }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [packageApp ? (_jsxs("button", { type: "button", onClick: () => window.open(`/apps/${encodeURIComponent(packageApp.name)}`, '_blank'), title: tFormat('engine.studio.app.openTitle', locale, { label: packageApp.label }), className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5" }), t('engine.studio.app.open', locale)] })) : appDraftPending ? (_jsx("span", { title: t('engine.studio.app.willOpenAfterPublish', locale), className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: tFormat('engine.studio.app.pending', locale, { label: appDraftPending }) })) : (_jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setAppCreating((v) => !v), title: t('engine.studio.app.noneTitle', locale), className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), t('engine.studio.app.create', locale)] }), appCreating && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-40", onClick: () => setAppCreating(false) }), _jsxs("div", { className: "absolute right-0 top-full z-50 mt-1 flex w-72 flex-col gap-1.5 rounded-lg border bg-background p-2 shadow-lg", children: [_jsx("input", { autoFocus: true, value: appLabel, onChange: (e) => {
344
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'), children: [_jsx(p.Icon, { className: "h-3.5 w-3.5" }), t(`engine.studio.pillar.${p.key}`, locale)] }, p.key))) }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [packageApp ? (_jsxs("button", { type: "button", onClick: () => window.open(`/apps/${encodeURIComponent(packageApp.name)}`, '_blank'), title: tFormat('engine.studio.app.openTitle', locale, { label: packageApp.label }), className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5" }), t('engine.studio.app.open', locale)] })) : appDraftPending ? (_jsx("span", { title: t('engine.studio.app.willOpenAfterPublish', locale), className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: tFormat('engine.studio.app.pending', locale, { label: appDraftPending }) })) : (_jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setAppCreating((v) => !v), disabled: readOnly, title: readOnly ? t('engine.studio.pkg.readonlyHint', locale) : t('engine.studio.app.noneTitle', locale), className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), t('engine.studio.app.create', locale)] }), appCreating && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-40", onClick: () => setAppCreating(false) }), _jsxs("div", { className: "absolute right-0 top-full z-50 mt-1 flex w-72 flex-col gap-1.5 rounded-lg border bg-background p-2 shadow-lg", children: [_jsx("input", { autoFocus: true, value: appLabel, onChange: (e) => {
302
345
  setAppLabel(e.target.value);
303
346
  if (!appNameTouched)
304
347
  setAppName(toFieldNameLoose(e.target.value));
@@ -315,7 +358,15 @@ export function StudioDesignSurface({ aiSlot }) {
315
358
  void doCreateApp();
316
359
  if (e.key === 'Escape')
317
360
  setAppCreating(false);
318
- }, placeholder: t('engine.studio.app.idPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsxs("button", { type: "button", onClick: () => void doCreateApp(), disabled: appBusy || !appLabel.trim() || !toFieldName(appName.trim() || appLabel) || toFieldName(appName.trim() || appLabel) === 'field', className: "inline-flex items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [appBusy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.createDraft', locale)] })] })] }))] })), _jsxs("button", { type: "button", onClick: () => setChangesOpen(true), className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(GitBranch, { className: "h-3.5 w-3.5" }), t('engine.studio.changes', locale), hasPending ? ` · ${pendingCount}` : ''] }), _jsxs("button", { type: "button", onClick: doPublish, disabled: publishing || !hasPending, title: hasPending ? t('engine.studio.publishTitle', locale) : t('engine.studio.publishNoneTitle', locale), className: "inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50", children: [publishing ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Rocket, { className: "h-3.5 w-3.5" }), t('engine.studio.publish', locale)] })] })] }), _jsx("div", { className: "min-h-0 flex-1", children: tab === 'data' ? (_jsx(DataPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved })) : tab === 'automations' ? (_jsx(AutomationsPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved })) : tab === 'access' ? (_jsx(AccessPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved })) : (_jsx(InterfacesPillar, { packageId: packageId, publishNonce: publishNonce, draftNonce: draftNonce, onDraftSaved: onDraftSaved, onCreateApp: () => setAppCreating(true) })) })] }), _jsx(DraftChangesPanel, { open: changesOpen, onOpenChange: setChangesOpen, packageId: packageId })] }));
361
+ }, placeholder: t('engine.studio.app.idPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsxs("label", { className: "flex cursor-pointer items-center gap-1.5 px-0.5 py-0.5 text-[11px] text-muted-foreground", children: [_jsx("input", { type: "checkbox", checked: appAddObjects, onChange: (e) => setAppAddObjects(e.target.checked), className: "h-3 w-3 accent-primary" }), t('engine.studio.app.scaffoldNav', locale)] }), _jsxs("button", { type: "button", onClick: () => void doCreateApp(), disabled: appBusy || !appLabel.trim() || !toFieldName(appName.trim() || appLabel) || toFieldName(appName.trim() || appLabel) === 'field', className: "inline-flex items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [appBusy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.createDraft', locale)] })] })] }))] })), _jsxs("button", { type: "button", onClick: () => setChangesOpen(true), className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(GitBranch, { className: "h-3.5 w-3.5" }), t('engine.studio.changes', locale), hasPending ? ` · ${pendingCount}` : ''] }), _jsxs("button", { type: "button",
362
+ // Publish is review-then-confirm: open the pending-changes panel,
363
+ // whose footer button fires the actual atomic package publish —
364
+ // never straight from this header click (objectui#2261).
365
+ onClick: () => setChangesOpen(true), disabled: publishing || !hasPending || readOnly, title: readOnly
366
+ ? t('engine.studio.pkg.readonlyHint', locale)
367
+ : hasPending
368
+ ? t('engine.studio.publishTitle', locale)
369
+ : t('engine.studio.publishNoneTitle', locale), className: "inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50", children: [publishing ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Rocket, { className: "h-3.5 w-3.5" }), t('engine.studio.publish', locale)] })] })] }), _jsx("div", { className: "min-h-0 flex-1", children: tab === 'data' ? (_jsx(DataPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved, readOnly: readOnly })) : tab === 'automations' ? (_jsx(AutomationsPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved, readOnly: readOnly })) : tab === 'access' ? (_jsx(AccessPillar, { packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved, readOnly: readOnly })) : (_jsx(InterfacesPillar, { packageId: packageId, publishNonce: publishNonce, draftNonce: draftNonce, onDraftSaved: onDraftSaved, onCreateApp: readOnly ? undefined : () => setAppCreating(true), readOnly: readOnly })) })] }), _jsx(DraftChangesPanel, { open: changesOpen, onOpenChange: setChangesOpen, packageId: packageId, onPublish: readOnly ? undefined : doPublish, publishing: publishing })] }));
319
370
  }
320
371
  /** Recursive App-navigation tree (groups + typed leaves). */
321
372
  function NavTree({ nodes, active, onPick, }) {
@@ -382,7 +433,7 @@ function StudioNavItemInspector({ navId, appDraft, objects, onNavPatch, onClear,
382
433
  });
383
434
  }, className: "w-full rounded border bg-background px-2 py-1 text-xs", children: [_jsx("option", { value: "", children: t('engine.studio.nav.chooseObject', locale) }), objects.map((o) => (_jsxs("option", { value: o.name, children: [o.label, " (", o.name, ")"] }, o.name)))] }), _jsx("p", { className: "mt-1 text-[11px] text-muted-foreground", children: boundObject ? t('engine.studio.nav.boundHint', locale) : t('engine.studio.nav.unboundHint', locale) }), objects.length === 0 && (_jsx("p", { className: "mt-1 text-[11px] text-amber-600 dark:text-amber-400", children: t('engine.studio.nav.noObjects', locale) }))] }), _jsx("button", { type: "button", onClick: onClear, className: "text-[11px] text-muted-foreground underline-offset-2 hover:underline", children: t('engine.studio.deselect', locale) })] }));
384
435
  }
385
- function InterfacesPillar({ packageId, publishNonce = 0, draftNonce = 0, onDraftSaved, onCreateApp, }) {
436
+ function InterfacesPillar({ packageId, publishNonce = 0, draftNonce = 0, onDraftSaved, onCreateApp, readOnly = false, }) {
386
437
  const client = useMetadataClient();
387
438
  const locale = useMetadataLocale();
388
439
  const [appLabel, setAppLabel] = React.useState(packageId);
@@ -392,6 +443,37 @@ function InterfacesPillar({ packageId, publishNonce = 0, draftNonce = 0, onDraft
392
443
  // nav editing — drag-drop reorder / rename / add / remove via AppNavCanvas
393
444
  const [editNav, setEditNav] = React.useState(false);
394
445
  const [navSel, setNavSel] = React.useState(null);
446
+ // #2272 — designer deep-link: `?sel=nav:<id>` selects the nav item with
447
+ // that spec `id` and switches the pillar into nav editing. The id is the
448
+ // stable external contract; positional `navigation[i]` selection ids stay
449
+ // internal. Selection changes mirror back to the URL (replace).
450
+ const [searchParams, setSearchParams] = useSearchParams();
451
+ const navSelParam = parseNavSelParam(searchParams.get(DESIGNER_SEL_PARAM));
452
+ const appliedNavSelRef = React.useRef(null);
453
+ React.useEffect(() => {
454
+ if (!navSelParam || navTree.length === 0)
455
+ return;
456
+ if (appliedNavSelRef.current === navSelParam)
457
+ return;
458
+ const hit = findNavPositionById({ navigation: navTree }, navSelParam);
459
+ if (!hit)
460
+ return;
461
+ appliedNavSelRef.current = navSelParam;
462
+ setEditNav(true);
463
+ setNavSel({ kind: 'nav', id: hit.selectionId });
464
+ }, [navSelParam, navTree]);
465
+ React.useEffect(() => {
466
+ const navId = navSel ? navIdAtPosition({ navigation: navTree }, navSel.id) : null;
467
+ setSearchParams((prev) => {
468
+ const next = new URLSearchParams(prev);
469
+ if (navId)
470
+ next.set(DESIGNER_SEL_PARAM, formatNavSelParam(navId));
471
+ else
472
+ next.delete(DESIGNER_SEL_PARAM);
473
+ return next;
474
+ }, { replace: true });
475
+ // eslint-disable-next-line react-hooks/exhaustive-deps
476
+ }, [navSel]);
395
477
  const [navDirty, setNavDirty] = React.useState(false);
396
478
  const [navHasDraft, setNavHasDraft] = React.useState(false);
397
479
  const [navSaving, setNavSaving] = React.useState(false);
@@ -611,7 +693,7 @@ function InterfacesPillar({ packageId, publishNonce = 0, draftNonce = 0, onDraft
611
693
  setNavSaving(false);
612
694
  }
613
695
  }, [client, appName, appDraft, onDraftSaved]);
614
- return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [current ? (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-muted-foreground", children: [_jsx("span", { className: "text-[13px] font-medium text-foreground", children: current.label }), _jsxs("span", { className: "rounded bg-muted px-1.5 py-0.5", children: [current.type, " \u00B7 ", current.name] })] })) : (_jsx("span", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.if.pickLeft', locale) })), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublishedDraft', locale) })), _jsxs("button", { onClick: doSave, disabled: !current || !isEditable || !!saving, className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50", children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), t('engine.studio.saveDraft', locale)] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: (editNav ? 'w-72' : 'w-52') + ' flex shrink-0 flex-col border-r', children: [_jsxs("div", { className: "shrink-0 border-b px-2 py-1.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-1", children: [_jsx("p", { className: "truncate text-[11px] font-medium text-muted-foreground", children: tFormat('engine.studio.if.navHeading', locale, { app: appLabel }) }), appStatus === 'ready' && (_jsx("button", { type: "button", onClick: () => {
696
+ return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [current ? (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-muted-foreground", children: [_jsx("span", { className: "text-[13px] font-medium text-foreground", children: current.label }), _jsxs("span", { className: "rounded bg-muted px-1.5 py-0.5", children: [current.type, " \u00B7 ", current.name] })] })) : (_jsx("span", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.if.pickLeft', locale) })), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublishedDraft', locale) })), _jsxs("button", { onClick: doSave, disabled: !current || !isEditable || !!saving || readOnly, title: readOnly ? t('engine.studio.pkg.readonlyHint', locale) : undefined, className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50", children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), t('engine.studio.saveDraft', locale)] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: (editNav ? 'w-72' : 'w-52') + ' flex shrink-0 flex-col border-r', children: [_jsxs("div", { className: "shrink-0 border-b px-2 py-1.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-1", children: [_jsx("p", { className: "truncate text-[11px] font-medium text-muted-foreground", children: tFormat('engine.studio.if.navHeading', locale, { app: appLabel }) }), appStatus === 'ready' && !readOnly && (_jsx("button", { type: "button", onClick: () => {
615
697
  setEditNav((v) => !v);
616
698
  setNavSel(null);
617
699
  }, title: editNav ? t('engine.studio.if.doneEditTitle', locale) : t('engine.studio.if.editNavTitle', locale), className: 'inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] ' +
@@ -669,7 +751,7 @@ function renderStudioGridList(props) {
669
751
  addDeleteRecordsInline: true,
670
752
  }, dataSource: ds, onEdit: onEdit, className: className, refreshKey: refreshKey }));
671
753
  }
672
- function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
754
+ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, readOnly = false, }) {
673
755
  const client = useMetadataClient();
674
756
  const adapter = useAdapter();
675
757
  const locale = useMetadataLocale();
@@ -684,6 +766,10 @@ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
684
766
  const [dirty, setDirty] = React.useState(false);
685
767
  const [hasDraft, setHasDraft] = React.useState(false);
686
768
  const [saving, setSaving] = React.useState(false);
769
+ // Timestamp of the last successful draft save — renders a "last saved HH:MM"
770
+ // hint next to the Save button (framework#2615 P3: nothing confirmed a draft
771
+ // save persisted, unlike the sibling pillars which toast).
772
+ const [savedAt, setSavedAt] = React.useState(null);
687
773
  const [gridVer, setGridVer] = React.useState(0);
688
774
  // Records grid ⇄ Form ⇄ Validations ⇄ Settings — four views of the SAME
689
775
  // object. Grid/Form are the runtime renderer (same-renderer principle);
@@ -734,7 +820,7 @@ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
734
820
  setCurrent((c) => c ?? items[0] ?? null);
735
821
  // First-run: an empty writable package opens the creator right away —
736
822
  // the first thing to do here is make an object, so put the inputs up.
737
- if (items.length === 0)
823
+ if (items.length === 0 && !readOnly)
738
824
  setCreating(true);
739
825
  }
740
826
  catch (e) {
@@ -749,7 +835,7 @@ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
749
835
  return () => {
750
836
  cancelled = true;
751
837
  };
752
- }, [client, packageId]);
838
+ }, [client, packageId, readOnly]);
753
839
  React.useEffect(() => {
754
840
  if (!current)
755
841
  return;
@@ -801,20 +887,26 @@ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
801
887
  setDirty(true);
802
888
  }, []);
803
889
  // "+ add field": append a fresh text field and select it for editing in the panel.
890
+ // Guarded in addition to being hidden — it's also reachable through
891
+ // GridFieldAuthoringProvider/ObjectFormDesigner.
804
892
  const addField = React.useCallback(() => {
893
+ if (readOnly)
894
+ return;
805
895
  const view = readFields(objDraft.fields);
806
896
  const name = nextFieldName(view.entries.map((e) => e.name));
807
897
  view.entries.push(newField(name, 'text', t('engine.studio.data.newFieldLabel', locale)));
808
898
  setObjDraft((d) => ({ ...d, fields: writeFields(view) }));
809
899
  setDirty(true);
810
900
  setFieldSel({ kind: 'field', id: name });
811
- }, [objDraft]);
901
+ }, [objDraft, readOnly]);
812
902
  // "+ new object": create a fresh object as a DRAFT in this package (runtime
813
903
  // create — same path the classic Studio editor uses), seeded with one text
814
904
  // field so the form/grid isn't empty. It stays draft-only (no physical table)
815
905
  // until the package publish, so we land on 表单·布局 — the metadata-level
816
906
  // surface that never fires data SQL.
817
907
  const doCreateObject = React.useCallback(async () => {
908
+ if (readOnly)
909
+ return;
818
910
  const label = newLabel.trim();
819
911
  const name = toFieldName(newName.trim() || label);
820
912
  if (!label || !name || name === 'field')
@@ -826,11 +918,7 @@ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
826
918
  setCreateBusy(true);
827
919
  setError(null);
828
920
  try {
829
- const body = {
830
- name,
831
- label,
832
- fields: { name: { type: 'text', label: t('engine.studio.data.nameFieldLabel', locale) } },
833
- };
921
+ const body = buildObjectSkeleton(name, label, t('engine.studio.data.nameFieldLabel', locale));
834
922
  await client.save('object', name, body, { mode: 'draft', packageId });
835
923
  const surface = { type: 'object', name, label };
836
924
  setObjects((prev) => [...prev, surface]);
@@ -849,7 +937,7 @@ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
849
937
  finally {
850
938
  setCreateBusy(false);
851
939
  }
852
- }, [newLabel, newName, objects, client, packageId, onDraftSaved]);
940
+ }, [newLabel, newName, objects, client, packageId, onDraftSaved, readOnly]);
853
941
  const doSave = React.useCallback(async () => {
854
942
  if (!current)
855
943
  return;
@@ -859,6 +947,8 @@ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
859
947
  await client.save('object', current.name, objDraft, { mode: 'draft', packageId });
860
948
  setHasDraft(true);
861
949
  setDirty(false);
950
+ setSavedAt(new Date());
951
+ toast.success(tFormat('engine.studio.data.savedDraft', locale, { label: current.label }));
862
952
  onDraftSaved?.();
863
953
  }
864
954
  catch (e) {
@@ -867,7 +957,7 @@ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
867
957
  finally {
868
958
  setSaving(false);
869
959
  }
870
- }, [client, current, objDraft, onDraftSaved]);
960
+ }, [client, current, objDraft, onDraftSaved, packageId, locale]);
871
961
  // Drag-reorder columns → reorder the object's `fields` metadata (field display
872
962
  // order follows metadata order), saved as a DRAFT. Published later via the
873
963
  // package release — NOT auto-published per reorder as it used to be.
@@ -902,12 +992,15 @@ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
902
992
  }
903
993
  }, [client, current, objDraft, onDraftSaved]);
904
994
  const inspector = getMetadataInspector('object');
905
- return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [current ? (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-muted-foreground", children: [_jsx("span", { className: "text-[13px] font-medium text-foreground", children: current.label }), _jsxs("span", { className: "rounded bg-muted px-1.5 py-0.5", children: ["object \u00B7 ", current.name] }), _jsx("span", { children: tFormat('engine.studio.data.fieldCount', locale, { count: fieldCount }) })] })) : (_jsx("span", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.data.pickObject', locale) })), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublishedDraft', locale) })), _jsxs("button", { onClick: doSave, disabled: !current || !dirty || !!saving, className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50", children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), t('engine.studio.saveDraft', locale)] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: "flex w-52 shrink-0 flex-col border-r", children: [_jsxs("div", { className: "shrink-0 p-2 pb-1", children: [_jsx("p", { className: "px-2 pb-1 pt-1 text-[11px] font-medium text-muted-foreground", children: t('engine.studio.data.objects', locale) }), _jsx("input", { value: query, onChange: (e) => setQuery(e.target.value), placeholder: t('engine.studio.data.searchObjects', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none placeholder:text-muted-foreground/70 focus:ring-1 focus:ring-primary" })] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-auto p-2 pt-1", children: [objects.length === 0 && (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error ? t('engine.studio.loadFailed', locale) : objectsLoaded ? t('engine.studio.data.noObjects', locale) : t('engine.studio.loading', locale) })), objects
995
+ return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [current ? (_jsxs("span", { className: "flex items-center gap-1.5 text-[11px] text-muted-foreground", children: [_jsx("span", { className: "text-[13px] font-medium text-foreground", children: current.label }), _jsxs("span", { className: "rounded bg-muted px-1.5 py-0.5", children: ["object \u00B7 ", current.name] }), _jsx("span", { children: tFormat('engine.studio.data.fieldCount', locale, { count: fieldCount }) })] })) : (_jsx("span", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.data.pickObject', locale) })), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublishedDraft', locale) })), savedAt && !dirty && (_jsx("span", { className: "ml-auto text-[11px] text-muted-foreground", "data-testid": "data-saved-at", children: tFormat('engine.studio.data.lastSaved', locale, {
996
+ time: savedAt.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
997
+ }) })), _jsxs("button", { onClick: doSave, disabled: !current || !dirty || !!saving || readOnly, title: readOnly ? t('engine.studio.pkg.readonlyHint', locale) : undefined, className: (savedAt && !dirty ? '' : 'ml-auto ') +
998
+ 'inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50', children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), t('engine.studio.saveDraft', locale)] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: "flex w-52 shrink-0 flex-col border-r", children: [_jsxs("div", { className: "shrink-0 p-2 pb-1", children: [_jsx("p", { className: "px-2 pb-1 pt-1 text-[11px] font-medium text-muted-foreground", children: t('engine.studio.data.objects', locale) }), _jsx("input", { value: query, onChange: (e) => setQuery(e.target.value), placeholder: t('engine.studio.data.searchObjects', locale), className: "h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none placeholder:text-muted-foreground/70 focus:ring-1 focus:ring-primary" })] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-auto p-2 pt-1", children: [objects.length === 0 && (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error ? t('engine.studio.loadFailed', locale) : objectsLoaded ? t('engine.studio.data.noObjects', locale) : t('engine.studio.loading', locale) })), objects
906
999
  .filter((o) => !query.trim() ||
907
1000
  o.label.toLowerCase().includes(query.trim().toLowerCase()) ||
908
1001
  o.name.toLowerCase().includes(query.trim().toLowerCase()))
909
1002
  .map((o) => (_jsxs("button", { onClick: () => setCurrent(o), className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
910
- (current?.name === o.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60'), children: [_jsx(Database, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 truncate", children: o.label })] }, o.name)))] }), _jsx("div", { className: "shrink-0 border-t p-2", children: creating ? (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("input", { autoFocus: true, value: newLabel, onChange: (e) => {
1003
+ (current?.name === o.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60'), children: [_jsx(Database, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 truncate", children: o.label })] }, o.name)))] }), _jsx("div", { className: "shrink-0 border-t p-2", children: readOnly ? (_jsxs("p", { title: t('engine.studio.pkg.readonlyHint', locale), className: "flex items-center gap-1.5 px-2 py-1.5 text-[11px] text-muted-foreground", children: [_jsx(Lock, { className: "h-3 w-3" }), " ", t('engine.studio.pkg.readonly', locale)] })) : creating ? (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("input", { autoFocus: true, value: newLabel, onChange: (e) => {
911
1004
  setNewLabel(e.target.value);
912
1005
  if (!nameTouched)
913
1006
  setNewName(toFieldNameLoose(e.target.value));
@@ -936,12 +1029,19 @@ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
936
1029
  ? t('engine.studio.data.badge.settings', locale)
937
1030
  : formMode === 'layout'
938
1031
  ? t('engine.studio.data.badge.formLayout', locale)
939
- : t('engine.studio.data.badge.formPreview', locale)] }), (viewMode === 'grid' || viewMode === 'form') && (_jsxs("button", { type: "button", onClick: addField, title: t('engine.studio.data.addFieldTitle', locale), className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.data.addField', locale)] }))] }), error && (_jsx("div", { className: "mb-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive whitespace-pre-line", children: error })), viewMode === 'rules' ? (_jsx(ObjectValidationsPanel, { draft: objDraft, onPatch: onPatch })) : viewMode === 'settings' ? (_jsx(ObjectSettingsPanel, { name: current.name, draft: objDraft, onPatch: onPatch, locale: locale })) : viewMode === 'grid' && !hasBaseline ? (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/20 text-center", children: [_jsx("p", { className: "text-sm font-medium", children: t('engine.studio.data.draftObjectTitle', locale) }), _jsx("p", { className: "max-w-md text-[11px] leading-5 text-muted-foreground", children: t('engine.studio.data.draftObjectHint', locale) }), _jsx("button", { type: "button", onClick: () => {
1032
+ : t('engine.studio.data.badge.formPreview', locale)] }), (viewMode === 'grid' || viewMode === 'form') && !readOnly && (_jsxs("button", { type: "button", onClick: addField, title: t('engine.studio.data.addFieldTitle', locale), className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.data.addField', locale)] }))] }), error && (_jsx("div", { className: "mb-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive whitespace-pre-line", children: error })), viewMode === 'rules' ? (_jsx(ObjectValidationsPanel, { draft: objDraft, onPatch: onPatch, disabled: readOnly })) : viewMode === 'settings' ? (_jsx(ObjectSettingsPanel, { name: current.name, draft: objDraft, onPatch: onPatch, locale: locale, disabled: readOnly })) : viewMode === 'grid' && !hasBaseline ? (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/20 text-center", children: [_jsx("p", { className: "text-sm font-medium", children: t('engine.studio.data.draftObjectTitle', locale) }), _jsx("p", { className: "max-w-md text-[11px] leading-5 text-muted-foreground", children: t('engine.studio.data.draftObjectHint', locale) }), _jsx("button", { type: "button", onClick: () => {
940
1033
  setViewMode('form');
941
1034
  setFormMode('layout');
942
1035
  }, className: "mt-1 inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted", children: t('engine.studio.data.goDesignFields', locale) })] })) : viewMode === 'grid' ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "min-h-0 flex-1 overflow-auto rounded-lg border bg-background", children: _jsx(GridFieldAuthoringProvider, { value: {
943
- onAddColumn: addField,
944
- addColumnLabel: t('engine.studio.data.addField', locale),
1036
+ // Read-only package: keep the per-column inspector (view props)
1037
+ // but drop add-column and drag-reorder — both are doomed writes.
1038
+ ...(readOnly
1039
+ ? {}
1040
+ : {
1041
+ onAddColumn: addField,
1042
+ addColumnLabel: t('engine.studio.data.addField', locale),
1043
+ onReorderFields: doReorderFields,
1044
+ }),
945
1045
  onEditColumn: (fieldName) => {
946
1046
  // ignore non-field columns (e.g. the row-actions column)
947
1047
  if (readFields(objDraft.fields).entries.some((e) => e.name === fieldName)) {
@@ -949,7 +1049,6 @@ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
949
1049
  }
950
1050
  },
951
1051
  editColumnLabel: t('engine.studio.data.editFieldProps', locale),
952
- onReorderFields: doReorderFields,
953
1052
  }, children: _jsx(SchemaRendererProvider, { dataSource: adapter, children: _jsx(PluginObjectView, { schema: {
954
1053
  type: 'object-view',
955
1054
  objectName: current.name,
@@ -960,11 +1059,15 @@ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
960
1059
  table: {
961
1060
  fields: readFields(objDraft.fields)
962
1061
  .entries.map((e) => e.name)
963
- .filter((n) => !STUDIO_SYSTEM_FIELD_NAMES.has(n)),
1062
+ // Also drop a field named `actions`: the grid always pins
1063
+ // its own row-actions column headed "Actions", so a data
1064
+ // column of the same name reads as a duplicated column.
1065
+ // The field stays editable in the form designer.
1066
+ .filter((n) => !STUDIO_SYSTEM_FIELD_NAMES.has(n) && n !== 'actions'),
964
1067
  },
965
1068
  }, dataSource: adapter, renderListView: renderStudioGridList }, `${current.name}:${gridVer}`) }) }) }), _jsxs("p", { className: "mt-2 flex items-center gap-1 text-[11px] text-muted-foreground", children: [_jsx(MousePointer2, { className: "h-3 w-3" }), " ", t('engine.studio.data.gridHint', locale)] })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mb-3 flex items-center gap-2", children: [_jsxs("div", { className: "inline-flex rounded-md border p-0.5 text-[11px]", children: [_jsx("button", { type: "button", onClick: () => setFormMode('layout'), className: 'rounded px-2.5 py-0.5 ' +
966
1069
  (formMode === 'layout' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.form.layout', locale) }), _jsx("button", { type: "button", onClick: () => setFormMode('preview'), className: 'rounded px-2.5 py-0.5 ' +
967
- (formMode === 'preview' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.form.preview', locale) })] }), _jsx("span", { className: "text-[11px] text-muted-foreground", children: formMode === 'layout' ? t('engine.studio.data.form.layoutBadge', locale) : t('engine.studio.data.form.previewBadge', locale) }), formMode === 'preview' && (dirty || hasDraft) && (_jsx("span", { className: "inline-flex items-center gap-1 rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.data.form.previewWarn', locale) }))] }), formMode === 'layout' ? (_jsx(ObjectFormDesigner, { draft: objDraft, systemFieldNames: STUDIO_SYSTEM_FIELD_NAMES, onChange: onPatch, selectedField: fieldSel?.kind === 'field' ? fieldSel.id : null, onSelectField: (name) => setFieldSel({ kind: 'field', id: name }), onAddField: addField })) : !hasBaseline ? (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/20 text-center", children: [_jsx("p", { className: "text-sm font-medium", children: t('engine.studio.data.form.noPublishedTitle', locale) }), _jsx("p", { className: "max-w-md text-[11px] leading-5 text-muted-foreground", children: t('engine.studio.data.form.noPublishedHint', locale) })] })) : (_jsxs(_Fragment, { children: [_jsx("style", { children: `
1070
+ (formMode === 'preview' ? 'bg-muted font-medium' : 'text-muted-foreground hover:text-foreground'), children: t('engine.studio.data.form.preview', locale) })] }), _jsx("span", { className: "text-[11px] text-muted-foreground", children: formMode === 'layout' ? t('engine.studio.data.form.layoutBadge', locale) : t('engine.studio.data.form.previewBadge', locale) }), formMode === 'preview' && (dirty || hasDraft) && (_jsx("span", { className: "inline-flex items-center gap-1 rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.data.form.previewWarn', locale) }))] }), formMode === 'layout' ? (_jsx(ObjectFormDesigner, { draft: objDraft, systemFieldNames: STUDIO_SYSTEM_FIELD_NAMES, onChange: onPatch, selectedField: fieldSel?.kind === 'field' ? fieldSel.id : null, onSelectField: (name) => setFieldSel({ kind: 'field', id: name }), onAddField: addField, readOnly: readOnly })) : !hasBaseline ? (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/20 text-center", children: [_jsx("p", { className: "text-sm font-medium", children: t('engine.studio.data.form.noPublishedTitle', locale) }), _jsx("p", { className: "max-w-md text-[11px] leading-5 text-muted-foreground", children: t('engine.studio.data.form.noPublishedHint', locale) })] })) : (_jsxs(_Fragment, { children: [_jsx("style", { children: `
968
1071
  /* Design preview: the form is a click-to-select canvas, not a data-entry
969
1072
  * form. Disable interaction on field contents so a click anywhere on a
970
1073
  * field routes to its [data-field] wrapper (→ select), and neutralize the
@@ -997,12 +1100,28 @@ function DataPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
997
1100
  onPatch,
998
1101
  onClearSelection: () => setFieldSel(null),
999
1102
  onSelectionChange: setFieldSel,
1000
- readOnly: false,
1103
+ readOnly,
1001
1104
  locale,
1002
1105
  }) })] }))] })] }));
1003
1106
  }
1004
- /** Automations pillar — flows: list → FlowPreview (default OFF / review-then-enable). */
1005
- function AutomationsPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
1107
+ /**
1108
+ * A flow's live status in the Automations rail: a colored dot + On/Off, from the
1109
+ * engine's runtime state (persisted `status` is intent; this is what's actually
1110
+ * live). Renders nothing for a flow the engine doesn't know yet (never published)
1111
+ * — the amber "unpublished draft" chip already covers that case.
1112
+ */
1113
+ export function FlowStatusDot({ state, locale }) {
1114
+ if (!state)
1115
+ return null;
1116
+ const { enabled, bound } = state;
1117
+ const title = enabled
1118
+ ? bound
1119
+ ? t('engine.studio.auto.onBound', locale)
1120
+ : t('engine.studio.auto.onUnbound', locale)
1121
+ : t('engine.studio.auto.offTitle', locale);
1122
+ return (_jsxs("span", { title: title, className: "inline-flex shrink-0 items-center gap-1", children: [_jsx("span", { className: 'h-1.5 w-1.5 rounded-full ' + (enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40') }), _jsx("span", { className: 'text-[10px] ' + (enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'), children: enabled ? t('engine.studio.auto.on', locale) : t('engine.studio.auto.off', locale) })] }));
1123
+ }
1124
+ function AutomationsPillar({ packageId, publishNonce = 0, onDraftSaved, readOnly = false, }) {
1006
1125
  const client = useMetadataClient();
1007
1126
  const locale = useMetadataLocale();
1008
1127
  const [flows, setFlows] = React.useState([]);
@@ -1025,16 +1144,50 @@ function AutomationsPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
1025
1144
  const Preview = getMetadataPreview(current?.type ?? '');
1026
1145
  const inspector = getMetadataInspector('flow');
1027
1146
  const isEditable = !!Preview;
1147
+ // Runtime enable/bound state per flow (GET /automation/_status). Persisted
1148
+ // `status` is intent; this is what's actually live in the engine — the truth
1149
+ // behind the rail's status dots. Refetched after a publish (publishNonce);
1150
+ // degrades silently on an older backend / offline (dots just don't render).
1151
+ const [flowStatus, setFlowStatus] = React.useState({});
1028
1152
  React.useEffect(() => {
1029
1153
  let cancelled = false;
1030
1154
  (async () => {
1031
1155
  try {
1032
- const list = (await client.list('flow', { packageId }));
1156
+ const res = await fetch('/api/v1/automation/_status', { credentials: 'include', headers: { Accept: 'application/json' } });
1157
+ if (!res.ok)
1158
+ return;
1159
+ const payload = (await res.json().catch(() => null));
1160
+ const list = payload?.data?.flows ?? payload?.flows ?? [];
1161
+ if (cancelled || !Array.isArray(list))
1162
+ return;
1163
+ const map = {};
1164
+ for (const s of list)
1165
+ if (s?.name)
1166
+ map[s.name] = { enabled: s.enabled !== false, bound: !!s.bound };
1167
+ setFlowStatus(map);
1168
+ }
1169
+ catch {
1170
+ /* offline / older backend → no dots */
1171
+ }
1172
+ })();
1173
+ return () => {
1174
+ cancelled = true;
1175
+ };
1176
+ }, [publishNonce]);
1177
+ React.useEffect(() => {
1178
+ let cancelled = false;
1179
+ (async () => {
1180
+ try {
1181
+ // Published flows ∪ pending DRAFT flows — `list()` only sees
1182
+ // published/active metadata, so a just-authored flow that hasn't been
1183
+ // published yet (or a fresh writable-base package whose flows are all
1184
+ // drafts) would render an empty rail even though "Changes · N" shows the
1185
+ // draft exists. Mirrors the Data / Interfaces / Access pillars, which all
1186
+ // merge their drafts. Keyed on `publishNonce` too so drafts that go live
1187
+ // collapse back into the published rail after a package publish.
1188
+ const items = await loadPackageSurfaces(client, 'flow', packageId);
1033
1189
  if (cancelled)
1034
1190
  return;
1035
- const items = (list || [])
1036
- .map((f) => ({ type: 'flow', name: String(f.name ?? ''), label: String(f.label ?? f.name ?? '') }))
1037
- .filter((f) => f.name);
1038
1191
  setFlows(items);
1039
1192
  setCurrent((c) => c ?? items[0] ?? null);
1040
1193
  }
@@ -1050,7 +1203,7 @@ function AutomationsPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
1050
1203
  return () => {
1051
1204
  cancelled = true;
1052
1205
  };
1053
- }, [client, packageId]);
1206
+ }, [client, packageId, publishNonce]);
1054
1207
  const doCreateFlow = React.useCallback(async () => {
1055
1208
  const label = newLabel.trim();
1056
1209
  const name = toFieldName(newName.trim() || label);
@@ -1061,16 +1214,7 @@ function AutomationsPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
1061
1214
  try {
1062
1215
  // Minimal valid, autolaunched skeleton: start → end. The designer fills in
1063
1216
  // the trigger + nodes; publishing it is a separate, user-initiated step.
1064
- const skeleton = {
1065
- name,
1066
- label,
1067
- type: 'autolaunched',
1068
- nodes: [
1069
- { id: 'start', type: 'start', label: t('engine.studio.auto.nodeStart', locale) },
1070
- { id: 'end', type: 'end', label: t('engine.studio.auto.nodeEnd', locale) },
1071
- ],
1072
- edges: [{ id: 'e1', source: 'start', target: 'end' }],
1073
- };
1217
+ const skeleton = buildFlowSkeleton(name, label, t('engine.studio.auto.nodeStart', locale), t('engine.studio.auto.nodeEnd', locale));
1074
1218
  await client.save('flow', name, skeleton, { mode: 'draft', packageId });
1075
1219
  const item = { type: 'flow', name, label };
1076
1220
  setFlows((fs) => [...fs.filter((f) => f.name !== name), item]);
@@ -1141,9 +1285,35 @@ function AutomationsPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
1141
1285
  setSaving(false);
1142
1286
  }
1143
1287
  }, [client, current, draft, onDraftSaved]);
1144
- return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [_jsx("span", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.auto.defaultOff', locale) }), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublishedDraft', locale) })), _jsxs("button", { onClick: doSave, disabled: !current || !isEditable || !!saving, className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50", children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), t('engine.studio.saveDraft', locale)] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: "flex w-52 shrink-0 flex-col overflow-auto border-r p-2", children: [_jsxs("div", { className: "flex items-center gap-1 px-2 pb-1 pt-1", children: [_jsx("p", { className: "flex-1 text-[11px] font-medium text-muted-foreground", children: t('engine.studio.auto.heading', locale) }), _jsxs("button", { type: "button", onClick: () => setCreating((v) => !v), title: t('engine.studio.auto.newTitle', locale), className: "inline-flex items-center gap-0.5 rounded border px-1.5 py-0.5 text-[11px] hover:bg-muted", children: [_jsx(Plus, { className: "h-3 w-3" }), " ", t('engine.studio.new', locale)] })] }), flows.length > 0 &&
1288
+ // Enable/disable persists via the flow's deployment `status` (active = on,
1289
+ // obsolete = off) — the engine honors it on the next publish. The switch flips
1290
+ // it and saves the draft immediately; the change goes live when the package is
1291
+ // published (so "review before enabling" is preserved).
1292
+ const flowEnabled = draft.status !== 'obsolete' && draft.status !== 'invalid';
1293
+ const toggleEnabled = React.useCallback(async () => {
1294
+ if (!current)
1295
+ return;
1296
+ const next = !(draft.status !== 'obsolete' && draft.status !== 'invalid');
1297
+ const nextDraft = { ...draft, status: next ? 'active' : 'obsolete' };
1298
+ setDraft(nextDraft);
1299
+ setSaving('draft');
1300
+ setError(null);
1301
+ try {
1302
+ await client.save('flow', current.name, nextDraft, { mode: 'draft', packageId });
1303
+ setHasDraft(true);
1304
+ onDraftSaved?.();
1305
+ toast.success(next ? t('engine.studio.auto.enabledToast', locale) : t('engine.studio.auto.disabledToast', locale));
1306
+ }
1307
+ catch (e) {
1308
+ setError(formatMetadataError(e));
1309
+ }
1310
+ finally {
1311
+ setSaving(false);
1312
+ }
1313
+ }, [client, current, draft, packageId, onDraftSaved, locale]);
1314
+ return (_jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-1.5", children: [_jsx("span", { className: "text-[11px] text-muted-foreground", children: t('engine.studio.auto.defaultOff', locale) }), hasDraft && (_jsx("span", { className: "rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300", children: t('engine.studio.unpublishedDraft', locale) })), current && (_jsxs("button", { type: "button", role: "switch", "aria-checked": flowEnabled, onClick: toggleEnabled, disabled: !isEditable || !!saving, title: flowEnabled ? t('engine.studio.auto.disableTitle', locale) : t('engine.studio.auto.enableTitle', locale), className: "inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] hover:bg-muted disabled:opacity-50", children: [_jsx("span", { className: 'relative inline-flex h-3.5 w-6 shrink-0 items-center rounded-full transition-colors ' + (flowEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40'), children: _jsx("span", { className: 'inline-block h-2.5 w-2.5 rounded-full bg-white transition-transform ' + (flowEnabled ? 'translate-x-3' : 'translate-x-0.5') }) }), flowEnabled ? t('engine.studio.auto.enabled', locale) : t('engine.studio.auto.disabled', locale)] })), _jsxs("button", { onClick: doSave, disabled: !current || !isEditable || !!saving || readOnly, title: readOnly ? t('engine.studio.pkg.readonlyHint', locale) : undefined, className: "ml-auto inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-muted disabled:opacity-50", children: [saving === 'draft' ? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : _jsx(Save, { className: "h-3.5 w-3.5" }), t('engine.studio.saveDraft', locale)] })] }), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("nav", { className: "flex w-52 shrink-0 flex-col overflow-auto border-r p-2", children: [_jsxs("div", { className: "flex items-center gap-1 px-2 pb-1 pt-1", children: [_jsx("p", { className: "flex-1 text-[11px] font-medium text-muted-foreground", children: t('engine.studio.auto.heading', locale) }), !readOnly && (_jsxs("button", { type: "button", onClick: () => setCreating((v) => !v), title: t('engine.studio.auto.newTitle', locale), className: "inline-flex items-center gap-0.5 rounded border px-1.5 py-0.5 text-[11px] hover:bg-muted", children: [_jsx(Plus, { className: "h-3 w-3" }), " ", t('engine.studio.new', locale)] }))] }), flows.length > 0 &&
1145
1315
  flows.map((f) => (_jsxs("button", { onClick: () => setCurrent(f), className: 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs ' +
1146
- (current?.name === f.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60'), children: [_jsx(Workflow, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 truncate", children: f.label })] }, f.name))), flows.length === 0 && !creating && (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error ? t('engine.studio.loadFailed', locale) : !listed ? t('engine.studio.loading', locale) : t('engine.studio.auto.none', locale) })), creating && (_jsxs("div", { className: "mt-1 space-y-1.5 rounded-md border bg-muted/30 p-2", children: [_jsx("input", { autoFocus: true, value: newLabel, onChange: (e) => setNewLabel(e.target.value), onKeyDown: (e) => {
1316
+ (current?.name === f.name ? 'bg-muted font-medium' : 'text-foreground/90 hover:bg-muted/60'), children: [_jsx(Workflow, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 truncate", children: f.label }), _jsx(FlowStatusDot, { state: flowStatus[f.name], locale: locale })] }, f.name))), flows.length === 0 && !creating && (_jsx("p", { className: "px-2 py-3 text-[11px] text-muted-foreground", children: error ? t('engine.studio.loadFailed', locale) : !listed ? t('engine.studio.loading', locale) : t('engine.studio.auto.none', locale) })), creating && (_jsxs("div", { className: "mt-1 space-y-1.5 rounded-md border bg-muted/30 p-2", children: [_jsx("input", { autoFocus: true, value: newLabel, onChange: (e) => setNewLabel(e.target.value), onKeyDown: (e) => {
1147
1317
  if (e.key === 'Enter')
1148
1318
  void doCreateFlow();
1149
1319
  if (e.key === 'Escape')
@@ -1189,7 +1359,7 @@ function AutomationsPillar({ packageId, publishNonce = 0, onDraftSaved, }) {
1189
1359
  * leaving other packages' contributed rows untouched (P0). Save writes a package
1190
1360
  * DRAFT and publishes with the whole package via the top-bar Publish (P2, D6).
1191
1361
  */
1192
- function AccessPillar({ packageId, publishNonce, onDraftSaved, }) {
1362
+ function AccessPillar({ packageId, publishNonce, onDraftSaved, readOnly = false, }) {
1193
1363
  const client = useMetadataClient();
1194
1364
  const locale = useMetadataLocale();
1195
1365
  const [perms, setPerms] = React.useState([]);
@@ -1263,7 +1433,7 @@ function AccessPillar({ packageId, publishNonce, onDraftSaved, }) {
1263
1433
  try {
1264
1434
  // Package door → create as a DRAFT stamped with this package (D6/D7),
1265
1435
  // published atomically with the rest of the package.
1266
- await client.save('permission', name, { name, label, objects: {}, fields: {} }, { mode: 'draft', packageId });
1436
+ await client.save('permission', name, buildPermissionSkeleton(name, label), { mode: 'draft', packageId });
1267
1437
  toast.success(tFormat('engine.studio.access.created', locale, { label }));
1268
1438
  setCreating(false);
1269
1439
  setNewLabel('');
@@ -1301,6 +1471,6 @@ function AccessPillar({ packageId, publishNonce, onDraftSaved, }) {
1301
1471
  void doCreate();
1302
1472
  if (e.key === 'Escape')
1303
1473
  setCreating(false);
1304
- }, placeholder: t('engine.studio.access.idPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => void doCreate(), disabled: busy || !newLabel.trim() || !toFieldName(newName.trim() || newLabel) || toFieldName(newName.trim() || newLabel) === 'field', className: "inline-flex flex-1 items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [busy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.create', locale)] }), _jsx("button", { type: "button", onClick: () => setCreating(false), className: "rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted", children: t('engine.studio.cancel', locale) })] })] })) : (_jsxs("button", { type: "button", onClick: () => setCreating(true), className: "flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.access.new', locale)] })) })] }), _jsx("main", { className: "min-w-0 flex-1 overflow-auto", children: current ? (_jsx(PermissionMatrixEditPage, { type: "permission", name: current, packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved }, current)) : (_jsx("div", { className: "py-16 text-center text-sm text-muted-foreground", children: loaded && perms.length === 0 ? t('engine.studio.access.emptyMain', locale) : t('engine.studio.access.pick', locale) })) })] })] }));
1474
+ }, placeholder: t('engine.studio.access.idPlaceholder', locale), className: "h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => void doCreate(), disabled: busy || !newLabel.trim() || !toFieldName(newName.trim() || newLabel) || toFieldName(newName.trim() || newLabel) === 'field', className: "inline-flex flex-1 items-center justify-center gap-1 rounded-md bg-primary px-2 py-1 text-[11px] font-medium text-primary-foreground disabled:opacity-50", children: [busy ? _jsx(Loader2, { className: "h-3 w-3 animate-spin" }) : _jsx(Plus, { className: "h-3 w-3" }), t('engine.studio.create', locale)] }), _jsx("button", { type: "button", onClick: () => setCreating(false), className: "rounded-md border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted", children: t('engine.studio.cancel', locale) })] })] })) : readOnly ? (_jsxs("p", { title: t('engine.studio.pkg.readonlyHint', locale), className: "flex items-center gap-1.5 px-2 py-1.5 text-[11px] text-muted-foreground", children: [_jsx(Lock, { className: "h-3 w-3" }), " ", t('engine.studio.pkg.readonly', locale)] })) : (_jsxs("button", { type: "button", onClick: () => setCreating(true), className: "flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), " ", t('engine.studio.access.new', locale)] })) })] }), _jsx("main", { className: "min-w-0 flex-1 overflow-auto", children: current ? (_jsx(PermissionMatrixEditPage, { type: "permission", name: current, packageId: packageId, publishNonce: publishNonce, onDraftSaved: onDraftSaved }, current)) : (_jsx("div", { className: "py-16 text-center text-sm text-muted-foreground", children: loaded && perms.length === 0 ? t('engine.studio.access.emptyMain', locale) : t('engine.studio.access.pick', locale) })) })] })] }));
1305
1475
  }
1306
1476
  export default StudioDesignSurface;