@flyingrobots/bijou-tui 4.2.0 → 4.4.1

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 (66) hide show
  1. package/README.md +52 -617
  2. package/dist/app-frame-actions.d.ts.map +1 -1
  3. package/dist/app-frame-actions.js +4 -1
  4. package/dist/app-frame-actions.js.map +1 -1
  5. package/dist/app-frame-i18n.d.ts.map +1 -1
  6. package/dist/app-frame-i18n.js +4 -0
  7. package/dist/app-frame-i18n.js.map +1 -1
  8. package/dist/app-frame-render.d.ts +11 -9
  9. package/dist/app-frame-render.d.ts.map +1 -1
  10. package/dist/app-frame-render.js +102 -39
  11. package/dist/app-frame-render.js.map +1 -1
  12. package/dist/app-frame-types.d.ts +3 -0
  13. package/dist/app-frame-types.d.ts.map +1 -1
  14. package/dist/app-frame-types.js.map +1 -1
  15. package/dist/app-frame.d.ts +27 -1
  16. package/dist/app-frame.d.ts.map +1 -1
  17. package/dist/app-frame.js +244 -63
  18. package/dist/app-frame.js.map +1 -1
  19. package/dist/canvas.d.ts.map +1 -1
  20. package/dist/canvas.js +25 -4
  21. package/dist/canvas.js.map +1 -1
  22. package/dist/command-palette.d.ts +5 -3
  23. package/dist/command-palette.d.ts.map +1 -1
  24. package/dist/command-palette.js +5 -3
  25. package/dist/command-palette.js.map +1 -1
  26. package/dist/css/text-style.d.ts +6 -0
  27. package/dist/css/text-style.d.ts.map +1 -1
  28. package/dist/css/text-style.js +59 -15
  29. package/dist/css/text-style.js.map +1 -1
  30. package/dist/flex.d.ts.map +1 -1
  31. package/dist/flex.js +28 -4
  32. package/dist/flex.js.map +1 -1
  33. package/dist/focus-area.d.ts.map +1 -1
  34. package/dist/focus-area.js +18 -3
  35. package/dist/focus-area.js.map +1 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/notification.d.ts.map +1 -1
  40. package/dist/notification.js +56 -16
  41. package/dist/notification.js.map +1 -1
  42. package/dist/overlay.d.ts.map +1 -1
  43. package/dist/overlay.js +103 -21
  44. package/dist/overlay.js.map +1 -1
  45. package/dist/pipeline/middleware/grayscale.d.ts +3 -0
  46. package/dist/pipeline/middleware/grayscale.d.ts.map +1 -1
  47. package/dist/pipeline/middleware/grayscale.js +63 -10
  48. package/dist/pipeline/middleware/grayscale.js.map +1 -1
  49. package/dist/pipeline/pipeline.d.ts +7 -0
  50. package/dist/pipeline/pipeline.d.ts.map +1 -1
  51. package/dist/pipeline/pipeline.js.map +1 -1
  52. package/dist/runtime.d.ts.map +1 -1
  53. package/dist/runtime.js +33 -1
  54. package/dist/runtime.js.map +1 -1
  55. package/dist/screen.d.ts +5 -1
  56. package/dist/screen.d.ts.map +1 -1
  57. package/dist/screen.js +6 -2
  58. package/dist/screen.js.map +1 -1
  59. package/dist/shell-quit.d.ts +1 -1
  60. package/dist/shell-quit.d.ts.map +1 -1
  61. package/dist/shell-quit.js +14 -3
  62. package/dist/shell-quit.js.map +1 -1
  63. package/dist/transition-shaders.d.ts.map +1 -1
  64. package/dist/transition-shaders.js +6 -1
  65. package/dist/transition-shaders.js.map +1 -1
  66. package/package.json +3 -3
package/dist/app-frame.js CHANGED
@@ -4,14 +4,14 @@
4
4
  * Provides tabs, pane focus/scroll isolation, shell key handling, help,
5
5
  * panel-scoped overlay context, and optional frame-level command palette.
6
6
  */
7
- import { createSurface, preparePreferenceSections, preferenceListSurface, resolvePreferenceRowLayout, resolveClock, resolveSafeCtx, } from '@flyingrobots/bijou';
7
+ import { cloneContextWithResolvedTheme, createResolved, createSurface, setDefaultContext, preparePreferenceSections, preferenceListSurface, resolvePreferenceRowLayout, resolveClock, resolveSafeCtx, } from '@flyingrobots/bijou';
8
8
  import { helpViewSurface } from './help.js';
9
9
  import { createKeyMap } from './keybindings.js';
10
10
  import { isKeyMsg, isMouseMsg, isResizeMsg } from './types.js';
11
11
  import { quit } from './commands.js';
12
12
  import { compositeSurfaceInto, drawer, modal } from './overlay.js';
13
13
  import { isShellQuitConfirmAccept, isShellQuitConfirmDismiss, isShellQuitRequest, renderShellQuitOverlay, shouldUseShellQuitConfirm, } from './shell-quit.js';
14
- import { commandPalette, commandPaletteKeyMap, } from './command-palette.js';
14
+ import { commandPaletteSurface, commandPaletteKeyMap, } from './command-palette.js';
15
15
  import { createPagerStateForSurface, pagerSurface, } from './pager.js';
16
16
  import { restoreLayoutState } from './layout-preset.js';
17
17
  import { countNotificationHistory, createNotificationState, dismissNotification, hitTestNotificationStack, notificationsNeedTick, pushNotification, renderNotificationHistorySurface, renderNotificationReviewEntrySurface, renderNotificationStack, tickNotifications, trimNotificationsToViewport, } from './notification.js';
@@ -22,7 +22,7 @@ import { createFrameKeyMap, frameBodyRect, mergeBindingSources, } from './app-fr
22
22
  import { activeFrameLayer, describeFrameLayerStack, describeFrameRuntimeViewStack, } from './app-frame-layers.js';
23
23
  import { applyRuntimeCommandBuffer, bufferRuntimeRouteResult, createRuntimeBuffers, createRuntimeRetainedLayouts, retainRuntimeLayout, routeRuntimeInput, } from './runtime-engine.js';
24
24
  import { frameEndAnchor, frameMessage, frameNotificationCue, frameNotificationFilterLabel, frameStartAnchor, } from './app-frame-i18n.js';
25
- import { resolveHeaderLine, renderHelpLine, renderPageContent, renderPageContentInto, renderMaximizedPane, renderMaximizedPaneInto, renderTransition, } from './app-frame-render.js';
25
+ import { resolveHeaderLine, renderHelpLine, renderPageContent, renderPageContentInto, renderMaximizedPane, renderMaximizedPaneInto, renderTransition, createFramePaneScratchPool, } from './app-frame-render.js';
26
26
  import { applyFrameAction, scrollFocusedPane, switchTab, syncPageFrameState, } from './app-frame-actions.js';
27
27
  import { handlePaletteKey, openCommandPalette, openSearchPalette, } from './app-frame-palette.js';
28
28
  export { activeFrameLayer, describeFrameLayerStack, describeFrameRuntimeViewStack, underlyingFrameLayer, } from './app-frame-layers.js';
@@ -134,6 +134,81 @@ function createFrameNotificationTickCmd() {
134
134
  });
135
135
  };
136
136
  }
137
+ function cloneShellThemeContext(ctx, resolvedTheme) {
138
+ return cloneContextWithResolvedTheme(ctx, resolvedTheme);
139
+ }
140
+ function resolveShellThemeOptionsText(shellThemes, i18n) {
141
+ const labels = shellThemes.map((theme) => theme.label);
142
+ if (labels.length === 0)
143
+ return '';
144
+ if (i18n == null)
145
+ return labels.join(', ');
146
+ return i18n.formatList(labels, i18n.locale);
147
+ }
148
+ function resolveCurrentShellTheme(shellThemes, activeShellThemeId) {
149
+ return shellThemes.find((theme) => theme.id === activeShellThemeId) ?? shellThemes[0];
150
+ }
151
+ function resolveNextShellTheme(shellThemes, activeShellThemeId) {
152
+ if (shellThemes.length === 0)
153
+ return undefined;
154
+ const currentIndex = Math.max(0, shellThemes.findIndex((theme) => theme.id === activeShellThemeId));
155
+ return shellThemes[(currentIndex + 1) % shellThemes.length];
156
+ }
157
+ function resolveShellThemeForContext(shellThemes, ctx) {
158
+ if (ctx == null)
159
+ return undefined;
160
+ return shellThemes.find((theme) => theme.resolvedTheme.theme === ctx.theme.theme);
161
+ }
162
+ function mergeShellThemeSettings(settings, shellThemes, activeShellThemeId, i18n) {
163
+ if (shellThemes.length < 2)
164
+ return settings;
165
+ const currentTheme = resolveCurrentShellTheme(shellThemes, activeShellThemeId);
166
+ const nextTheme = resolveNextShellTheme(shellThemes, activeShellThemeId);
167
+ if (currentTheme == null || nextTheme == null)
168
+ return settings;
169
+ const row = {
170
+ id: FRAME_SHELL_THEME_ROW_ID,
171
+ label: frameMessage(i18n, 'settings.shellTheme.label', 'Shell theme'),
172
+ description: currentTheme.description ?? frameMessage(i18n, 'settings.shellTheme.description', 'Current theme: {theme}. Options: {options}.', {
173
+ theme: currentTheme.label,
174
+ options: resolveShellThemeOptionsText(shellThemes, i18n),
175
+ }),
176
+ valueLabel: currentTheme.label,
177
+ kind: 'choice',
178
+ feedback: {
179
+ title: frameMessage(i18n, 'settings.title', 'Settings'),
180
+ message: frameMessage(i18n, 'settings.shellTheme.feedback', 'Shell theme set to {theme}.', { theme: nextTheme.label }),
181
+ },
182
+ };
183
+ const shellSectionTitle = frameMessage(i18n, 'settings.section.shell', 'Shell');
184
+ if (settings == null) {
185
+ return {
186
+ title: frameMessage(i18n, 'settings.title', 'Settings'),
187
+ sections: [{ id: 'shell', title: shellSectionTitle, rows: [row] }],
188
+ };
189
+ }
190
+ const shellSectionIndex = settings.sections.findIndex((section) => section.id === 'shell');
191
+ if (shellSectionIndex >= 0) {
192
+ const shellSection = settings.sections[shellSectionIndex];
193
+ const existingRowIndex = shellSection.rows.findIndex((existingRow) => existingRow.id === FRAME_SHELL_THEME_ROW_ID);
194
+ const nextRows = existingRowIndex >= 0
195
+ ? shellSection.rows.map((existingRow, index) => (index === existingRowIndex ? row : existingRow))
196
+ : [...shellSection.rows, row];
197
+ return {
198
+ ...settings,
199
+ sections: settings.sections.map((section, index) => (index === shellSectionIndex
200
+ ? { ...shellSection, rows: nextRows }
201
+ : section)),
202
+ };
203
+ }
204
+ return {
205
+ ...settings,
206
+ sections: [
207
+ { id: 'shell', title: shellSectionTitle, rows: [row] },
208
+ ...settings.sections,
209
+ ],
210
+ };
211
+ }
137
212
  // Factory
138
213
  // ---------------------------------------------------------------------------
139
214
  /**
@@ -155,13 +230,65 @@ export function createFramedApp(options) {
155
230
  if (!pagesById.has(defaultPageId)) {
156
231
  throw new Error(`createFramedApp: defaultPageId "${defaultPageId}" not found in pages`);
157
232
  }
233
+ const defaultFrameCtx = options.ctx ?? resolveSafeCtx();
234
+ if (options.shellThemes != null && options.shellThemes.length > 0 && defaultFrameCtx == null) {
235
+ throw new Error('createFramedApp: shellThemes requires options.ctx or a default Bijou context');
236
+ }
237
+ const resolvedShellThemes = options.shellThemes?.map((theme) => ({
238
+ id: theme.id,
239
+ label: theme.label,
240
+ description: theme.description,
241
+ shellTheme: theme,
242
+ resolvedTheme: createResolved(theme.theme, defaultFrameCtx.theme.noColor, defaultFrameCtx.theme.colorScheme),
243
+ })) ?? [];
244
+ const enableShellThemeSettings = resolvedShellThemes.length > 1;
245
+ const initialShellTheme = resolveShellThemeForContext(resolvedShellThemes, defaultFrameCtx)
246
+ ?? resolvedShellThemes[0];
247
+ const usesAmbientDefaultContext = options.ctx == null && defaultFrameCtx != null;
248
+ let frameCtx = options.ctx;
249
+ let frameCtxShellThemeId = resolveShellThemeForContext(resolvedShellThemes, frameCtx)?.id;
250
+ function resolveFrameCtx() {
251
+ return frameCtx ?? options.ctx ?? resolveSafeCtx();
252
+ }
253
+ function resolveFrameThemeCtx(activeShellThemeId) {
254
+ const baseCtx = resolveFrameCtx();
255
+ if (defaultFrameCtx == null)
256
+ return baseCtx;
257
+ const activeTheme = resolveCurrentShellTheme(resolvedShellThemes, activeShellThemeId);
258
+ if (activeTheme == null)
259
+ return baseCtx;
260
+ if (frameCtx != null && frameCtxShellThemeId === activeTheme.id) {
261
+ return frameCtx;
262
+ }
263
+ if (resolveShellThemeForContext(resolvedShellThemes, baseCtx)?.id === activeTheme.id) {
264
+ return baseCtx;
265
+ }
266
+ return cloneShellThemeContext(defaultFrameCtx, activeTheme.resolvedTheme);
267
+ }
268
+ function publishShellThemeContext(nextTheme) {
269
+ if (defaultFrameCtx == null)
270
+ return resolveFrameCtx();
271
+ frameCtx = cloneShellThemeContext(defaultFrameCtx, nextTheme.resolvedTheme);
272
+ frameCtxShellThemeId = nextTheme.id;
273
+ if (usesAmbientDefaultContext) {
274
+ setDefaultContext(frameCtx);
275
+ }
276
+ options.onShellThemeChange?.({
277
+ shellTheme: nextTheme.shellTheme,
278
+ ctx: frameCtx,
279
+ });
280
+ return frameCtx;
281
+ }
158
282
  const frameKeys = createFrameKeyMap({
159
- enableSettings: options.settings != null,
283
+ enableSettings: options.settings != null || enableShellThemeSettings,
160
284
  enableNotifications: options.notificationCenter != null || options.runtimeNotifications !== false,
161
285
  i18n: options.i18n,
162
286
  });
163
287
  const frameNotificationOptions = resolveFrameNotificationOptions(options);
164
288
  let composedFrameScratch = null;
289
+ let headerScratch;
290
+ let helpLineScratch;
291
+ const paneScratchPool = createFramePaneScratchPool();
165
292
  const paletteKeys = commandPaletteKeyMap({
166
293
  focusNext: { type: 'cp-next' },
167
294
  focusPrev: { type: 'cp-prev' },
@@ -218,30 +345,35 @@ export function createFramedApp(options) {
218
345
  // --- settings ---
219
346
  'settings-focus-move': (model, cmd) => {
220
347
  const c = cmd;
221
- const layout = resolveSettingsLayout(model, options, pagesById);
348
+ const layout = resolveSettingsLayout(model, options, pagesById, resolvedShellThemes);
222
349
  return layout != null ? moveSettingsFocus(model, layout, c.delta) : model;
223
350
  },
224
351
  'settings-scroll': (model, cmd) => {
225
352
  const c = cmd;
226
- const layout = resolveSettingsLayout(model, options, pagesById);
353
+ const layout = resolveSettingsLayout(model, options, pagesById, resolvedShellThemes);
227
354
  return layout != null ? scrollSettingsBy(model, layout, c.delta) : model;
228
355
  },
229
356
  'settings-scroll-to': (model, cmd) => {
230
357
  const c = cmd;
231
- const layout = resolveSettingsLayout(model, options, pagesById);
358
+ const layout = resolveSettingsLayout(model, options, pagesById, resolvedShellThemes);
232
359
  if (layout == null)
233
360
  return model;
234
361
  return { ...model, settingsScrollY: c.position === 'top' ? 0 : layout.maxScrollY };
235
362
  },
236
363
  'activate-settings-row': (model, cmd, teaCmds) => {
237
364
  const c = cmd;
238
- const layout = resolveSettingsLayout(model, options, pagesById);
365
+ const layout = resolveSettingsLayout(model, options, pagesById, resolvedShellThemes);
239
366
  if (layout == null)
240
367
  return model;
241
368
  const hitRow = layout.rows.find((r) => r.index === c.rowIndex);
242
369
  if (hitRow == null)
243
370
  return model;
244
371
  const focusedModel = { ...model, settingsFocusIndex: hitRow.index };
372
+ if (hitRow.behavior === 'cycle-shell-theme') {
373
+ const [nextModel, cmds] = cycleShellThemeSetting(focusedModel, hitRow.row);
374
+ teaCmds.push(...cmds);
375
+ return nextModel;
376
+ }
245
377
  if (hitRow.row.action === undefined || hitRow.row.enabled === false || hitRow.row.kind === 'info') {
246
378
  return focusedModel;
247
379
  }
@@ -252,18 +384,18 @@ export function createFramedApp(options) {
252
384
  // --- notification center ---
253
385
  'notification-center-scroll': (model, cmd) => {
254
386
  const c = cmd;
255
- const layout = resolveNotificationCenterLayout(model, options, pagesById);
387
+ const layout = resolveNotificationCenterLayout(model, options, pagesById, resolveFrameThemeCtx(model.activeShellThemeId));
256
388
  return layout != null ? scrollNotificationCenterBy(model, layout, c.delta) : model;
257
389
  },
258
390
  'notification-center-scroll-to': (model, cmd) => {
259
391
  const c = cmd;
260
- const layout = resolveNotificationCenterLayout(model, options, pagesById);
392
+ const layout = resolveNotificationCenterLayout(model, options, pagesById, resolveFrameThemeCtx(model.activeShellThemeId));
261
393
  if (layout == null)
262
394
  return model;
263
395
  return { ...model, notificationCenterScrollY: c.position === 'top' ? 0 : layout.maxScrollY };
264
396
  },
265
397
  'cycle-notification-filter': (model, _cmd, teaCmds) => {
266
- const layout = resolveNotificationCenterLayout(model, options, pagesById);
398
+ const layout = resolveNotificationCenterLayout(model, options, pagesById, resolveFrameThemeCtx(model.activeShellThemeId));
267
399
  if (layout == null)
268
400
  return model;
269
401
  const [nextModel, cmds] = cycleNotificationCenterFilter(model, layout);
@@ -274,7 +406,7 @@ export function createFramedApp(options) {
274
406
  'help-scroll': (model, cmd) => {
275
407
  const c = cmd;
276
408
  const activePage = pagesById.get(model.activePageId);
277
- const overlay = renderHelpOverlay(model, activePage, frameKeys, paletteKeys, options, pagesById);
409
+ const overlay = renderHelpOverlay(model, activePage, frameKeys, paletteKeys, options, pagesById, resolvedShellThemes);
278
410
  const viewportHeight = Math.max(1, overlay.body.height - 1);
279
411
  const delta = c.action === 'down' ? 3
280
412
  : c.action === 'up' ? -3
@@ -334,7 +466,7 @@ export function createFramedApp(options) {
334
466
  const c = cmd;
335
467
  if (!frameNotificationOptions.enabled)
336
468
  return model;
337
- const nowMs = resolveClock(resolveSafeCtx()).now();
469
+ const nowMs = resolveClock(resolveFrameCtx()).now();
338
470
  const [nextModel, cmds] = applyFrameNotificationState(model, dismissNotification(model.runtimeNotifications, c.notificationId, nowMs), nowMs);
339
471
  teaCmds.push(...cmds);
340
472
  return nextModel;
@@ -433,7 +565,7 @@ export function createFramedApp(options) {
433
565
  return [obs];
434
566
  }
435
567
  function handleSettingsLayerKeyCommands(msg, model) {
436
- const layout = resolveSettingsLayout(model, options, pagesById);
568
+ const layout = resolveSettingsLayout(model, options, pagesById, resolvedShellThemes);
437
569
  if (layout == null)
438
570
  return undefined;
439
571
  const obs = { type: 'observed-key', msg, route: 'frame' };
@@ -486,7 +618,10 @@ export function createFramedApp(options) {
486
618
  if (!msg.ctrl && !msg.alt && (msg.key === 'enter' || msg.key === 'space')) {
487
619
  const rowIndex = clampSettingsFocus(model, layout);
488
620
  const row = layout.rows[rowIndex];
489
- if (row?.row.action !== undefined && row.row.enabled !== false && row.row.kind !== 'info') {
621
+ if (row != null
622
+ && row.row.enabled !== false
623
+ && row.row.kind !== 'info'
624
+ && (row.behavior === 'cycle-shell-theme' || row.row.action !== undefined)) {
490
625
  return [obs, { type: 'activate-settings-row', rowIndex: row.index }];
491
626
  }
492
627
  return [obs];
@@ -494,7 +629,7 @@ export function createFramedApp(options) {
494
629
  return [obs];
495
630
  }
496
631
  function handleNotificationCenterLayerKeyCommands(msg, model) {
497
- const layout = resolveNotificationCenterLayout(model, options, pagesById);
632
+ const layout = resolveNotificationCenterLayout(model, options, pagesById, resolveFrameThemeCtx(model.activeShellThemeId));
498
633
  if (layout == null)
499
634
  return undefined;
500
635
  const obs = { type: 'observed-key', msg, route: 'frame' };
@@ -648,13 +783,15 @@ export function createFramedApp(options) {
648
783
  const bodyRect = resolveBodyRect(model, options);
649
784
  const maxState = model.maximizedPaneByPage[model.activePageId];
650
785
  const maximizedPaneId = maxState?.maximizedPaneId;
786
+ const themedFrameCtx = resolveFrameThemeCtx(model.activeShellThemeId);
651
787
  const renderResult = maximizedPaneId
652
- ? renderMaximizedPane(model.activePageId, model, bodyRect, pagesById, maximizedPaneId)
653
- : renderPageContent(model.activePageId, model, bodyRect, pagesById);
788
+ ? renderMaximizedPane(model.activePageId, model, bodyRect, pagesById, maximizedPaneId, paneScratchPool, themedFrameCtx)
789
+ : renderPageContent(model.activePageId, model, bodyRect, pagesById, themedFrameCtx);
654
790
  return renderResult.paneRects;
655
791
  }
656
792
  function buildWorkspaceLayoutTree(model) {
657
- const header = resolveHeaderLine(model, options, pagesById);
793
+ const header = resolveHeaderLine(model, options, pagesById, headerScratch, resolveFrameThemeCtx(model.activeShellThemeId));
794
+ headerScratch = header.surface;
658
795
  const tabChildren = header.tabTargets.map((target) => createShellRetainedLayoutNode(`tab:${target.pageId}`, {
659
796
  row: 0,
660
797
  col: target.startCol,
@@ -694,7 +831,9 @@ export function createFramedApp(options) {
694
831
  }
695
832
  function resolveFrameMouseRuntimeLayouts(model) {
696
833
  let layouts = EMPTY_RUNTIME_LAYOUTS;
697
- const settingsLayout = model.settingsOpen ? resolveSettingsLayout(model, options, pagesById) : undefined;
834
+ const settingsLayout = model.settingsOpen
835
+ ? resolveSettingsLayout(model, options, pagesById, resolvedShellThemes)
836
+ : undefined;
698
837
  if (settingsLayout != null) {
699
838
  layouts = retainRuntimeLayout(layouts, {
700
839
  viewId: 'settings',
@@ -706,7 +845,9 @@ export function createFramedApp(options) {
706
845
  }, buildSettingsRowChildren(model, settingsLayout)),
707
846
  });
708
847
  }
709
- const notificationCenterLayout = model.notificationCenterOpen ? resolveNotificationCenterLayout(model, options, pagesById) : undefined;
848
+ const notificationCenterLayout = model.notificationCenterOpen
849
+ ? resolveNotificationCenterLayout(model, options, pagesById, resolveFrameThemeCtx(model.activeShellThemeId))
850
+ : undefined;
710
851
  if (notificationCenterLayout != null) {
711
852
  layouts = retainRuntimeLayout(layouts, {
712
853
  viewId: 'notification-center',
@@ -786,7 +927,7 @@ export function createFramedApp(options) {
786
927
  screenHeight: model.rows,
787
928
  margin: frameNotificationOptions.margin,
788
929
  gap: frameNotificationOptions.gap,
789
- ctx: resolveSafeCtx() ?? undefined,
930
+ ctx: resolveFrameThemeCtx(model.activeShellThemeId) ?? undefined,
790
931
  }, msg.col, msg.row);
791
932
  if (notificationTarget?.kind === 'dismiss') {
792
933
  cmds.push({ type: 'dismiss-notification', notificationId: notificationTarget.item.id });
@@ -868,7 +1009,7 @@ export function createFramedApp(options) {
868
1009
  return helpLineOverride ?? mergeBindingSources(frameKeys, options.globalKeys, activeInputArea?.helpSource ?? activeInputArea?.keyMap, activePage.helpSource ?? activePage.keyMap);
869
1010
  }
870
1011
  function resolveLayerMetadata(model, activePage, activeInputArea, modalKeyMap) {
871
- const settings = resolveFrameSettings(model, options, pagesById);
1012
+ const settings = resolveFrameSettings(model, options, pagesById, resolvedShellThemes);
872
1013
  const notificationCenter = resolveFrameNotificationCenter(model, options, pagesById);
873
1014
  const workspaceHintSource = resolveWorkspaceHintSource(model, activePage, activeInputArea);
874
1015
  const workspaceHelpSource = resolveWorkspaceHelpSource(activePage, activeInputArea);
@@ -978,19 +1119,15 @@ export function createFramedApp(options) {
978
1119
  }
979
1120
  return [nextModel, []];
980
1121
  }
981
- function activateSettingsRow(model, row) {
982
- if (row.action === undefined || row.enabled === false || row.kind === 'info') {
983
- return [model, []];
984
- }
985
- const cmds = [emitMsgForPage(model.activePageId, row.action)];
1122
+ function pushSettingsFeedback(model, row) {
986
1123
  if (!frameNotificationOptions.enabled) {
987
- return [model, cmds];
1124
+ return [model, []];
988
1125
  }
989
1126
  const feedback = row.feedback ?? {
990
1127
  title: 'Setting updated',
991
1128
  message: `${row.label} updated.`,
992
1129
  };
993
- const nowMs = resolveClock(resolveSafeCtx()).now();
1130
+ const nowMs = resolveClock(resolveFrameCtx()).now();
994
1131
  const notifications = pushNotification(model.runtimeNotifications, {
995
1132
  title: feedback.title ?? 'Setting updated',
996
1133
  message: feedback.message,
@@ -1001,9 +1138,28 @@ export function createFramedApp(options) {
1001
1138
  durationMs: feedback.durationMs ?? 2_500,
1002
1139
  overflow: frameNotificationOptions.overflow,
1003
1140
  }, nowMs);
1004
- const [nextModel, notificationCmds] = applyFrameNotificationState(model, notifications, nowMs);
1141
+ return applyFrameNotificationState(model, notifications, nowMs);
1142
+ }
1143
+ function activateSettingsRow(model, row) {
1144
+ if (row.action === undefined || row.enabled === false || row.kind === 'info') {
1145
+ return [model, []];
1146
+ }
1147
+ const cmds = [emitMsgForPage(model.activePageId, row.action)];
1148
+ const [nextModel, notificationCmds] = pushSettingsFeedback(model, row);
1005
1149
  return [nextModel, [...cmds, ...notificationCmds]];
1006
1150
  }
1151
+ function cycleShellThemeSetting(model, row) {
1152
+ const nextTheme = resolveNextShellTheme(resolvedShellThemes, model.activeShellThemeId);
1153
+ if (nextTheme == null) {
1154
+ return [model, []];
1155
+ }
1156
+ publishShellThemeContext(nextTheme);
1157
+ const [nextModel, notificationCmds] = pushSettingsFeedback({
1158
+ ...model,
1159
+ activeShellThemeId: nextTheme.id,
1160
+ }, row);
1161
+ return [nextModel, notificationCmds];
1162
+ }
1007
1163
  const app = {
1008
1164
  init() {
1009
1165
  const pageModels = {};
@@ -1040,7 +1196,11 @@ export function createFramedApp(options) {
1040
1196
  runtimeNotifications: createNotificationState(),
1041
1197
  runtimeNotificationHistoryFilter: 'ALL',
1042
1198
  runtimeNotificationLoopActive: false,
1199
+ activeShellThemeId: initialShellTheme?.id,
1043
1200
  };
1201
+ if (initialShellTheme != null) {
1202
+ publishShellThemeContext(initialShellTheme);
1203
+ }
1044
1204
  for (const pageId of pageOrder) {
1045
1205
  model = syncPageFrameState(model, pageId, pagesById);
1046
1206
  }
@@ -1155,14 +1315,20 @@ export function createFramedApp(options) {
1155
1315
  return updateTargetPage(model, model.activePageId, msg);
1156
1316
  },
1157
1317
  view(model) {
1318
+ const themedFrameCtx = resolveFrameThemeCtx(model.activeShellThemeId);
1158
1319
  const { activePage, layerStack, activeLayer, } = resolvePresentedLayerContext(model);
1159
- const header = resolveHeaderLine(model, options, pagesById).surface;
1160
- const helpLine = renderHelpLine(model, activeLayer, options.i18n, resolveNotificationFooterCue(model, options, pagesById));
1320
+ const headerResult = resolveHeaderLine(model, options, pagesById, headerScratch, themedFrameCtx);
1321
+ headerScratch = headerResult.surface;
1322
+ const header = headerResult.surface;
1323
+ helpLineScratch = renderHelpLine(model, activeLayer, options.i18n, resolveNotificationFooterCue(model, options, pagesById), helpLineScratch, themedFrameCtx);
1324
+ const helpLine = helpLineScratch;
1161
1325
  const bodyRect = resolveBodyRect(model, options);
1162
1326
  // Check for maximized pane — if set, render only that pane at full body rect
1163
1327
  const maxState = model.maximizedPaneByPage[model.activePageId];
1164
1328
  const maximizedPaneId = maxState?.maximizedPaneId;
1165
1329
  const frameSurface = getComposedFrameScratch(model.columns, model.rows);
1330
+ // clear() is load-bearing: it resets dim flags left by overlay compositing
1331
+ // on the previous frame. Do not skip or defer this call.
1166
1332
  frameSurface.clear();
1167
1333
  frameSurface.blit(header, 0, 0);
1168
1334
  if (model.rows > 1) {
@@ -1173,20 +1339,20 @@ export function createFramedApp(options) {
1173
1339
  const activeTransition = model.activeTransition ?? options.transition;
1174
1340
  if (model.previousPageId != null && model.transitionProgress < 1 && activeTransition && activeTransition !== 'none') {
1175
1341
  const activeBodyResult = maximizedPaneId
1176
- ? renderMaximizedPane(model.activePageId, model, bodyRect, pagesById, maximizedPaneId)
1177
- : renderPageContent(model.activePageId, model, bodyRect, pagesById);
1342
+ ? renderMaximizedPane(model.activePageId, model, bodyRect, pagesById, maximizedPaneId, paneScratchPool, themedFrameCtx)
1343
+ : renderPageContent(model.activePageId, model, bodyRect, pagesById, themedFrameCtx);
1178
1344
  activeResult = activeBodyResult;
1179
1345
  bodySurface = activeBodyResult.surface;
1180
- const ctx = resolveSafeCtx();
1346
+ const ctx = themedFrameCtx;
1181
1347
  if (ctx) {
1182
- const prevResult = renderPageContent(model.previousPageId, model, bodyRect, pagesById);
1348
+ const prevResult = renderPageContent(model.previousPageId, model, bodyRect, pagesById, themedFrameCtx);
1183
1349
  bodySurface = renderTransition(prevResult.surface, activeBodyResult.surface, activeTransition, model.transitionProgress, bodyRect.width, bodyRect.height, ctx, model.transitionFrame);
1184
1350
  }
1185
1351
  }
1186
1352
  else {
1187
1353
  activeResult = maximizedPaneId
1188
- ? renderMaximizedPaneInto(model.activePageId, model, bodyRect, pagesById, maximizedPaneId, frameSurface)
1189
- : renderPageContentInto(model.activePageId, model, bodyRect, pagesById, frameSurface);
1354
+ ? renderMaximizedPaneInto(model.activePageId, model, bodyRect, pagesById, maximizedPaneId, frameSurface, bodyRect.row, bodyRect.col, paneScratchPool, themedFrameCtx)
1355
+ : renderPageContentInto(model.activePageId, model, bodyRect, pagesById, frameSurface, bodyRect.row, bodyRect.col, paneScratchPool, themedFrameCtx);
1190
1356
  }
1191
1357
  const overlays = [];
1192
1358
  if (options.overlayFactory != null) {
@@ -1198,7 +1364,7 @@ export function createFramedApp(options) {
1198
1364
  }));
1199
1365
  }
1200
1366
  if (frameNotificationOptions.enabled) {
1201
- const ctx = resolveSafeCtx();
1367
+ const ctx = themedFrameCtx;
1202
1368
  overlays.push(...renderNotificationStack(model.runtimeNotifications, {
1203
1369
  screenWidth: model.columns,
1204
1370
  screenHeight: model.rows,
@@ -1209,20 +1375,20 @@ export function createFramedApp(options) {
1209
1375
  }
1210
1376
  if (model.settingsOpen) {
1211
1377
  const settingsLayer = layerStack.find((layer) => layer.kind === 'settings');
1212
- const settingsOverlay = renderSettingsDrawer(model, options, pagesById, settingsLayer?.title);
1378
+ const settingsOverlay = renderSettingsDrawer(model, options, pagesById, resolvedShellThemes, settingsLayer?.title, themedFrameCtx);
1213
1379
  if (settingsOverlay != null) {
1214
1380
  overlays.push(settingsOverlay);
1215
1381
  }
1216
1382
  }
1217
1383
  if (model.notificationCenterOpen) {
1218
1384
  const notificationLayer = layerStack.find((layer) => layer.kind === 'notification-center');
1219
- const notificationCenterOverlay = renderNotificationCenterDrawer(model, options, pagesById, notificationLayer?.title);
1385
+ const notificationCenterOverlay = renderNotificationCenterDrawer(model, options, pagesById, notificationLayer?.title, themedFrameCtx);
1220
1386
  if (notificationCenterOverlay != null) {
1221
1387
  overlays.push(notificationCenterOverlay);
1222
1388
  }
1223
1389
  }
1224
1390
  if (model.helpOpen) {
1225
- const helpOverlay = renderHelpOverlay(model, activePage, frameKeys, paletteKeys, options, pagesById);
1391
+ const helpOverlay = renderHelpOverlay(model, activePage, frameKeys, paletteKeys, options, pagesById, resolvedShellThemes);
1226
1392
  overlays.push(modal({
1227
1393
  title: activeLayer.kind === 'help'
1228
1394
  ? (activeLayer.title ?? frameMessage(options.i18n, 'help.title', 'Keyboard Help'))
@@ -1231,6 +1397,9 @@ export function createFramedApp(options) {
1231
1397
  hint: typeof activeLayer.hintSource === 'string'
1232
1398
  ? activeLayer.hintSource
1233
1399
  : frameMessage(options.i18n, 'help.hint', 'j/k scroll • d/u page • g/G top/bottom • mouse wheel • ?/Esc close'),
1400
+ borderToken: themedFrameCtx?.border('primary'),
1401
+ bgToken: themedFrameCtx?.surface('elevated'),
1402
+ ctx: themedFrameCtx,
1234
1403
  width: helpOverlay.body.width + 4,
1235
1404
  screenWidth: model.columns,
1236
1405
  screenHeight: model.rows,
@@ -1238,7 +1407,11 @@ export function createFramedApp(options) {
1238
1407
  }
1239
1408
  if (model.commandPalette != null) {
1240
1409
  const paletteWidth = Math.max(20, Math.min(80, model.columns - 4));
1241
- const paletteBody = commandPalette(model.commandPalette, { width: Math.max(16, paletteWidth - 4) });
1410
+ const paletteBody = commandPaletteSurface(model.commandPalette, {
1411
+ width: Math.max(16, paletteWidth - 4),
1412
+ ctx: themedFrameCtx,
1413
+ showScrollbar: false,
1414
+ });
1242
1415
  const paletteLayer = activeLayer.kind === 'search' || activeLayer.kind === 'command-palette'
1243
1416
  ? activeLayer
1244
1417
  : undefined;
@@ -1248,13 +1421,16 @@ export function createFramedApp(options) {
1248
1421
  hint: typeof paletteLayer?.hintSource === 'string'
1249
1422
  ? paletteLayer.hintSource
1250
1423
  : frameMessage(options.i18n, 'palette.hint', 'Enter select • Esc close'),
1424
+ borderToken: themedFrameCtx?.border('primary'),
1425
+ bgToken: themedFrameCtx?.surface('elevated'),
1426
+ ctx: themedFrameCtx,
1251
1427
  width: paletteWidth,
1252
1428
  screenWidth: model.columns,
1253
1429
  screenHeight: model.rows,
1254
1430
  }));
1255
1431
  }
1256
1432
  if (model.quitConfirmOpen) {
1257
- overlays.push(renderShellQuitOverlay(model.columns, model.rows, options.i18n));
1433
+ overlays.push(renderShellQuitOverlay(model.columns, model.rows, options.i18n, themedFrameCtx));
1258
1434
  }
1259
1435
  if (bodySurface != null && bodyRect.width > 0 && bodyRect.height > 0) {
1260
1436
  frameSurface.blit(bodySurface, bodyRect.col, bodyRect.row);
@@ -1283,11 +1459,11 @@ function focusPane(model, paneId) {
1283
1459
  function resolveBodyRect(model, options) {
1284
1460
  return frameBodyRect(model.columns, model.rows, options.bodyTopRows ?? 1, options.bodyBottomRows ?? 1);
1285
1461
  }
1286
- function renderHelpOverlay(model, activePage, frameKeys, paletteKeys, options, pagesById) {
1462
+ function renderHelpOverlay(model, activePage, frameKeys, paletteKeys, options, pagesById, shellThemes) {
1287
1463
  const activePageModel = model.pageModels[model.activePageId];
1288
1464
  const activeInputArea = findInputAreaByPaneId(resolveInputAreas(activePage, activePageModel), model.focusedPaneByPage[model.activePageId]);
1289
1465
  const modalKeyMap = activePage.modalKeyMap?.(activePageModel);
1290
- const settings = resolveFrameSettings(model, options, pagesById);
1466
+ const settings = resolveFrameSettings(model, options, pagesById, shellThemes);
1291
1467
  const notificationCenter = resolveFrameNotificationCenter(model, options, pagesById);
1292
1468
  const workspaceHintSource = options.helpLineSource?.({
1293
1469
  model,
@@ -1380,13 +1556,15 @@ function isHelpScrollAction(action) {
1380
1556
  || action.type === 'top'
1381
1557
  || action.type === 'bottom';
1382
1558
  }
1383
- function resolveFrameSettings(model, options, pagesById) {
1559
+ const FRAME_SHELL_THEME_ROW_ID = '__frame-shell-theme__';
1560
+ function resolveFrameSettings(model, options, pagesById, shellThemes) {
1384
1561
  const activePage = pagesById.get(model.activePageId);
1385
- return options.settings?.({
1562
+ const provided = options.settings?.({
1386
1563
  model,
1387
1564
  activePage,
1388
1565
  pageModel: model.pageModels[model.activePageId],
1389
1566
  });
1567
+ return mergeShellThemeSettings(provided, shellThemes, model.activeShellThemeId, options.i18n);
1390
1568
  }
1391
1569
  function resolveFrameNotificationCenter(model, options, pagesById) {
1392
1570
  const activePage = pagesById.get(model.activePageId);
@@ -1421,8 +1599,8 @@ function resolveFrameNotificationCenter(model, options, pagesById) {
1421
1599
  activeFilter: model.runtimeNotificationHistoryFilter,
1422
1600
  };
1423
1601
  }
1424
- function resolveSettingsLayout(model, options, pagesById) {
1425
- const settings = resolveFrameSettings(model, options, pagesById);
1602
+ function resolveSettingsLayout(model, options, pagesById, shellThemes) {
1603
+ const settings = resolveFrameSettings(model, options, pagesById, shellThemes);
1426
1604
  if (settings == null)
1427
1605
  return undefined;
1428
1606
  const sections = settings.sections.filter((section) => section.rows.length > 0);
@@ -1451,6 +1629,7 @@ function resolveSettingsLayout(model, options, pagesById) {
1451
1629
  line,
1452
1630
  height: rowLayout.height,
1453
1631
  row,
1632
+ behavior: row.id === FRAME_SHELL_THEME_ROW_ID ? 'cycle-shell-theme' : undefined,
1454
1633
  });
1455
1634
  line += rowLayout.height;
1456
1635
  if (rowIndex < section.rows.length - 1) {
@@ -1481,7 +1660,7 @@ function resolveNotificationCenterDrawerWidth(columns) {
1481
1660
  const boundedColumns = Math.max(28, columns);
1482
1661
  return Math.min(Math.max(32, Math.floor(boundedColumns * 0.34)), Math.max(32, boundedColumns - 4), 52);
1483
1662
  }
1484
- function resolveNotificationCenterLayout(model, options, pagesById) {
1663
+ function resolveNotificationCenterLayout(model, options, pagesById, ctx) {
1485
1664
  const center = resolveFrameNotificationCenter(model, options, pagesById);
1486
1665
  if (center == null)
1487
1666
  return undefined;
@@ -1489,7 +1668,7 @@ function resolveNotificationCenterLayout(model, options, pagesById) {
1489
1668
  const anchor = frameEndAnchor(options.i18n);
1490
1669
  const startCol = anchor === 'left' ? 0 : Math.max(0, model.columns - drawerWidth);
1491
1670
  const contentWidth = Math.max(18, drawerWidth - 4);
1492
- const content = renderNotificationCenterSurface(center, contentWidth, options.i18n);
1671
+ const content = renderNotificationCenterSurface(center, contentWidth, options.i18n, ctx);
1493
1672
  const contentHeight = Math.max(1, model.rows - 2);
1494
1673
  const pagerState = createPagerStateForSurface(content, {
1495
1674
  width: contentWidth,
@@ -1579,12 +1758,12 @@ function cycleNotificationCenterFilter(model, layout) {
1579
1758
  notificationCenterScrollY: 0,
1580
1759
  }, []];
1581
1760
  }
1582
- function renderSettingsDrawer(model, options, pagesById, titleOverride) {
1583
- const layout = resolveSettingsLayout(model, options, pagesById);
1761
+ function renderSettingsDrawer(model, options, pagesById, shellThemes, titleOverride, ctx) {
1762
+ const layout = resolveSettingsLayout(model, options, pagesById, shellThemes);
1584
1763
  if (layout == null)
1585
1764
  return undefined;
1586
1765
  const scrollY = clampSettingsScroll(model, layout);
1587
- const content = renderSettingsSurface(layout, model);
1766
+ const content = renderSettingsSurface(layout, model, ctx);
1588
1767
  const pagerState = createPagerStateForSurface(content, {
1589
1768
  width: layout.contentWidth,
1590
1769
  height: layout.contentHeight,
@@ -1606,14 +1785,14 @@ function renderSettingsDrawer(model, options, pagesById, titleOverride) {
1606
1785
  content: body,
1607
1786
  borderToken: layout.settings.borderToken,
1608
1787
  bgToken: layout.settings.bgToken,
1609
- ctx: resolveSafeCtx() ?? undefined,
1788
+ ctx,
1610
1789
  width: layout.drawerWidth,
1611
1790
  screenWidth: model.columns,
1612
1791
  screenHeight: model.rows,
1613
1792
  });
1614
1793
  }
1615
- function renderNotificationCenterDrawer(model, options, pagesById, titleOverride) {
1616
- const layout = resolveNotificationCenterLayout(model, options, pagesById);
1794
+ function renderNotificationCenterDrawer(model, options, pagesById, titleOverride, ctx) {
1795
+ const layout = resolveNotificationCenterLayout(model, options, pagesById, ctx);
1617
1796
  if (layout == null)
1618
1797
  return undefined;
1619
1798
  const pagerState = createPagerStateForSurface(layout.content, {
@@ -1635,17 +1814,20 @@ function renderNotificationCenterDrawer(model, options, pagesById, titleOverride
1635
1814
  anchor: layout.anchor,
1636
1815
  title: titleOverride ?? `${layout.center.title} • ${frameNotificationFilterLabel(options.i18n, layout.center.activeFilter)}`,
1637
1816
  content: body,
1817
+ borderToken: ctx?.border('primary'),
1818
+ bgToken: ctx?.surface('elevated'),
1819
+ ctx,
1638
1820
  width: layout.drawerWidth,
1639
1821
  screenWidth: model.columns,
1640
1822
  screenHeight: model.rows,
1641
1823
  });
1642
1824
  }
1643
- function renderSettingsSurface(layout, model) {
1825
+ function renderSettingsSurface(layout, model, ctx) {
1644
1826
  const focusedIndex = clampSettingsFocus(model, layout);
1645
1827
  return preferenceListSurface(layout.preferenceSections, {
1646
1828
  width: layout.contentWidth,
1647
1829
  selectedRowId: layout.rows[focusedIndex]?.row.id,
1648
- ctx: resolveSafeCtx() ?? undefined,
1830
+ ctx,
1649
1831
  theme: layout.settings.listTheme,
1650
1832
  });
1651
1833
  }
@@ -1675,8 +1857,7 @@ function resolveNotificationFooterCue(model, options, pagesById) {
1675
1857
  const archivedCount = countNotificationHistory(center.state, center.activeFilter);
1676
1858
  return frameNotificationCue(options.i18n, liveCount, archivedCount);
1677
1859
  }
1678
- function renderNotificationCenterSurface(center, width, i18n) {
1679
- const ctx = resolveSafeCtx() ?? undefined;
1860
+ function renderNotificationCenterSurface(center, width, i18n, ctx) {
1680
1861
  const rows = [
1681
1862
  insetLineSurface(`Live: ${center.state.items.length} • Archived: ${center.state.history.length}`, width),
1682
1863
  insetLineSurface(`Filter: ${frameNotificationFilterLabel(i18n, center.activeFilter)}`, width),