@caretcms/core 0.2.0 → 0.3.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 (101) hide show
  1. package/README.md +81 -3
  2. package/dist/index.d.ts +13 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +37 -9
  5. package/dist/index.js.map +1 -1
  6. package/dist/loader.d.ts.map +1 -1
  7. package/dist/loader.js +27 -3
  8. package/dist/loader.js.map +1 -1
  9. package/dist/markdown/satteri.d.ts.map +1 -1
  10. package/dist/markdown/satteri.js +32 -8
  11. package/dist/markdown/satteri.js.map +1 -1
  12. package/dist/runtime/auth/session.d.ts +1 -0
  13. package/dist/runtime/auth/session.d.ts.map +1 -1
  14. package/dist/runtime/auth/session.js +16 -0
  15. package/dist/runtime/auth/session.js.map +1 -1
  16. package/dist/runtime/config.d.ts +3 -0
  17. package/dist/runtime/config.d.ts.map +1 -1
  18. package/dist/runtime/config.js +4 -1
  19. package/dist/runtime/config.js.map +1 -1
  20. package/dist/runtime/i18n.d.ts +86 -0
  21. package/dist/runtime/i18n.d.ts.map +1 -0
  22. package/dist/runtime/i18n.js +168 -0
  23. package/dist/runtime/i18n.js.map +1 -0
  24. package/dist/runtime/middleware.d.ts.map +1 -1
  25. package/dist/runtime/middleware.js +40 -2
  26. package/dist/runtime/middleware.js.map +1 -1
  27. package/dist/runtime/mutations/contracts.d.ts +3 -0
  28. package/dist/runtime/mutations/contracts.d.ts.map +1 -1
  29. package/dist/runtime/mutations/contracts.js +9 -0
  30. package/dist/runtime/mutations/contracts.js.map +1 -1
  31. package/dist/runtime/mutations/engine.d.ts.map +1 -1
  32. package/dist/runtime/mutations/engine.js +121 -10
  33. package/dist/runtime/mutations/engine.js.map +1 -1
  34. package/dist/runtime/providers.d.ts +2 -1
  35. package/dist/runtime/providers.d.ts.map +1 -1
  36. package/dist/runtime/providers.js +7 -1
  37. package/dist/runtime/providers.js.map +1 -1
  38. package/dist/runtime/publish.d.ts.map +1 -1
  39. package/dist/runtime/publish.js +5 -0
  40. package/dist/runtime/publish.js.map +1 -1
  41. package/dist/runtime/request-context.d.ts +5 -1
  42. package/dist/runtime/request-context.d.ts.map +1 -1
  43. package/dist/runtime/request-context.js +7 -1
  44. package/dist/runtime/request-context.js.map +1 -1
  45. package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
  46. package/dist/runtime/routes/admin-entry.js +114 -18
  47. package/dist/runtime/routes/admin-entry.js.map +1 -1
  48. package/dist/runtime/routes/admin.d.ts.map +1 -1
  49. package/dist/runtime/routes/admin.js +28 -14
  50. package/dist/runtime/routes/admin.js.map +1 -1
  51. package/dist/runtime/routes/auth-login.d.ts.map +1 -1
  52. package/dist/runtime/routes/auth-login.js +4 -0
  53. package/dist/runtime/routes/auth-login.js.map +1 -1
  54. package/dist/runtime/routes/auth-logout.d.ts.map +1 -1
  55. package/dist/runtime/routes/auth-logout.js +7 -2
  56. package/dist/runtime/routes/auth-logout.js.map +1 -1
  57. package/dist/runtime/routes/auth-session.d.ts.map +1 -1
  58. package/dist/runtime/routes/auth-session.js +4 -2
  59. package/dist/runtime/routes/auth-session.js.map +1 -1
  60. package/dist/runtime/routes/collection-list.d.ts.map +1 -1
  61. package/dist/runtime/routes/collection-list.js +64 -41
  62. package/dist/runtime/routes/collection-list.js.map +1 -1
  63. package/dist/runtime/routes/entries.d.ts.map +1 -1
  64. package/dist/runtime/routes/entries.js +15 -6
  65. package/dist/runtime/routes/entries.js.map +1 -1
  66. package/dist/runtime/routes/history.d.ts.map +1 -1
  67. package/dist/runtime/routes/history.js +4 -1
  68. package/dist/runtime/routes/history.js.map +1 -1
  69. package/dist/runtime/routes/schema.d.ts.map +1 -1
  70. package/dist/runtime/routes/schema.js +7 -2
  71. package/dist/runtime/routes/schema.js.map +1 -1
  72. package/dist/runtime/routes/studio-home.d.ts.map +1 -1
  73. package/dist/runtime/routes/studio-home.js +38 -19
  74. package/dist/runtime/routes/studio-home.js.map +1 -1
  75. package/dist/runtime/schema-registry.d.ts +8 -1
  76. package/dist/runtime/schema-registry.d.ts.map +1 -1
  77. package/dist/runtime/schema-registry.js +47 -3
  78. package/dist/runtime/schema-registry.js.map +1 -1
  79. package/dist/runtime/themes/preset-tokens.js +4 -4
  80. package/dist/runtime/themes/preset-tokens.js.map +1 -1
  81. package/dist/runtime/views/studio-layout.d.ts +3 -0
  82. package/dist/runtime/views/studio-layout.d.ts.map +1 -1
  83. package/dist/runtime/views/studio-layout.js +36 -13
  84. package/dist/runtime/views/studio-layout.js.map +1 -1
  85. package/dist/schema-utils.d.ts +7 -0
  86. package/dist/schema-utils.d.ts.map +1 -1
  87. package/dist/schema-utils.js +143 -0
  88. package/dist/schema-utils.js.map +1 -1
  89. package/dist/types.d.ts +43 -1
  90. package/dist/types.d.ts.map +1 -1
  91. package/package.json +1 -1
  92. package/static/cms/admin-entry.css +135 -13
  93. package/static/cms/admin-entry.js +506 -117
  94. package/static/cms/editor/image-edit.js +1 -0
  95. package/static/cms/editor/panel.js +19 -1
  96. package/static/cms/editor/sync.js +277 -56
  97. package/static/cms/editor/tokens-and-text.css +9 -0
  98. package/static/cms/editor.css +1 -1
  99. package/static/cms/editor.js +1 -1
  100. package/static/cms/studio-tokens.css +4 -4
  101. package/static/cms/studio.css +24 -0
@@ -72,6 +72,7 @@ export function mountImageEditors({
72
72
  const input = document.createElement('input');
73
73
  input.type = 'file';
74
74
  input.accept = 'image/jpeg,image/png,image/webp,image/avif';
75
+ input.setAttribute('aria-label', 'Upload replacement image');
75
76
  input.style.display = 'none';
76
77
  wrapper.appendChild(input);
77
78
 
@@ -24,10 +24,28 @@ export function mountStudioPanel({ studioButton }) {
24
24
  panel.appendChild(loading);
25
25
 
26
26
  const iframe = document.createElement('iframe');
27
- iframe.src = STUDIO_PATH;
27
+ let rememberedPath = '';
28
+ try {
29
+ const storedPath = sessionStorage.getItem('cms-panel-path') || '';
30
+ if (storedPath === STUDIO_PATH || storedPath.startsWith(`${STUDIO_PATH}/`)) {
31
+ rememberedPath = storedPath;
32
+ }
33
+ } catch {
34
+ // Session storage is optional; the Studio root remains a safe fallback.
35
+ }
36
+ iframe.src = rememberedPath || STUDIO_PATH;
28
37
  iframe.title = 'Content Studio';
29
38
  iframe.addEventListener('load', () => {
30
39
  loading.setAttribute('hidden', 'hidden');
40
+ try {
41
+ const currentPath = `${iframe.contentWindow.location.pathname}${iframe.contentWindow.location.search}`;
42
+ if (currentPath === STUDIO_PATH || currentPath.startsWith(`${STUDIO_PATH}/`)) {
43
+ sessionStorage.setItem('cms-panel-path', currentPath);
44
+ }
45
+ } catch {
46
+ // The configured Studio may be cross-origin, in which case its route is
47
+ // intentionally unavailable to the parent page.
48
+ }
31
49
  });
32
50
  panel.appendChild(iframe);
33
51
  document.body.appendChild(panel);
@@ -1,72 +1,293 @@
1
1
  import { sanitizeHtml } from './sanitize.js';
2
+ import { parseCaretAttr, resolveBinding } from './helpers.js';
3
+ import { STUDIO_PATH } from './config.js';
4
+
5
+ const SYNC_CHANNEL = 'caretcms:content';
2
6
 
3
7
  export function mountStudioSync({ studioIframe, state, showToast, clientLinkify }) {
4
- if (!studioIframe) return;
8
+ let refreshTimer = 0;
9
+ let selectedInlineEl = null;
10
+ let linkedSelectionTimer = 0;
11
+ let pendingStudioSelection = null;
12
+ let activeStudioSelection = null;
5
13
 
6
- window.addEventListener('message', (e) => {
7
- // Only accept messages from our iframe
8
- if (e.source !== studioIframe.contentWindow) return;
9
- const msg = e.data;
10
- if (!msg || typeof msg.type !== 'string') return;
14
+ function matchingElements(collection, id, field) {
15
+ const fullKey = `${collection}::${id}::${field}`;
16
+ const matches = Array.from(document.querySelectorAll(`[data-caret="${CSS.escape(fullKey)}"]`));
17
+ document.querySelectorAll(`[data-caret="${CSS.escape(field)}"]`).forEach((el) => {
18
+ const scope = el.closest('[data-caret-scope]')?.getAttribute('data-caret-scope');
19
+ if (scope === `${collection}::${id}` && !matches.includes(el)) matches.push(el);
20
+ });
21
+ return matches;
22
+ }
11
23
 
12
- if (msg.type === 'cms:saved') {
13
- const { collection, id, data } = msg;
14
- if (!data || typeof data !== 'object') return;
15
-
16
- let updated = 0;
17
-
18
- function walkData(obj, prefix) {
19
- for (const [key, value] of Object.entries(obj)) {
20
- const path = prefix ? `${prefix}.${key}` : key;
21
- const selector = `${collection}::${id}::${path}`;
22
-
23
- if (typeof value === 'string') {
24
- // Text and image fields unified under [data-caret]
25
- const els = document.querySelectorAll(`[data-caret="${selector}"]`);
26
- els.forEach((el) => {
27
- // Skip elements being actively edited to prevent overwriting unsaved work
28
- if (state.dirtyEls.has(el) || el === document.activeElement) return;
29
-
30
- if (el instanceof HTMLImageElement) {
31
- // Image element
32
- if (el.src !== value && el.getAttribute('src') !== value) {
33
- el.src = value;
34
- updated++;
35
- }
36
- } else if (el.hasAttribute('data-caret-rich')) {
37
- const sanitized = sanitizeHtml(value);
38
- if (el.innerHTML !== sanitized) {
39
- el.innerHTML = sanitized;
40
- updated++;
41
- }
42
- } else if (el.hasAttribute('data-caret-raw')) {
43
- if (el.getAttribute('data-caret-raw') !== value) {
44
- el.setAttribute('data-caret-raw', value);
45
- el.innerHTML = clientLinkify(value);
46
- updated++;
47
- }
48
- } else if (el.textContent !== value) {
49
- el.textContent = value;
24
+ function selectInlineField(collection, id, field, shouldScroll) {
25
+ if (typeof collection !== 'string' || typeof id !== 'string' || typeof field !== 'string') return;
26
+ const target = matchingElements(collection, id, field)[0];
27
+ if (!target) return;
28
+
29
+ window.clearTimeout(linkedSelectionTimer);
30
+ if (selectedInlineEl && selectedInlineEl !== target) {
31
+ selectedInlineEl.classList.remove('cms-linked-selection');
32
+ }
33
+ selectedInlineEl = target;
34
+ target.classList.add('cms-linked-selection');
35
+ if (shouldScroll) {
36
+ target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
37
+ }
38
+
39
+ // Blue confirms that the paired field was located. Green remains the
40
+ // distinct confirmation that an edit was actually saved.
41
+ linkedSelectionTimer = window.setTimeout(() => {
42
+ if (selectedInlineEl !== target) return;
43
+ target.classList.remove('cms-linked-selection');
44
+ selectedInlineEl = null;
45
+ }, 1500);
46
+ }
47
+
48
+ function publishSelection(message) {
49
+ if (!('BroadcastChannel' in window)) return;
50
+ try {
51
+ const channel = new BroadcastChannel(SYNC_CHANNEL);
52
+ channel.postMessage(message);
53
+ window.setTimeout(() => channel.close(), 0);
54
+ } catch {
55
+ // Cross-tab selection linking is a progressive enhancement.
56
+ }
57
+ }
58
+
59
+ function studioEntryPath(message) {
60
+ return `${STUDIO_PATH}/${encodeURIComponent(message.collection)}/${encodeURIComponent(message.id)}`;
61
+ }
62
+
63
+ function postEmbeddedSelection(message) {
64
+ if (!studioIframe?.contentWindow) return;
65
+ try {
66
+ studioIframe.contentWindow.postMessage({ ...message, embedded: true }, window.location.origin);
67
+ } catch {
68
+ // Embedded field linking is a progressive enhancement.
69
+ }
70
+ }
71
+
72
+ function deliverEmbeddedSelection(message) {
73
+ if (!studioIframe?.contentWindow) return;
74
+ const targetPath = studioEntryPath(message);
75
+
76
+ // A normal mouse interaction can emit pointerdown, focusin, and click.
77
+ // Keep the latest field without restarting the same iframe navigation for
78
+ // each event in that sequence.
79
+ if (pendingStudioSelection && studioIframe.getAttribute('src') === targetPath) {
80
+ pendingStudioSelection = message;
81
+ return;
82
+ }
83
+
84
+ try {
85
+ if (studioIframe.contentWindow.location.pathname !== targetPath) {
86
+ pendingStudioSelection = message;
87
+ studioIframe.src = targetPath;
88
+ try {
89
+ sessionStorage.setItem('cms-panel-path', targetPath);
90
+ } catch {
91
+ // Remembering the route is optional; the current navigation is not.
92
+ }
93
+ return;
94
+ }
95
+ } catch {
96
+ // Cross-origin Studio panels cannot be inspected or navigated safely;
97
+ // preserve the existing postMessage-only behavior for those setups.
98
+ }
99
+
100
+ postEmbeddedSelection(message);
101
+ }
102
+
103
+ studioIframe?.addEventListener('load', () => {
104
+ if (!pendingStudioSelection) return;
105
+ const message = pendingStudioSelection;
106
+ pendingStudioSelection = null;
107
+ window.requestAnimationFrame(() => postEmbeddedSelection(message));
108
+ });
109
+
110
+ function selectStudioField(el) {
111
+ const resolved = resolveBinding(el, parseCaretAttr(el.getAttribute('data-caret') || ''));
112
+ if (!resolved) return;
113
+
114
+ selectInlineField(resolved.collection, resolved.id, resolved.field, false);
115
+ const message = {
116
+ type: 'cms:field-selected',
117
+ collection: resolved.collection,
118
+ id: resolved.id,
119
+ field: resolved.field,
120
+ source: 'inline',
121
+ };
122
+
123
+ activeStudioSelection = message;
124
+ deliverEmbeddedSelection(message);
125
+ publishSelection(message);
126
+ }
127
+
128
+ // Pointerdown covers image-like bindings; focusin covers keyboard navigation
129
+ // and text editing. Click catches non-focusable bound elements.
130
+ document.addEventListener('pointerdown', (event) => {
131
+ const el = event.target instanceof Element ? event.target.closest('[data-caret]') : null;
132
+ if (el) selectStudioField(el);
133
+ });
134
+ document.addEventListener('click', (event) => {
135
+ const el = event.target instanceof Element ? event.target.closest('[data-caret]') : null;
136
+ if (el) selectStudioField(el);
137
+ });
138
+ document.addEventListener('focusin', (event) => {
139
+ const el = event.target instanceof Element ? event.target.closest('[data-caret]') : null;
140
+ if (el) selectStudioField(el);
141
+ });
142
+
143
+ function rememberStudioPath(msg) {
144
+ if (typeof msg.studioPath !== 'string') return;
145
+ try {
146
+ sessionStorage.setItem('cms-panel-path', msg.studioPath);
147
+ } catch {
148
+ // Remembering the current Studio route is a progressive enhancement.
149
+ }
150
+ }
151
+
152
+ function scheduleEmbeddedRefresh(msg) {
153
+ rememberStudioPath(msg);
154
+
155
+ if (state.dirtyEls.size > 0) {
156
+ showToast('Saved in Studio — finish your inline edit before refreshing', 'success');
157
+ return;
158
+ }
159
+
160
+ showToast('Saved — refreshing visual preview…', 'success');
161
+ window.clearTimeout(refreshTimer);
162
+ refreshTimer = window.setTimeout(() => window.location.reload(), 280);
163
+ }
164
+
165
+ function applySavedMessage(msg) {
166
+ const { collection, id, data } = msg;
167
+ if (!data || typeof data !== 'object') return 0;
168
+
169
+ let updated = 0;
170
+
171
+ function boundElements(selector, path) {
172
+ const matches = Array.from(document.querySelectorAll(`[data-caret="${CSS.escape(selector)}"]`));
173
+ document.querySelectorAll(`[data-caret="${CSS.escape(path)}"]`).forEach((el) => {
174
+ const scope = el.closest('[data-caret-scope]')?.getAttribute('data-caret-scope');
175
+ if (scope === `${collection}::${id}` && !matches.includes(el)) matches.push(el);
176
+ });
177
+ return matches;
178
+ }
179
+
180
+ function walkData(obj, prefix) {
181
+ for (const [key, value] of Object.entries(obj)) {
182
+ const path = prefix ? `${prefix}.${key}` : key;
183
+ const selector = `${collection}::${id}::${path}`;
184
+
185
+ if (typeof value === 'string') {
186
+ const els = boundElements(selector, path);
187
+ els.forEach((el) => {
188
+ // Skip elements being actively edited to prevent overwriting unsaved work
189
+ if (state.dirtyEls.has(el) || el === document.activeElement) return;
190
+
191
+ if (el instanceof HTMLImageElement) {
192
+ if (el.src !== value && el.getAttribute('src') !== value) {
193
+ el.src = value;
194
+ updated++;
195
+ }
196
+ } else if (el.hasAttribute('data-caret-rich')) {
197
+ const sanitized = sanitizeHtml(value);
198
+ if (el.innerHTML !== sanitized) {
199
+ el.innerHTML = sanitized;
200
+ updated++;
201
+ }
202
+ } else if (el.hasAttribute('data-caret-raw')) {
203
+ if (el.getAttribute('data-caret-raw') !== value) {
204
+ el.setAttribute('data-caret-raw', value);
205
+ el.innerHTML = clientLinkify(value);
50
206
  updated++;
51
207
  }
52
- });
53
- } else if (typeof value === 'object' && value !== null) {
54
- walkData(value, path);
55
- }
208
+ } else if (el.textContent !== value) {
209
+ el.textContent = value;
210
+ updated++;
211
+ }
212
+ });
213
+ } else if (typeof value === 'object' && value !== null) {
214
+ walkData(value, path);
56
215
  }
57
216
  }
217
+ }
58
218
 
59
- walkData(data, '');
219
+ walkData(data, '');
220
+ return updated;
221
+ }
60
222
 
61
- if (updated > 0) {
62
- showToast(`Updated ${updated} element${updated > 1 ? 's' : ''} on page`, 'success');
63
- } else {
64
- showToast('Saved refresh to see changes', 'success');
65
- }
223
+ window.addEventListener('message', (e) => {
224
+ // Only accept messages from our iframe
225
+ if (!studioIframe || e.source !== studioIframe.contentWindow) return;
226
+ const msg = e.data;
227
+ if (!msg || typeof msg.type !== 'string') return;
228
+
229
+ if (
230
+ msg.type === 'cms:entry-ready'
231
+ && activeStudioSelection
232
+ && msg.collection === activeStudioSelection.collection
233
+ && msg.id === activeStudioSelection.id
234
+ ) {
235
+ postEmbeddedSelection(activeStudioSelection);
236
+ return;
237
+ }
238
+
239
+ if (msg.type === 'cms:preview') {
240
+ applySavedMessage(msg);
241
+ return;
66
242
  }
67
243
 
68
- if (msg.type === 'cms:created') {
69
- showToast('New entry created — refresh to see changes', 'success');
244
+ if (msg.type === 'cms:field-selected') {
245
+ selectInlineField(msg.collection, msg.id, msg.field, true);
246
+ return;
247
+ }
248
+
249
+ if (msg.type === 'cms:saved') {
250
+ applySavedMessage(msg);
251
+ scheduleEmbeddedRefresh(msg);
252
+ }
253
+
254
+ if (msg.type === 'cms:created' || msg.type === 'cms:deleted') {
255
+ scheduleEmbeddedRefresh(msg);
70
256
  }
71
257
  });
258
+
259
+ // Standalone Studio pages live in a separate tab, so postMessage cannot
260
+ // reach the site preview. BroadcastChannel provides a same-origin bridge.
261
+ // Reload only after a confirmed mutation. The server remains the source of
262
+ // truth for lists, routing, and derived UI; sites can make the refresh feel
263
+ // continuous with their own cross-document view transitions.
264
+ if ('BroadcastChannel' in window) {
265
+ try {
266
+ const channel = new BroadcastChannel(SYNC_CHANNEL);
267
+ channel.addEventListener('message', (event) => {
268
+ const msg = event.data;
269
+ if (!msg || typeof msg.type !== 'string' || msg.embedded === true) return;
270
+
271
+ if (msg.type === 'cms:field-selected') {
272
+ selectInlineField(msg.collection, msg.id, msg.field, true);
273
+ return;
274
+ }
275
+ if (!['cms:saved', 'cms:created', 'cms:deleted'].includes(msg.type)) return;
276
+
277
+ if (state.dirtyEls.size > 0) {
278
+ showToast('Saved in Studio — finish your inline edit before refreshing', 'success');
279
+ return;
280
+ }
281
+
282
+ if (msg.type === 'cms:saved') applySavedMessage(msg);
283
+ showToast('Saved — refreshing preview…', 'success');
284
+ window.clearTimeout(refreshTimer);
285
+ refreshTimer = window.setTimeout(() => window.location.reload(), 280);
286
+ });
287
+ window.addEventListener('pagehide', () => channel.close(), { once: true });
288
+ } catch {
289
+ // Cross-tab preview sync is progressive enhancement; iframe sync above
290
+ // remains available in browsers without BroadcastChannel.
291
+ }
292
+ }
72
293
  }
@@ -72,6 +72,15 @@
72
72
  box-shadow: 0 0 0 4px var(--cms-blue-dim);
73
73
  }
74
74
 
75
+ /* Briefly confirms that the paired Studio field was located. Save feedback is
76
+ green, so linked selection keeps the editor's blue navigation language. */
77
+ .cms-linked-selection {
78
+ outline: 2px solid var(--cms-blue) !important;
79
+ outline-offset: 4px;
80
+ box-shadow: 0 0 0 4px var(--cms-blue-dim);
81
+ transition: outline-color 0.35s ease, box-shadow 0.35s ease;
82
+ }
83
+
75
84
  /* ---------------------------------------------------------------------------
76
85
  Unpublished body draft
77
86
  The server rewrite tags blocks whose prose comes from an unpublished draft
@@ -1,7 +1,7 @@
1
1
  /* ========================================================================
2
2
  CMS Editor Styles — only loaded when isEditor = true
3
3
  ======================================================================== */
4
- @import "./editor/tokens-and-text.css";
4
+ @import "./editor/tokens-and-text.css?v=studio-upstream-20260819";
5
5
  @import "./editor/image.css";
6
6
  @import "./editor/section-controls.css";
7
7
  @import "./editor/toolbar.css";
@@ -18,7 +18,7 @@ import { createToast } from './editor/toast.js';
18
18
  import { mountEditorGuards } from './editor/guards.js';
19
19
  import { mountToolbar, normalizePreviewForDelivery } from './editor/toolbar.js';
20
20
  import { mountStudioPanel } from './editor/panel.js';
21
- import { mountStudioSync } from './editor/sync.js';
21
+ import { mountStudioSync } from './editor/sync.js?v=studio-upstream-20260819';
22
22
  import { mountTextEditors } from './editor/text-edit.js';
23
23
  import { mountImageEditors } from './editor/image-edit.js';
24
24
  import { mountSectionControls } from './editor/section-controls.js';
@@ -30,15 +30,15 @@
30
30
  --color-theme-border: rgba(255, 255, 255, 0.10);
31
31
 
32
32
  /* ─── Semantic: Accent ────────────────────────────────────────────── */
33
- --color-theme-accent: #3b82f6;
33
+ --color-theme-accent: #60a5fa;
34
34
  --color-theme-accent-soft: rgba(59, 130, 246, 0.12);
35
35
  --color-theme-accent-strong: rgba(59, 130, 246, 0.24);
36
- --color-theme-on-accent: #ffffff;
36
+ --color-theme-on-accent: #07111f;
37
37
 
38
38
  /* ─── Semantic: Text ──────────────────────────────────────────────── */
39
39
  --color-theme-text: rgba(255, 255, 255, 0.92);
40
- --color-theme-text-muted: rgba(255, 255, 255, 0.55);
41
- --color-theme-text-dim: rgba(255, 255, 255, 0.32);
40
+ --color-theme-text-muted: #b6bac3;
41
+ --color-theme-text-dim: #9298a3;
42
42
 
43
43
  /* ─── Semantic: State ─────────────────────────────────────────────── */
44
44
  --color-theme-success: #22c55e;
@@ -29,6 +29,18 @@ html, body {
29
29
  box-sizing: border-box;
30
30
  }
31
31
 
32
+ .studio-sr-only {
33
+ position: absolute;
34
+ width: 1px;
35
+ height: 1px;
36
+ padding: 0;
37
+ margin: -1px;
38
+ overflow: hidden;
39
+ clip: rect(0, 0, 0, 0);
40
+ white-space: nowrap;
41
+ border: 0;
42
+ }
43
+
32
44
  a {
33
45
  color: inherit;
34
46
  text-decoration: none;
@@ -47,9 +59,11 @@ a {
47
59
  /* ─── Layout: Shell + Header ────────────────────────────────────────── */
48
60
 
49
61
  .studio-shell {
62
+ height: 100dvh;
50
63
  min-height: 100vh;
51
64
  display: flex;
52
65
  flex-direction: column;
66
+ overflow: hidden;
53
67
  }
54
68
 
55
69
  .studio-header {
@@ -87,6 +101,15 @@ a {
87
101
  flex-shrink: 0;
88
102
  }
89
103
 
104
+ .studio-editor-identity {
105
+ max-width: 14rem;
106
+ overflow: hidden;
107
+ text-overflow: ellipsis;
108
+ white-space: nowrap;
109
+ color: var(--studio-text-muted);
110
+ font-size: 0.72rem;
111
+ }
112
+
90
113
  .studio-brand {
91
114
  display: flex;
92
115
  align-items: center;
@@ -113,6 +136,7 @@ a {
113
136
 
114
137
  .studio-main {
115
138
  flex: 1;
139
+ min-height: 0;
116
140
  padding: var(--space-theme-xl) var(--space-theme-lg);
117
141
  overflow-y: auto;
118
142
  }