@elytracms/studio 0.0.97 → 0.0.99

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 (107) hide show
  1. package/convex/assets.ts +16 -2
  2. package/convex/ingest-attempt.test.ts +161 -0
  3. package/convex/ingest.test.ts +269 -16
  4. package/convex/ingest.ts +238 -47
  5. package/convex/path-segment-refusal.test.ts +201 -0
  6. package/convex/prototype-key.test.ts +92 -0
  7. package/convex/records-column-transform.convex.test.ts +147 -0
  8. package/convex/records-zod-refinements.convex.test.ts +94 -0
  9. package/convex/schema.ts +1 -1
  10. package/dist/addon.d.ts +5 -3
  11. package/dist/addon.js +4 -3
  12. package/dist/addon.js.map +1 -1
  13. package/dist/components/app-views/default-modules.d.ts +10 -3
  14. package/dist/components/app-views/default-modules.js +5 -1
  15. package/dist/components/app-views/default-modules.js.map +1 -1
  16. package/dist/components/app-views/module.d.ts +15 -8
  17. package/dist/components/app-views/module.js.map +1 -1
  18. package/dist/components/builder/reuse-context.d.ts +29 -1
  19. package/dist/components/builder/reuse-context.js +58 -12
  20. package/dist/components/builder/reuse-context.js.map +1 -1
  21. package/dist/components/cms/cms-store.d.ts +5 -6
  22. package/dist/components/cms/cms-store.js +12 -24
  23. package/dist/components/cms/cms-store.js.map +1 -1
  24. package/dist/components/cms/field-control.d.ts +12 -1
  25. package/dist/components/cms/field-control.js +31 -6
  26. package/dist/components/cms/field-control.js.map +1 -1
  27. package/dist/components/cms/import-batch.d.ts +23 -0
  28. package/dist/components/cms/import-batch.js +43 -0
  29. package/dist/components/cms/import-batch.js.map +1 -0
  30. package/dist/components/cms/rich-text/editor.js +13 -10
  31. package/dist/components/cms/rich-text/editor.js.map +1 -1
  32. package/dist/components/cms/rich-text/value.d.ts +9 -0
  33. package/dist/components/cms/rich-text/value.js +13 -0
  34. package/dist/components/cms/rich-text/value.js.map +1 -1
  35. package/dist/components/cms/use-document-draft.d.ts +39 -3
  36. package/dist/components/cms/use-document-draft.js +46 -16
  37. package/dist/components/cms/use-document-draft.js.map +1 -1
  38. package/dist/components/cms/use-publish-controller.d.ts +7 -0
  39. package/dist/components/cms/use-publish-controller.js +17 -3
  40. package/dist/components/cms/use-publish-controller.js.map +1 -1
  41. package/dist/components/cms/versions-pane.js +8 -5
  42. package/dist/components/cms/versions-pane.js.map +1 -1
  43. package/dist/components/delivery-health-banner.d.ts +34 -16
  44. package/dist/components/delivery-health-banner.js +59 -27
  45. package/dist/components/delivery-health-banner.js.map +1 -1
  46. package/dist/components/issue-severity.d.ts +3 -2
  47. package/dist/components/issue-severity.js.map +1 -1
  48. package/dist/components/nav-issues-registry.d.ts +39 -24
  49. package/dist/components/nav-issues-registry.js +50 -21
  50. package/dist/components/nav-issues-registry.js.map +1 -1
  51. package/dist/components/nav-issues.d.ts +23 -50
  52. package/dist/components/nav-issues.js +31 -59
  53. package/dist/components/nav-issues.js.map +1 -1
  54. package/dist/components/notifications/notice-center.d.ts +61 -0
  55. package/dist/components/notifications/notice-center.js +119 -0
  56. package/dist/components/notifications/notice-center.js.map +1 -0
  57. package/dist/components/notifications/notice-model.d.ts +137 -0
  58. package/dist/components/notifications/notice-model.js +107 -0
  59. package/dist/components/notifications/notice-model.js.map +1 -0
  60. package/dist/components/notifications/outcome-notice.d.ts +16 -0
  61. package/dist/components/notifications/outcome-notice.js +34 -0
  62. package/dist/components/notifications/outcome-notice.js.map +1 -0
  63. package/dist/components/notifications/shell-findings.d.ts +36 -0
  64. package/dist/components/notifications/shell-findings.js +140 -0
  65. package/dist/components/notifications/shell-findings.js.map +1 -0
  66. package/dist/components/rail-view-source.js +5 -0
  67. package/dist/components/rail-view-source.js.map +1 -1
  68. package/dist/components/records/record-lens.js.map +1 -1
  69. package/dist/components/records/record-rail.d.ts +10 -2
  70. package/dist/components/records/record-rail.js +74 -4
  71. package/dist/components/records/record-rail.js.map +1 -1
  72. package/dist/components/section-nav.d.ts +15 -3
  73. package/dist/components/section-nav.js +39 -147
  74. package/dist/components/section-nav.js.map +1 -1
  75. package/dist/components/studio-project-screen.js.map +1 -1
  76. package/dist/components/studio-shell.js +8 -3
  77. package/dist/components/studio-shell.js.map +1 -1
  78. package/dist/components/use-narrow-viewport.d.ts +11 -0
  79. package/dist/components/use-narrow-viewport.js +25 -0
  80. package/dist/components/use-narrow-viewport.js.map +1 -0
  81. package/dist/components/users/role-policy-drift-issues.d.ts +14 -7
  82. package/dist/components/users/role-policy-drift-issues.js +17 -8
  83. package/dist/components/users/role-policy-drift-issues.js.map +1 -1
  84. package/dist/lib/collection-view/collection-view-source.d.ts +4 -2
  85. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  86. package/dist/lib/engine-version.d.ts +1 -1
  87. package/dist/lib/engine-version.js +1 -1
  88. package/dist/lib/engine-version.js.map +1 -1
  89. package/dist/lib/i18n/messages.de.js +24 -3
  90. package/dist/lib/i18n/messages.de.js.map +1 -1
  91. package/dist/lib/i18n/messages.en.d.ts +22 -2
  92. package/dist/lib/i18n/messages.en.js +27 -4
  93. package/dist/lib/i18n/messages.en.js.map +1 -1
  94. package/dist/lib/notices/toast-store.d.ts +61 -0
  95. package/dist/lib/notices/toast-store.js +116 -0
  96. package/dist/lib/notices/toast-store.js.map +1 -0
  97. package/dist/lib/operations/cms-persistence.d.ts +32 -7
  98. package/dist/lib/operations/cms-persistence.js +511 -71
  99. package/dist/lib/operations/cms-persistence.js.map +1 -1
  100. package/dist/lib/persistence/convex-adapter.js +61 -30
  101. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  102. package/dist/lib/persona/labels.js +1 -0
  103. package/dist/lib/persona/labels.js.map +1 -1
  104. package/dist/ui/toast.d.ts +24 -16
  105. package/dist/ui/toast.js +23 -32
  106. package/dist/ui/toast.js.map +1 -1
  107. package/package.json +4 -3
@@ -0,0 +1,137 @@
1
+ import type { CmsValidationIssue } from '@elytracms/core/cms-core';
2
+ import type { IssueSeverity } from '../issue-severity.js';
3
+ /**
4
+ * EC-690 — the model of the studio's ONE notification-and-issue surface (design note and
5
+ * owner ruling in `tasks/EC-690-one-notification-and-issue-surface.md`).
6
+ *
7
+ * Two kinds of entry, two TYPES — never a `dismissable` flag:
8
+ * - a {@link Finding} is PERSISTENT: it is listed while its producer publishes it, and no
9
+ * reader can dismiss it (no API exists for it);
10
+ * - a transient notice (`lib/notices/toast-store.ts` `ToastItem`) is an action's outcome:
11
+ * always dismissable; a success expires, an error stays until dismissed.
12
+ */
13
+ /**
14
+ * WHERE a finding is — what the tray's "Go to" opens, or the file it names. Exported for
15
+ * addons (`@elytracms/studio/addon`), so a contributed finding can be located too.
16
+ *
17
+ * - `record` — a row of a record rail, by RECORD KIND (`redirect`, `submission`): a
18
+ * consumer names the rail's view (`nav.rail(kind, { id })`), the kind is what the data is.
19
+ * No `id` → the rail's list.
20
+ * - `document` — a CMS document.
21
+ * - `view` — a studio view's home, by view id.
22
+ * - `config` — code, so it is NAMED, never linked: `project` = `elytra.config.ts`, `studio` =
23
+ * the developer's `defineStudio({…})` (its file path is theirs, AGENTS §4). `entry` names the
24
+ * declaration (a route id, a view id).
25
+ * - `deployment` — a setting of the Convex deployment (env vars), named, never linked.
26
+ */
27
+ export type NoticeLocation = {
28
+ kind: 'record';
29
+ recordKind: string;
30
+ id?: string;
31
+ } | {
32
+ kind: 'document';
33
+ collection: string;
34
+ id: string;
35
+ } | {
36
+ kind: 'view';
37
+ view: string;
38
+ } | {
39
+ kind: 'config';
40
+ declaredIn: 'project' | 'studio';
41
+ entry?: string;
42
+ } | {
43
+ kind: 'deployment';
44
+ settings: readonly string[];
45
+ };
46
+ /** An action a finding's row offers besides "Go to" (an engine finding's Retry). */
47
+ export interface FindingAction {
48
+ label: string;
49
+ run(): void;
50
+ }
51
+ /** One persistent finding, as the tray reads it. */
52
+ export interface Finding {
53
+ /** Unique across the tray (its publisher's identity + its key). */
54
+ id: string;
55
+ /** Who reported it — an engine source (`routes`, `config`, …) or a contributor's name. */
56
+ source: string;
57
+ /** Stable within its source. */
58
+ key: string;
59
+ severity: IssueSeverity;
60
+ /** The localized message — the detail. */
61
+ message: string;
62
+ /** A short localized title ("Invalid redirect"); absent → the row leads with the message. */
63
+ title?: string;
64
+ location?: NoticeLocation;
65
+ action?: FindingAction;
66
+ }
67
+ /**
68
+ * The engine's own finding sources, in the order the tray lists them (critic point 2: an
69
+ * EXPLICIT rank, never "registration order" — a source that clears and comes back must not
70
+ * move behind the addons). Reserved: an addon cannot publish under one of these names, or
71
+ * under the `engine:` prefix (it is listed, and reported — never silently merged).
72
+ */
73
+ export declare const ENGINE_FINDING_SOURCES: readonly ["config", "customization", "routes", "orphans", "delivery"];
74
+ export type EngineFindingSource = (typeof ENGINE_FINDING_SOURCES)[number];
75
+ export declare function isReservedFindingSource(source: string): boolean;
76
+ /**
77
+ * The tray's ONE order: source rank, then severity (errors first), then source name, then key
78
+ * — total and deterministic, whatever order the publishers mounted in.
79
+ */
80
+ export declare function orderFindings(findings: readonly Finding[]): Finding[];
81
+ /**
82
+ * Where a URL-map issue (EC-469/590/628) is: core addresses a redirect as
83
+ * `['redirects', <record id>]` (an unreadable row without an id: its index), and a route as
84
+ * `['routes', <route id>]` — routes are code, so a route issue is named in `elytra.config.ts`.
85
+ */
86
+ export declare function routeIssueLocation(issue: Pick<CmsValidationIssue, 'path'>): NoticeLocation | undefined;
87
+ /** A studio view as the location resolver needs it. */
88
+ export interface LocatableView {
89
+ id: string;
90
+ /** The record kind a record rail lists (`railView`), absent on every other view. */
91
+ recordKind?: string;
92
+ }
93
+ /** What a finding's row offers for its location. */
94
+ export type GoToTarget = {
95
+ kind: 'link';
96
+ splat: string;
97
+ }
98
+ /** `config` / `deployment`: what to name (the file, the settings) — never a link. */
99
+ | {
100
+ kind: 'named';
101
+ declaredIn?: 'project' | 'studio';
102
+ entry?: string;
103
+ settings?: readonly string[];
104
+ };
105
+ /**
106
+ * Where "Go to" leads, or what the row names. `null` when the location's view is not in this
107
+ * studio (a record kind no rail lists, a view the consumer did not mount): the message still
108
+ * says what is wrong, there is just nowhere in this studio to open.
109
+ */
110
+ export declare function goToTarget(location: NoticeLocation, views: readonly LocatableView[]): GoToTarget | null;
111
+ /** Who is reading the tray. */
112
+ export interface FindingAudience {
113
+ /** The role grants `project.administer` — the one who can change config and the deployment. */
114
+ isAdmin: boolean;
115
+ /** The role grants `content.read`. */
116
+ canReadContent: boolean;
117
+ /** The views this role sees in the rail (`isNavViewVisible`), with the record kind a rail lists. */
118
+ views: readonly (LocatableView & {
119
+ visible: boolean;
120
+ })[];
121
+ }
122
+ /**
123
+ * Owner ruling (b), 2026-10-28: config and deployment findings go to admins; a finding
124
+ * located in content goes to anyone who can open that content; nothing is hidden from the
125
+ * role that can act on it. So:
126
+ * - `config` / `deployment` → admins;
127
+ * - `record` → whoever sees the rail that lists the kind; no such rail → admins (nobody else
128
+ * can act on it from this studio, and it is still a real problem);
129
+ * - `view` → whoever sees that view (absent view → admins);
130
+ * - `document` → whoever can read content;
131
+ * - no location → everyone (nothing to gate on, and hiding it could hide it from its fixer).
132
+ */
133
+ export declare function canSeeFinding(finding: Pick<Finding, 'location'>, audience: FindingAudience): boolean;
134
+ /** Per-severity counts, for the header badge. */
135
+ export declare function countBySeverity(entries: readonly {
136
+ severity: IssueSeverity;
137
+ }[]): Record<IssueSeverity, number>;
@@ -0,0 +1,107 @@
1
+ import { redirectRecordKind } from '@elytracms/core/persistence';
2
+ import { studioPath } from '@elytracms/studio/router';
3
+ /**
4
+ * The engine's own finding sources, in the order the tray lists them (critic point 2: an
5
+ * EXPLICIT rank, never "registration order" — a source that clears and comes back must not
6
+ * move behind the addons). Reserved: an addon cannot publish under one of these names, or
7
+ * under the `engine:` prefix (it is listed, and reported — never silently merged).
8
+ */
9
+ export const ENGINE_FINDING_SOURCES = ['config', 'customization', 'routes', 'orphans', 'delivery'];
10
+ /** Where every contributed source ranks: after the engine's, in source-name order. */
11
+ const CONTRIBUTED_RANK = ENGINE_FINDING_SOURCES.length + 1;
12
+ export function isReservedFindingSource(source) {
13
+ return ENGINE_FINDING_SOURCES.includes(source) || source.startsWith('engine:');
14
+ }
15
+ function sourceRank(source) {
16
+ const index = ENGINE_FINDING_SOURCES.indexOf(source);
17
+ return index === -1 ? CONTRIBUTED_RANK : index;
18
+ }
19
+ const SEVERITY_RANK = { error: 0, warning: 1 };
20
+ /**
21
+ * The tray's ONE order: source rank, then severity (errors first), then source name, then key
22
+ * — total and deterministic, whatever order the publishers mounted in.
23
+ */
24
+ export function orderFindings(findings) {
25
+ return [...findings].sort((a, b) => sourceRank(a.source) - sourceRank(b.source) ||
26
+ SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||
27
+ (a.source < b.source ? -1 : a.source > b.source ? 1 : 0) ||
28
+ (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
29
+ }
30
+ /**
31
+ * Where a URL-map issue (EC-469/590/628) is: core addresses a redirect as
32
+ * `['redirects', <record id>]` (an unreadable row without an id: its index), and a route as
33
+ * `['routes', <route id>]` — routes are code, so a route issue is named in `elytra.config.ts`.
34
+ */
35
+ export function routeIssueLocation(issue) {
36
+ const [domain, id] = issue.path;
37
+ if (domain === 'redirects') {
38
+ return typeof id === 'string'
39
+ ? { kind: 'record', recordKind: redirectRecordKind.kind, id }
40
+ : { kind: 'record', recordKind: redirectRecordKind.kind };
41
+ }
42
+ if (domain === 'routes' && typeof id === 'string')
43
+ return { kind: 'config', declaredIn: 'project', entry: id };
44
+ return undefined;
45
+ }
46
+ /**
47
+ * Where "Go to" leads, or what the row names. `null` when the location's view is not in this
48
+ * studio (a record kind no rail lists, a view the consumer did not mount): the message still
49
+ * says what is wrong, there is just nowhere in this studio to open.
50
+ */
51
+ export function goToTarget(location, views) {
52
+ switch (location.kind) {
53
+ case 'record': {
54
+ const view = views.find((candidate) => candidate.recordKind === location.recordKind);
55
+ if (!view)
56
+ return null;
57
+ return { kind: 'link', splat: location.id === undefined ? view.id : `${view.id}/${location.id}` };
58
+ }
59
+ case 'document':
60
+ return { kind: 'link', splat: studioPath.cmsDocument(location.collection, location.id) };
61
+ case 'view':
62
+ return views.some((candidate) => candidate.id === location.view) ? { kind: 'link', splat: location.view } : null;
63
+ case 'config':
64
+ return { kind: 'named', declaredIn: location.declaredIn, ...(location.entry !== undefined ? { entry: location.entry } : {}) };
65
+ case 'deployment':
66
+ return { kind: 'named', settings: location.settings };
67
+ }
68
+ }
69
+ /**
70
+ * Owner ruling (b), 2026-10-28: config and deployment findings go to admins; a finding
71
+ * located in content goes to anyone who can open that content; nothing is hidden from the
72
+ * role that can act on it. So:
73
+ * - `config` / `deployment` → admins;
74
+ * - `record` → whoever sees the rail that lists the kind; no such rail → admins (nobody else
75
+ * can act on it from this studio, and it is still a real problem);
76
+ * - `view` → whoever sees that view (absent view → admins);
77
+ * - `document` → whoever can read content;
78
+ * - no location → everyone (nothing to gate on, and hiding it could hide it from its fixer).
79
+ */
80
+ export function canSeeFinding(finding, audience) {
81
+ const location = finding.location;
82
+ if (!location)
83
+ return true;
84
+ switch (location.kind) {
85
+ case 'config':
86
+ case 'deployment':
87
+ return audience.isAdmin;
88
+ case 'record': {
89
+ const view = audience.views.find((candidate) => candidate.recordKind === location.recordKind);
90
+ return view ? view.visible : audience.isAdmin;
91
+ }
92
+ case 'view': {
93
+ const view = audience.views.find((candidate) => candidate.id === location.view);
94
+ return view ? view.visible : audience.isAdmin;
95
+ }
96
+ case 'document':
97
+ return audience.canReadContent;
98
+ }
99
+ }
100
+ /** Per-severity counts, for the header badge. */
101
+ export function countBySeverity(entries) {
102
+ const counts = { error: 0, warning: 0 };
103
+ for (const entry of entries)
104
+ counts[entry.severity] += 1;
105
+ return counts;
106
+ }
107
+ //# sourceMappingURL=notice-model.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notice-model.js","sourceRoot":"","sources":["../../../src/components/notifications/notice-model.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAA;AAChE,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAA;AA0DrD;;;;;GAKG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,QAAQ,EAAE,eAAe,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,CAAU,CAAA;AAG3G,sFAAsF;AACtF,MAAM,gBAAgB,GAAG,sBAAsB,CAAC,MAAM,GAAG,CAAC,CAAA;AAE1D,MAAM,UAAU,uBAAuB,CAAC,MAAc;IACpD,OAAQ,sBAA4C,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC,CAAA;AACvG,CAAC;AAED,SAAS,UAAU,CAAC,MAAc;IAChC,MAAM,KAAK,GAAI,sBAA4C,CAAC,OAAO,CAAC,MAAM,CAAC,CAAA;IAC3E,OAAO,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC,KAAK,CAAA;AAChD,CAAC;AAED,MAAM,aAAa,GAAkC,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,CAAA;AAE7E;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,QAA4B;IACxD,OAAO,CAAC,GAAG,QAAQ,CAAC,CAAC,IAAI,CACvB,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CACP,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC;QAC3C,aAAa,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,QAAQ,CAAC;QACrD,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACxD,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAC/C,CAAA;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAAuC;IACxE,MAAM,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,KAAK,CAAC,IAAI,CAAA;IAC/B,IAAI,MAAM,KAAK,WAAW,EAAE,CAAC;QAC3B,OAAO,OAAO,EAAE,KAAK,QAAQ;YAC3B,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,EAAE,kBAAkB,CAAC,IAAI,EAAE,EAAE,EAAE;YAC7D,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,EAAE,kBAAkB,CAAC,IAAI,EAAE,CAAA;IAC7D,CAAC;IACD,IAAI,MAAM,KAAK,QAAQ,IAAI,OAAO,EAAE,KAAK,QAAQ;QAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,EAAE,CAAA;IAC9G,OAAO,SAAS,CAAA;AAClB,CAAC;AAeD;;;;GAIG;AACH,MAAM,UAAU,UAAU,CAAC,QAAwB,EAAE,KAA+B;IAClF,QAAQ,QAAQ,CAAC,IAAI,EAAE,CAAC;QACtB,KAAK,QAAQ,CAAC,CAAC,CAAC;YACd,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,UAAU,KAAK,QAAQ,CAAC,UAAU,CAAC,CAAA;YACpF,IAAI,CAAC,IAAI;gBAAE,OAAO,IAAI,CAAA;YACtB,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,CAAC,EAAE,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,EAAE,IAAI,QAAQ,CAAC,EAAE,EAAE,EAAE,CAAA;QACnG,CAAC;QACD,KAAK,UAAU;YACb,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAA;QAC1F,KAAK,MAAM;YACT,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,EAAE,KAAK,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAA;QAClH,KAAK,QAAQ;YACX,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,UAAU,EAAE,QAAQ,CAAC,UAAU,EAAE,GAAG,CAAC,QAAQ,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAA;QAC/H,KAAK,YAAY;YACf,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAA;IACzD,CAAC;AACH,CAAC;AAYD;;;;;;;;;;GAUG;AACH,MAAM,UAAU,aAAa,CAAC,OAAkC,EAAE,QAAyB;IACzF,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAA;IACjC,IAAI,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAA;IAC1B,QAAQ,QAAQ,CAAC,IAAI,EAAE,CAAC;QACtB,KAAK,QAAQ,CAAC;QACd,KAAK,YAAY;YACf,OAAO,QAAQ,CAAC,OAAO,CAAA;QACzB,KAAK,QAAQ,CAAC,CAAC,CAAC;YACd,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,UAAU,KAAK,QAAQ,CAAC,UAAU,CAAC,CAAA;YAC7F,OAAO,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAA;QAC/C,CAAC;QACD,KAAK,MAAM,CAAC,CAAC,CAAC;YACZ,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,EAAE,KAAK,QAAQ,CAAC,IAAI,CAAC,CAAA;YAC/E,OAAO,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAA;QAC/C,CAAC;QACD,KAAK,UAAU;YACb,OAAO,QAAQ,CAAC,cAAc,CAAA;IAClC,CAAC;AACH,CAAC;AAED,iDAAiD;AACjD,MAAM,UAAU,eAAe,CAAC,OAA+C;IAC7E,MAAM,MAAM,GAAkC,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,CAAA;IACtE,KAAK,MAAM,KAAK,IAAI,OAAO;QAAE,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAA;IACxD,OAAO,MAAM,CAAA;AACf,CAAC","sourcesContent":["import type { CmsValidationIssue } from '@elytracms/core/cms-core'\nimport { redirectRecordKind } from '@elytracms/core/persistence'\nimport { studioPath } from '@elytracms/studio/router'\nimport type { IssueSeverity } from '../issue-severity'\n\n/**\n * EC-690 — the model of the studio's ONE notification-and-issue surface (design note and\n * owner ruling in `tasks/EC-690-one-notification-and-issue-surface.md`).\n *\n * Two kinds of entry, two TYPES — never a `dismissable` flag:\n * - a {@link Finding} is PERSISTENT: it is listed while its producer publishes it, and no\n * reader can dismiss it (no API exists for it);\n * - a transient notice (`lib/notices/toast-store.ts` `ToastItem`) is an action's outcome:\n * always dismissable; a success expires, an error stays until dismissed.\n */\n\n/**\n * WHERE a finding is — what the tray's \"Go to\" opens, or the file it names. Exported for\n * addons (`@elytracms/studio/addon`), so a contributed finding can be located too.\n *\n * - `record` — a row of a record rail, by RECORD KIND (`redirect`, `submission`): a\n * consumer names the rail's view (`nav.rail(kind, { id })`), the kind is what the data is.\n * No `id` → the rail's list.\n * - `document` — a CMS document.\n * - `view` — a studio view's home, by view id.\n * - `config` — code, so it is NAMED, never linked: `project` = `elytra.config.ts`, `studio` =\n * the developer's `defineStudio({…})` (its file path is theirs, AGENTS §4). `entry` names the\n * declaration (a route id, a view id).\n * - `deployment` — a setting of the Convex deployment (env vars), named, never linked.\n */\nexport type NoticeLocation =\n | { kind: 'record'; recordKind: string; id?: string }\n | { kind: 'document'; collection: string; id: string }\n | { kind: 'view'; view: string }\n | { kind: 'config'; declaredIn: 'project' | 'studio'; entry?: string }\n | { kind: 'deployment'; settings: readonly string[] }\n\n/** An action a finding's row offers besides \"Go to\" (an engine finding's Retry). */\nexport interface FindingAction {\n label: string\n run(): void\n}\n\n/** One persistent finding, as the tray reads it. */\nexport interface Finding {\n /** Unique across the tray (its publisher's identity + its key). */\n id: string\n /** Who reported it — an engine source (`routes`, `config`, …) or a contributor's name. */\n source: string\n /** Stable within its source. */\n key: string\n severity: IssueSeverity\n /** The localized message — the detail. */\n message: string\n /** A short localized title (\"Invalid redirect\"); absent → the row leads with the message. */\n title?: string\n location?: NoticeLocation\n action?: FindingAction\n}\n\n/**\n * The engine's own finding sources, in the order the tray lists them (critic point 2: an\n * EXPLICIT rank, never \"registration order\" — a source that clears and comes back must not\n * move behind the addons). Reserved: an addon cannot publish under one of these names, or\n * under the `engine:` prefix (it is listed, and reported — never silently merged).\n */\nexport const ENGINE_FINDING_SOURCES = ['config', 'customization', 'routes', 'orphans', 'delivery'] as const\nexport type EngineFindingSource = (typeof ENGINE_FINDING_SOURCES)[number]\n\n/** Where every contributed source ranks: after the engine's, in source-name order. */\nconst CONTRIBUTED_RANK = ENGINE_FINDING_SOURCES.length + 1\n\nexport function isReservedFindingSource(source: string): boolean {\n return (ENGINE_FINDING_SOURCES as readonly string[]).includes(source) || source.startsWith('engine:')\n}\n\nfunction sourceRank(source: string): number {\n const index = (ENGINE_FINDING_SOURCES as readonly string[]).indexOf(source)\n return index === -1 ? CONTRIBUTED_RANK : index\n}\n\nconst SEVERITY_RANK: Record<IssueSeverity, number> = { error: 0, warning: 1 }\n\n/**\n * The tray's ONE order: source rank, then severity (errors first), then source name, then key\n * — total and deterministic, whatever order the publishers mounted in.\n */\nexport function orderFindings(findings: readonly Finding[]): Finding[] {\n return [...findings].sort(\n (a, b) =>\n sourceRank(a.source) - sourceRank(b.source) ||\n SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||\n (a.source < b.source ? -1 : a.source > b.source ? 1 : 0) ||\n (a.key < b.key ? -1 : a.key > b.key ? 1 : 0),\n )\n}\n\n/**\n * Where a URL-map issue (EC-469/590/628) is: core addresses a redirect as\n * `['redirects', <record id>]` (an unreadable row without an id: its index), and a route as\n * `['routes', <route id>]` — routes are code, so a route issue is named in `elytra.config.ts`.\n */\nexport function routeIssueLocation(issue: Pick<CmsValidationIssue, 'path'>): NoticeLocation | undefined {\n const [domain, id] = issue.path\n if (domain === 'redirects') {\n return typeof id === 'string'\n ? { kind: 'record', recordKind: redirectRecordKind.kind, id }\n : { kind: 'record', recordKind: redirectRecordKind.kind }\n }\n if (domain === 'routes' && typeof id === 'string') return { kind: 'config', declaredIn: 'project', entry: id }\n return undefined\n}\n\n/** A studio view as the location resolver needs it. */\nexport interface LocatableView {\n id: string\n /** The record kind a record rail lists (`railView`), absent on every other view. */\n recordKind?: string\n}\n\n/** What a finding's row offers for its location. */\nexport type GoToTarget =\n | { kind: 'link'; splat: string }\n /** `config` / `deployment`: what to name (the file, the settings) — never a link. */\n | { kind: 'named'; declaredIn?: 'project' | 'studio'; entry?: string; settings?: readonly string[] }\n\n/**\n * Where \"Go to\" leads, or what the row names. `null` when the location's view is not in this\n * studio (a record kind no rail lists, a view the consumer did not mount): the message still\n * says what is wrong, there is just nowhere in this studio to open.\n */\nexport function goToTarget(location: NoticeLocation, views: readonly LocatableView[]): GoToTarget | null {\n switch (location.kind) {\n case 'record': {\n const view = views.find((candidate) => candidate.recordKind === location.recordKind)\n if (!view) return null\n return { kind: 'link', splat: location.id === undefined ? view.id : `${view.id}/${location.id}` }\n }\n case 'document':\n return { kind: 'link', splat: studioPath.cmsDocument(location.collection, location.id) }\n case 'view':\n return views.some((candidate) => candidate.id === location.view) ? { kind: 'link', splat: location.view } : null\n case 'config':\n return { kind: 'named', declaredIn: location.declaredIn, ...(location.entry !== undefined ? { entry: location.entry } : {}) }\n case 'deployment':\n return { kind: 'named', settings: location.settings }\n }\n}\n\n/** Who is reading the tray. */\nexport interface FindingAudience {\n /** The role grants `project.administer` — the one who can change config and the deployment. */\n isAdmin: boolean\n /** The role grants `content.read`. */\n canReadContent: boolean\n /** The views this role sees in the rail (`isNavViewVisible`), with the record kind a rail lists. */\n views: readonly (LocatableView & { visible: boolean })[]\n}\n\n/**\n * Owner ruling (b), 2026-10-28: config and deployment findings go to admins; a finding\n * located in content goes to anyone who can open that content; nothing is hidden from the\n * role that can act on it. So:\n * - `config` / `deployment` → admins;\n * - `record` → whoever sees the rail that lists the kind; no such rail → admins (nobody else\n * can act on it from this studio, and it is still a real problem);\n * - `view` → whoever sees that view (absent view → admins);\n * - `document` → whoever can read content;\n * - no location → everyone (nothing to gate on, and hiding it could hide it from its fixer).\n */\nexport function canSeeFinding(finding: Pick<Finding, 'location'>, audience: FindingAudience): boolean {\n const location = finding.location\n if (!location) return true\n switch (location.kind) {\n case 'config':\n case 'deployment':\n return audience.isAdmin\n case 'record': {\n const view = audience.views.find((candidate) => candidate.recordKind === location.recordKind)\n return view ? view.visible : audience.isAdmin\n }\n case 'view': {\n const view = audience.views.find((candidate) => candidate.id === location.view)\n return view ? view.visible : audience.isAdmin\n }\n case 'document':\n return audience.canReadContent\n }\n}\n\n/** Per-severity counts, for the header badge. */\nexport function countBySeverity(entries: readonly { severity: IssueSeverity }[]): Record<IssueSeverity, number> {\n const counts: Record<IssueSeverity, number> = { error: 0, warning: 0 }\n for (const entry of entries) counts[entry.severity] += 1\n return counts\n}\n"]}
@@ -0,0 +1,16 @@
1
+ import type { OperationError } from '../../lib/operations/index.js';
2
+ /**
3
+ * The tray text of a failed operation an owner holds: its localized label and message — or
4
+ * `null` when there is nothing to show. A session expiry is never a notice: it is routed to
5
+ * the blocking re-auth overlay (EC-403), and a tray entry would only duplicate it.
6
+ */
7
+ export declare function outcomeNoticeMessage(error: OperationError | null): string | null;
8
+ /**
9
+ * EC-690 (critic point 4) — a failed operation its OWNER holds (`persistError`, a rail's
10
+ * `lastError`) as an error notice of the one issues tray, for as long as the owner holds it:
11
+ * raised when it is set, retracted when the owner clears it (the next save, the inline
12
+ * banner's dismiss), and dismissing it in the tray calls `clear` — one state, two views. It
13
+ * stays until dismissed (owner ruling (d)). Registered ONCE, by the hook's caller, which must
14
+ * be the error's real owner — never an edit shell that merely renders it.
15
+ */
16
+ export declare function useOutcomeNotice(error: OperationError | null, clear: () => void): void;
@@ -0,0 +1,34 @@
1
+ import { useEffect, useId } from 'react';
2
+ import { isSessionExpiredError } from '../../lib/auth-errors.js';
3
+ import { operationErrorLabel, operationErrorMessage } from '../../lib/persona/labels.js';
4
+ import { studioToasts } from '../../lib/notices/toast-store.js';
5
+ /**
6
+ * The tray text of a failed operation an owner holds: its localized label and message — or
7
+ * `null` when there is nothing to show. A session expiry is never a notice: it is routed to
8
+ * the blocking re-auth overlay (EC-403), and a tray entry would only duplicate it.
9
+ */
10
+ export function outcomeNoticeMessage(error) {
11
+ if (!error || isSessionExpiredError(error))
12
+ return null;
13
+ return `${operationErrorLabel(error.code)} — ${operationErrorMessage(error)}`;
14
+ }
15
+ /**
16
+ * EC-690 (critic point 4) — a failed operation its OWNER holds (`persistError`, a rail's
17
+ * `lastError`) as an error notice of the one issues tray, for as long as the owner holds it:
18
+ * raised when it is set, retracted when the owner clears it (the next save, the inline
19
+ * banner's dismiss), and dismissing it in the tray calls `clear` — one state, two views. It
20
+ * stays until dismissed (owner ruling (d)). Registered ONCE, by the hook's caller, which must
21
+ * be the error's real owner — never an edit shell that merely renders it.
22
+ */
23
+ export function useOutcomeNotice(error, clear) {
24
+ const key = useId();
25
+ const message = outcomeNoticeMessage(error);
26
+ useEffect(() => {
27
+ if (message === null)
28
+ studioToasts.retract(key);
29
+ else
30
+ studioToasts.raise(key, { message, variant: 'error', onDismiss: clear });
31
+ }, [key, message, clear]);
32
+ useEffect(() => () => studioToasts.retract(key), [key]);
33
+ }
34
+ //# sourceMappingURL=outcome-notice.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"outcome-notice.js","sourceRoot":"","sources":["../../../src/components/notifications/outcome-notice.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,OAAO,CAAA;AAExC,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAA;AAC7D,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAA;AACrF,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAA;AAE5D;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAA4B;IAC/D,IAAI,CAAC,KAAK,IAAI,qBAAqB,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IACvD,OAAO,GAAG,mBAAmB,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,qBAAqB,CAAC,KAAK,CAAC,EAAE,CAAA;AAC/E,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAA4B,EAAE,KAAiB;IAC9E,MAAM,GAAG,GAAG,KAAK,EAAE,CAAA;IACnB,MAAM,OAAO,GAAG,oBAAoB,CAAC,KAAK,CAAC,CAAA;IAC3C,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,OAAO,KAAK,IAAI;YAAE,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;;YAC1C,YAAY,CAAC,KAAK,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAA;IAC/E,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC,CAAA;IACzB,SAAS,CAAC,GAAG,EAAE,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAAA;AACzD,CAAC","sourcesContent":["import { useEffect, useId } from 'react'\nimport type { OperationError } from '../../lib/operations'\nimport { isSessionExpiredError } from '../../lib/auth-errors'\nimport { operationErrorLabel, operationErrorMessage } from '../../lib/persona/labels'\nimport { studioToasts } from '../../lib/notices/toast-store'\n\n/**\n * The tray text of a failed operation an owner holds: its localized label and message — or\n * `null` when there is nothing to show. A session expiry is never a notice: it is routed to\n * the blocking re-auth overlay (EC-403), and a tray entry would only duplicate it.\n */\nexport function outcomeNoticeMessage(error: OperationError | null): string | null {\n if (!error || isSessionExpiredError(error)) return null\n return `${operationErrorLabel(error.code)} — ${operationErrorMessage(error)}`\n}\n\n/**\n * EC-690 (critic point 4) — a failed operation its OWNER holds (`persistError`, a rail's\n * `lastError`) as an error notice of the one issues tray, for as long as the owner holds it:\n * raised when it is set, retracted when the owner clears it (the next save, the inline\n * banner's dismiss), and dismissing it in the tray calls `clear` — one state, two views. It\n * stays until dismissed (owner ruling (d)). Registered ONCE, by the hook's caller, which must\n * be the error's real owner — never an edit shell that merely renders it.\n */\nexport function useOutcomeNotice(error: OperationError | null, clear: () => void): void {\n const key = useId()\n const message = outcomeNoticeMessage(error)\n useEffect(() => {\n if (message === null) studioToasts.retract(key)\n else studioToasts.raise(key, { message, variant: 'error', onDismiss: clear })\n }, [key, message, clear])\n useEffect(() => () => studioToasts.retract(key), [key])\n}\n"]}
@@ -0,0 +1,36 @@
1
+ import type { Project } from '@elytracms/core/studio-core';
2
+ import type { Finding, FindingAudience } from './notice-model.js';
3
+ /**
4
+ * EC-690 — every persistent finding of the studio shell, for the ONE issues tray.
5
+ *
6
+ * The ENGINE's own sources are computed here, in render — config (EC-479), customization
7
+ * (EC-236/467/478), the URL map (EC-469/590/628), orphaned collections (EC-606) and
8
+ * instant-publish health (EC-268) — so they are there on the first paint (no effect has to
9
+ * run before the badge can count them) and no contributor can overwrite them: they are not
10
+ * in the registry's namespace at all. Contributions (`useNavIssues`, a view's `useIssues`)
11
+ * come from the registry. Then the viewer's audience filter (owner ruling (b)) and the ONE
12
+ * order ({@link orderFindings}).
13
+ *
14
+ * The findings hooks moved here from `SectionNav`, which renders none of them any more (the
15
+ * rail foot is only its collapse toggle — owner ruling (a)).
16
+ */
17
+ export declare function useShellFindings(project: Project): Finding[];
18
+ /**
19
+ * Who is reading (owner ruling (b)): an admin (`project.administer`) sees config and
20
+ * deployment findings; a located finding shows to whoever can open its location. While the
21
+ * role is still loading nobody is an admin and no view is visible, so a gated finding
22
+ * appears once the role is known, never to the wrong reader first.
23
+ */
24
+ export declare function useFindingAudience(project: Project): FindingAudience;
25
+ /**
26
+ * EC-616 → EC-690: runs every view's always-on findings hook (`useIssues`) — whether or not
27
+ * the view is open — and publishes what it returns. One component per view (each hook keeps
28
+ * its own hook order), each its OWN publisher with the source `view:<id>`: before EC-690 every
29
+ * probe published under the one source `view:`, so two such views overwrote each other and
30
+ * one unmount retracted both (critic point 1).
31
+ */
32
+ export declare function ViewIssuesProbes({ project }: {
33
+ project: Project;
34
+ }): import("react").JSX.Element;
35
+ /** The source a view's always-on findings publish under. */
36
+ export declare function viewIssuesSource(viewId: string): string;
@@ -0,0 +1,140 @@
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useMemo, useSyncExternalStore } from 'react';
3
+ import { redirectRecordKind, roleGrantsCapability } from '@elytracms/core/persistence';
4
+ import { getStudioRegistry } from '../../lib/registry/index.js';
5
+ import { projectConfigIssues, resolveDeploymentCollections, resolveDeploymentReuse, resolveDeploymentRoutes, } from '../../lib/workspace/index.js';
6
+ import { routeValidationIssues } from '../../lib/routes/route-issues.js';
7
+ import { contentSessionOrphans, serverContentSessionOrphans, subscribeContentSession } from '../../lib/data/index.js';
8
+ import { projectRolePolicyFor } from '../../lib/operations/project-runtime-config.js';
9
+ import { customizationItems, orphanedCollectionItems, routeIssueItems } from '../nav-issues.js';
10
+ import { useContributedNavIssues, useNavIssues } from '../nav-issues-registry.js';
11
+ import { resolveReuse } from '../builder/reuse.js';
12
+ import { useRecordList } from '../records/record-rail.js';
13
+ import { useFieldRenderers } from '../cms/field-renderer.js';
14
+ import { useProjectRole } from '../auth-context.js';
15
+ import { isNavViewVisible, useStudioNav } from '../app-views/sidebar.js';
16
+ import { useDeliveryHealthFindings } from '../delivery-health-banner.js';
17
+ import { canSeeFinding, orderFindings } from './notice-model.js';
18
+ /**
19
+ * EC-690 — every persistent finding of the studio shell, for the ONE issues tray.
20
+ *
21
+ * The ENGINE's own sources are computed here, in render — config (EC-479), customization
22
+ * (EC-236/467/478), the URL map (EC-469/590/628), orphaned collections (EC-606) and
23
+ * instant-publish health (EC-268) — so they are there on the first paint (no effect has to
24
+ * run before the badge can count them) and no contributor can overwrite them: they are not
25
+ * in the registry's namespace at all. Contributions (`useNavIssues`, a view's `useIssues`)
26
+ * come from the registry. Then the viewer's audience filter (owner ruling (b)) and the ONE
27
+ * order ({@link orderFindings}).
28
+ *
29
+ * The findings hooks moved here from `SectionNav`, which renders none of them any more (the
30
+ * rail foot is only its collapse toggle — owner ruling (a)).
31
+ */
32
+ export function useShellFindings(project) {
33
+ const nav = useStudioNav(project);
34
+ const { issues: rendererIssues } = useFieldRenderers();
35
+ const configIssues = useProjectConfigIssues();
36
+ const reuseIssues = useReuseIssues();
37
+ const routeIssues = useRouteIssues(project.id);
38
+ const orphanIssues = useOrphanedCollectionIssues();
39
+ const deliveryIssues = useDeliveryHealthFindings(project.id);
40
+ const contributed = useContributedNavIssues();
41
+ const audience = useFindingAudience(project);
42
+ return useMemo(() => {
43
+ const engine = [
44
+ // Config issues lead: "this project declares no collections" explains every other
45
+ // emptiness on the screen, so it is the first thing to read.
46
+ ...asFindings('config', customizationItems(configIssues)),
47
+ ...asFindings('customization', customizationItems([...nav.issues, ...rendererIssues, ...reuseIssues])),
48
+ ...asFindings('routes', routeIssues),
49
+ ...asFindings('orphans', orphanIssues),
50
+ ...asFindings('delivery', deliveryIssues),
51
+ ];
52
+ return orderFindings([...engine, ...contributed]).filter((finding) => canSeeFinding(finding, audience));
53
+ }, [configIssues, nav.issues, rendererIssues, reuseIssues, routeIssues, orphanIssues, deliveryIssues, contributed, audience]);
54
+ }
55
+ function asFindings(source, items) {
56
+ return items.map((item) => ({ id: `${source}:${item.key}`, source, ...item }));
57
+ }
58
+ /**
59
+ * Who is reading (owner ruling (b)): an admin (`project.administer`) sees config and
60
+ * deployment findings; a located finding shows to whoever can open its location. While the
61
+ * role is still loading nobody is an admin and no view is visible, so a gated finding
62
+ * appears once the role is known, never to the wrong reader first.
63
+ */
64
+ export function useFindingAudience(project) {
65
+ const nav = useStudioNav(project);
66
+ const role = useProjectRole(project.id);
67
+ return useMemo(() => {
68
+ const policy = projectRolePolicyFor(project.id);
69
+ const ctx = { project, role };
70
+ return {
71
+ isAdmin: role !== null && roleGrantsCapability(role, 'project.administer', policy),
72
+ canReadContent: role !== null && roleGrantsCapability(role, 'content.read', policy),
73
+ views: nav.registry.views.map((view) => ({
74
+ id: view.id,
75
+ ...(view.recordKind !== undefined ? { recordKind: view.recordKind } : {}),
76
+ visible: role !== null && isNavViewVisible(view, ctx),
77
+ })),
78
+ };
79
+ }, [nav, role, project]);
80
+ }
81
+ /**
82
+ * EC-616 → EC-690: runs every view's always-on findings hook (`useIssues`) — whether or not
83
+ * the view is open — and publishes what it returns. One component per view (each hook keeps
84
+ * its own hook order), each its OWN publisher with the source `view:<id>`: before EC-690 every
85
+ * probe published under the one source `view:`, so two such views overwrote each other and
86
+ * one unmount retracted both (critic point 1).
87
+ */
88
+ export function ViewIssuesProbes({ project }) {
89
+ const nav = useStudioNav(project);
90
+ const issueViews = useMemo(() => nav.registry.views.filter((view) => view.useIssues), [nav]);
91
+ return (_jsx(_Fragment, { children: issueViews.map((view) => (_jsx(ViewIssuesProbe, { view: view, project: project }, view.id))) }));
92
+ }
93
+ function ViewIssuesProbe({ view, project }) {
94
+ const issues = view.useIssues({ project, projectId: project.id });
95
+ useNavIssues(viewIssuesSource(view.id), issues);
96
+ return null;
97
+ }
98
+ /** The source a view's always-on findings publish under. */
99
+ export function viewIssuesSource(viewId) {
100
+ return `view:${viewId}`;
101
+ }
102
+ /**
103
+ * EC-479 — what the project has not declared but the studio cannot work without. Read from
104
+ * the config holder alone (static per studio boot), so it needs no CMS provider and is
105
+ * memoised once. The pure decision core is `projectConfigIssues`.
106
+ */
107
+ function useProjectConfigIssues() {
108
+ return useMemo(() => projectConfigIssues(resolveDeploymentCollections()), []);
109
+ }
110
+ /**
111
+ * EC-467 — the project's reusable-elements config issues: a declaration that cannot
112
+ * resolve, or an ambiguous inference the studio refuses to guess through. Static per studio
113
+ * boot (registry + config collections + studio config). The pure core is `resolveReuse`.
114
+ */
115
+ function useReuseIssues() {
116
+ return useMemo(() => resolveReuse(getStudioRegistry().registry.list(), resolveDeploymentCollections(), resolveDeploymentReuse())
117
+ .issues, []);
118
+ }
119
+ /**
120
+ * EC-469 — the project's URL-map issues: two routes that match the same URLs, a redirect
121
+ * chain that loops, a duplicate or unreadable redirect. Routes are CODE (config, static per
122
+ * boot); redirects are editorial DATA (the rail, live) — the read shares the rail's own query
123
+ * key, so a finding disappears the moment it is fixed; while that query is loading or failed
124
+ * only the code-side conflicts are reported (the rail surfaces its own load error).
125
+ */
126
+ function useRouteIssues(projectId) {
127
+ const redirects = useRecordList(redirectRecordKind, projectId).data;
128
+ return useMemo(() => routeIssueItems(routeValidationIssues({ routes: resolveDeploymentRoutes(), collections: resolveDeploymentCollections() }, redirects ?? [])), [redirects]);
129
+ }
130
+ /**
131
+ * EC-606 — stored documents whose collection the project does not declare. Read from the
132
+ * content session (subscribable, configured from the same hydration in both modes); the
133
+ * server snapshot is empty — there is no session during SSR. The pure mapping is
134
+ * {@link orphanedCollectionItems}.
135
+ */
136
+ function useOrphanedCollectionIssues() {
137
+ const orphans = useSyncExternalStore(subscribeContentSession, contentSessionOrphans, serverContentSessionOrphans);
138
+ return useMemo(() => orphanedCollectionItems(orphans), [orphans]);
139
+ }
140
+ //# sourceMappingURL=shell-findings.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shell-findings.js","sourceRoot":"","sources":["../../../src/components/notifications/shell-findings.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useMemo, useSyncExternalStore } from 'react'\nimport type { Project } from '@elytracms/core/studio-core'\nimport type { CustomizationIssue } from '@elytracms/core/studio-core'\nimport { redirectRecordKind, roleGrantsCapability } from '@elytracms/core/persistence'\nimport { getStudioRegistry } from '../../lib/registry'\nimport {\n projectConfigIssues,\n resolveDeploymentCollections,\n resolveDeploymentReuse,\n resolveDeploymentRoutes,\n} from '../../lib/workspace'\nimport { routeValidationIssues } from '../../lib/routes/route-issues'\nimport { contentSessionOrphans, serverContentSessionOrphans, subscribeContentSession } from '../../lib/data'\nimport { projectRolePolicyFor } from '../../lib/operations/project-runtime-config'\nimport { customizationItems, orphanedCollectionItems, routeIssueItems } from '../nav-issues'\nimport type { NavIssueItem } from '../nav-issues'\nimport { useContributedNavIssues, useNavIssues } from '../nav-issues-registry'\nimport { resolveReuse } from '../builder/reuse'\nimport { useRecordList } from '../records/record-rail'\nimport { useFieldRenderers } from '../cms/field-renderer'\nimport { useProjectRole } from '../auth-context'\nimport { isNavViewVisible, useStudioNav } from '../app-views/sidebar'\nimport type { AppViewModule } from '../app-views/module'\nimport { useDeliveryHealthFindings } from '../delivery-health-banner'\nimport type { DeliveryHealthFinding } from '../delivery-health-banner'\nimport { canSeeFinding, orderFindings } from './notice-model'\nimport type { EngineFindingSource, Finding, FindingAudience } from './notice-model'\n\n/**\n * EC-690 — every persistent finding of the studio shell, for the ONE issues tray.\n *\n * The ENGINE's own sources are computed here, in render — config (EC-479), customization\n * (EC-236/467/478), the URL map (EC-469/590/628), orphaned collections (EC-606) and\n * instant-publish health (EC-268) — so they are there on the first paint (no effect has to\n * run before the badge can count them) and no contributor can overwrite them: they are not\n * in the registry's namespace at all. Contributions (`useNavIssues`, a view's `useIssues`)\n * come from the registry. Then the viewer's audience filter (owner ruling (b)) and the ONE\n * order ({@link orderFindings}).\n *\n * The findings hooks moved here from `SectionNav`, which renders none of them any more (the\n * rail foot is only its collapse toggle — owner ruling (a)).\n */\nexport function useShellFindings(project: Project): Finding[] {\n const nav = useStudioNav(project)\n const { issues: rendererIssues } = useFieldRenderers()\n const configIssues = useProjectConfigIssues()\n const reuseIssues = useReuseIssues()\n const routeIssues = useRouteIssues(project.id)\n const orphanIssues = useOrphanedCollectionIssues()\n const deliveryIssues = useDeliveryHealthFindings(project.id)\n const contributed = useContributedNavIssues()\n const audience = useFindingAudience(project)\n\n return useMemo(() => {\n const engine: Finding[] = [\n // Config issues lead: \"this project declares no collections\" explains every other\n // emptiness on the screen, so it is the first thing to read.\n ...asFindings('config', customizationItems(configIssues)),\n ...asFindings('customization', customizationItems([...nav.issues, ...rendererIssues, ...reuseIssues])),\n ...asFindings('routes', routeIssues),\n ...asFindings('orphans', orphanIssues),\n ...asFindings('delivery', deliveryIssues),\n ]\n return orderFindings([...engine, ...contributed]).filter((finding) => canSeeFinding(finding, audience))\n }, [configIssues, nav.issues, rendererIssues, reuseIssues, routeIssues, orphanIssues, deliveryIssues, contributed, audience])\n}\n\nfunction asFindings(source: EngineFindingSource, items: readonly (NavIssueItem | DeliveryHealthFinding)[]): Finding[] {\n return items.map((item) => ({ id: `${source}:${item.key}`, source, ...item }))\n}\n\n/**\n * Who is reading (owner ruling (b)): an admin (`project.administer`) sees config and\n * deployment findings; a located finding shows to whoever can open its location. While the\n * role is still loading nobody is an admin and no view is visible, so a gated finding\n * appears once the role is known, never to the wrong reader first.\n */\nexport function useFindingAudience(project: Project): FindingAudience {\n const nav = useStudioNav(project)\n const role = useProjectRole(project.id)\n return useMemo(() => {\n const policy = projectRolePolicyFor(project.id)\n const ctx = { project, role }\n return {\n isAdmin: role !== null && roleGrantsCapability(role, 'project.administer', policy),\n canReadContent: role !== null && roleGrantsCapability(role, 'content.read', policy),\n views: nav.registry.views.map((view) => ({\n id: view.id,\n ...(view.recordKind !== undefined ? { recordKind: view.recordKind } : {}),\n visible: role !== null && isNavViewVisible(view, ctx),\n })),\n }\n }, [nav, role, project])\n}\n\n/**\n * EC-616 → EC-690: runs every view's always-on findings hook (`useIssues`) — whether or not\n * the view is open — and publishes what it returns. One component per view (each hook keeps\n * its own hook order), each its OWN publisher with the source `view:<id>`: before EC-690 every\n * probe published under the one source `view:`, so two such views overwrote each other and\n * one unmount retracted both (critic point 1).\n */\nexport function ViewIssuesProbes({ project }: { project: Project }) {\n const nav = useStudioNav(project)\n const issueViews = useMemo(() => nav.registry.views.filter((view) => view.useIssues), [nav])\n return (\n <>\n {issueViews.map((view) => (\n <ViewIssuesProbe key={view.id} view={view} project={project} />\n ))}\n </>\n )\n}\n\nfunction ViewIssuesProbe({ view, project }: { view: AppViewModule; project: Project }) {\n const issues = view.useIssues!({ project, projectId: project.id })\n useNavIssues(viewIssuesSource(view.id), issues)\n return null\n}\n\n/** The source a view's always-on findings publish under. */\nexport function viewIssuesSource(viewId: string): string {\n return `view:${viewId}`\n}\n\n/**\n * EC-479 — what the project has not declared but the studio cannot work without. Read from\n * the config holder alone (static per studio boot), so it needs no CMS provider and is\n * memoised once. The pure decision core is `projectConfigIssues`.\n */\nfunction useProjectConfigIssues(): CustomizationIssue[] {\n return useMemo(() => projectConfigIssues(resolveDeploymentCollections()), [])\n}\n\n/**\n * EC-467 — the project's reusable-elements config issues: a declaration that cannot\n * resolve, or an ambiguous inference the studio refuses to guess through. Static per studio\n * boot (registry + config collections + studio config). The pure core is `resolveReuse`.\n */\nfunction useReuseIssues(): CustomizationIssue[] {\n return useMemo(\n () =>\n resolveReuse(getStudioRegistry().registry.list(), resolveDeploymentCollections(), resolveDeploymentReuse())\n .issues,\n [],\n )\n}\n\n/**\n * EC-469 — the project's URL-map issues: two routes that match the same URLs, a redirect\n * chain that loops, a duplicate or unreadable redirect. Routes are CODE (config, static per\n * boot); redirects are editorial DATA (the rail, live) — the read shares the rail's own query\n * key, so a finding disappears the moment it is fixed; while that query is loading or failed\n * only the code-side conflicts are reported (the rail surfaces its own load error).\n */\nfunction useRouteIssues(projectId: string): NavIssueItem[] {\n const redirects = useRecordList(redirectRecordKind, projectId).data\n return useMemo(\n () =>\n routeIssueItems(\n routeValidationIssues(\n { routes: resolveDeploymentRoutes(), collections: resolveDeploymentCollections() },\n redirects ?? [],\n ),\n ),\n [redirects],\n )\n}\n\n/**\n * EC-606 — stored documents whose collection the project does not declare. Read from the\n * content session (subscribable, configured from the same hydration in both modes); the\n * server snapshot is empty — there is no session during SSR. The pure mapping is\n * {@link orphanedCollectionItems}.\n */\nfunction useOrphanedCollectionIssues(): NavIssueItem[] {\n const orphans = useSyncExternalStore(subscribeContentSession, contentSessionOrphans, serverContentSessionOrphans)\n return useMemo(() => orphanedCollectionItems(orphans), [orphans])\n}\n"]}
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useMemo, useState } from 'react';
2
2
  import { defaultViewState } from '../lib/cms-views/index.js';
3
3
  import { runBulkAction } from './cms/bulk-actions.js';
4
+ import { useOutcomeNotice } from './notifications/outcome-notice.js';
4
5
  /** The default normalizer for an {@link OkResult}: a failure maps to a
5
6
  * surfaced {@link OperationError}; a success maps to no error at all. */
6
7
  export function normalizeOkResult(result) {
@@ -122,6 +123,10 @@ export function useRailViewSource(adapter) {
122
123
  setViewStateRaw((prev) => (typeof update === 'function' ? update(prev) : update));
123
124
  }, []);
124
125
  const clearLastError = useCallback(() => setLastError(null), []);
126
+ // EC-690: a failed save/remove is ALSO an error notice of the shell's issues tray, for as long
127
+ // as this rail holds it — registered here, at its owner, once (the edit shell only renders it);
128
+ // dismissing it in the tray clears it here too.
129
+ useOutcomeNotice(lastError, clearLastError);
125
130
  const save = useCallback(createErrorMappedWriter(rawSave, normalizeSaveResult, setLastError), [
126
131
  rawSave,
127
132
  normalizeSaveResult,