@lapismd/design-core 0.1.2 → 0.2.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 (65) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE.md +3 -3
  3. package/README.md +20 -0
  4. package/package.json +24 -9
  5. package/spec/src/styling-and-themes.md +2 -0
  6. package/src/shared/ai/chat.css +23 -0
  7. package/src/shared/ai/chat.ts +12 -0
  8. package/src/shared/ai/context.svelte.ts +2 -0
  9. package/src/shared/ai/layout/Layout.svelte +116 -10
  10. package/src/shared/ai/message-list/MessageList.svelte +123 -43
  11. package/src/shared/ai/message-list/MessageListHistoryFixture.svelte +112 -0
  12. package/src/shared/ai/message-list/MessageListLegacyFixture.svelte +52 -0
  13. package/src/shared/ai/message-list/VirtualizedMessageRows.svelte +299 -0
  14. package/src/shared/ai/message-list/position.ts +7 -0
  15. package/src/shared/ai/new-messages.svelte.ts +24 -5
  16. package/src/shared/ai/stream-scroll.svelte.ts +135 -6
  17. package/src/shared/ai/tool-call-detail/ToolCallDetail.svelte +71 -0
  18. package/src/shared/ai/tool-call-detail/tool-call-detail.ts +209 -0
  19. package/src/shared/diff/core/merge/actions.ts +4 -3
  20. package/src/shared/diff/core/merge/render-model.ts +14 -10
  21. package/src/shared/diff/file-diff/FileDiff.css +68 -1
  22. package/src/shared/diff/file-diff/FileDiff.svelte +22 -6
  23. package/src/shared/diff/file-diff/FileDiffComposer.svelte +98 -16
  24. package/src/shared/diff/file-diff/index.ts +2 -0
  25. package/src/shared/diff/file-diff/types.ts +13 -0
  26. package/src/shared/diff/index.ts +2 -0
  27. package/src/shared/forms/confirm-button/ConfirmButton.svelte +38 -0
  28. package/src/shared/forms/confirm-button/ConfirmButtonExample.svelte +14 -0
  29. package/src/shared/forms/confirm-button/index.ts +1 -0
  30. package/src/shared/forms/confirm-dialog/ConfirmDialog.css +21 -0
  31. package/src/shared/forms/confirm-dialog/ConfirmDialog.svelte +116 -0
  32. package/src/shared/forms/confirm-dialog/ConfirmDialogExample.svelte +17 -0
  33. package/src/shared/forms/confirm-dialog/index.ts +2 -0
  34. package/src/shared/forms/dialog-frame/DialogFrame.css +130 -0
  35. package/src/shared/forms/dialog-frame/DialogFrame.svelte +108 -0
  36. package/src/shared/forms/dialog-frame/DialogFrameExample.svelte +37 -0
  37. package/src/shared/forms/dialog-frame/index.ts +6 -0
  38. package/src/shared/forms/dialog.ts +8 -0
  39. package/src/shared/forms/index.ts +11 -0
  40. package/src/shared/shadcn/button/button.svelte +4 -0
  41. package/src/shared/shadcn/code-block/highlightRanges.ts +10 -6
  42. package/src/shared/shadcn/code-block/syntax/defineSyntaxTheme.ts +4 -2
  43. package/src/shared/shadcn/code-block/tokenizer.ts +3 -3
  44. package/src/shared/shadcn/dialog/dialog-content.svelte +3 -4
  45. package/src/shared/shadcn/dialog/dialog-portal.svelte +10 -2
  46. package/src/shared/shadcn/tooltip/tooltip-content.svelte +10 -4
  47. package/src/shared/shadcn/tooltip/tooltip.tokens.ts +4 -0
  48. package/src/shared/shell/app-shell/AppShell.css +112 -32
  49. package/src/shared/shell/app-shell/AppShellMain.svelte +12 -1
  50. package/src/shared/shell/app-shell/AppShellRoot.svelte +27 -13
  51. package/src/shared/shell/app-shell/AppShellSidebar.svelte +121 -19
  52. package/src/shared/shell/app-shell/AppShellSidebarClose.svelte +17 -17
  53. package/src/shared/shell/app-shell/AppShellSidebarToggle.svelte +38 -58
  54. package/src/shared/shell/app-shell/AppShellSurfaceLayer.svelte +173 -0
  55. package/src/shared/shell/app-shell/AppShellSurfaceLayerBody.svelte +32 -0
  56. package/src/shared/shell/app-shell/AppShellSurfaceLayerHeader.svelte +26 -0
  57. package/src/shared/shell/app-shell/app-shell-context.svelte.ts +6 -3
  58. package/src/shared/shell/app-shell/app-shell-controller.svelte.ts +246 -2
  59. package/src/shared/shell/app-shell/app-shell-sidebar-context.svelte.ts +1 -0
  60. package/src/shared/shell/app-shell/examples/AppShellExamples.css +103 -0
  61. package/src/shared/shell/app-shell/examples/AppShellMultiPanelDemo.svelte +187 -0
  62. package/src/shared/shell/app-shell/index.ts +14 -0
  63. package/src/shared/shell/shell.tokens.css +2 -0
  64. package/src/shared/shell/shell.tokens.ts +5 -0
  65. package/src/theme.css +10 -0
@@ -0,0 +1,173 @@
1
+ <script lang="ts">
2
+ import { untrack, type Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import { useAppShell } from "./app-shell-context.svelte.js";
5
+
6
+ let {
7
+ ref = $bindable(null),
8
+ open,
9
+ label,
10
+ coverPanelIds = [],
11
+ suspendPanelIds = [],
12
+ returnFocus,
13
+ dismissLabel = `Close ${label}`,
14
+ dismissOnEscape = true,
15
+ onDismiss,
16
+ class: className,
17
+ children,
18
+ ...restProps
19
+ }: HTMLAttributes<HTMLDivElement> & {
20
+ ref?: HTMLElement | null;
21
+ /** Whether the structural replacement layer is mounted. */
22
+ open: boolean;
23
+ /** Accessible name for the non-modal replacement surface. */
24
+ label: string;
25
+ /** Mounted sidebar ids included in the covered surface bounds. */
26
+ coverPanelIds?: readonly string[];
27
+ /** Sidebar ids hidden transiently while the layer is active. */
28
+ suspendPanelIds?: readonly string[];
29
+ /** Focus target restored after dismissal. Defaults to the opener. */
30
+ returnFocus?: HTMLElement | null;
31
+ /** Accessible name for the dismissible scrim. */
32
+ dismissLabel?: string;
33
+ /** Whether unhandled Escape dismisses the layer. */
34
+ dismissOnEscape?: boolean;
35
+ /** Consumer-owned route or state transition that closes the layer. */
36
+ onDismiss?: () => void;
37
+ children?: Snippet;
38
+ } = $props();
39
+
40
+ const controller = useAppShell();
41
+ const token = Symbol("app-shell-surface-layer");
42
+ let surfaceElement = $state<HTMLElement | null>(null);
43
+ let inlineStart = $state(0);
44
+ let inlineEnd = $state(0);
45
+ let blockStart = $state(0);
46
+ let blockEnd = $state(0);
47
+
48
+ $effect(() => {
49
+ if (!open || typeof document === "undefined") return;
50
+ const { focusTarget, registration } = untrack(() => ({
51
+ focusTarget:
52
+ returnFocus ??
53
+ (document.activeElement instanceof HTMLElement
54
+ ? document.activeElement
55
+ : null),
56
+ registration: {
57
+ token,
58
+ coverPanelIds: [...coverPanelIds],
59
+ suspendPanelIds: [...suspendPanelIds],
60
+ },
61
+ }));
62
+ untrack(() => controller.activateSurfaceLayer(registration));
63
+ queueMicrotask(() => surfaceElement?.focus({ preventScroll: true }));
64
+
65
+ const handleKeydown = (event: KeyboardEvent) => {
66
+ if (
67
+ !dismissOnEscape ||
68
+ event.defaultPrevented ||
69
+ event.key !== "Escape"
70
+ ) {
71
+ return;
72
+ }
73
+ event.preventDefault();
74
+ onDismiss?.();
75
+ };
76
+ document.addEventListener("keydown", handleKeydown);
77
+ return () => {
78
+ document.removeEventListener("keydown", handleKeydown);
79
+ untrack(() => controller.deactivateSurfaceLayer(token));
80
+ queueMicrotask(() => {
81
+ const target = returnFocus ?? focusTarget;
82
+ if (target?.isConnected) target.focus({ preventScroll: true });
83
+ else controller.mobile.getMainElement()?.focus({ preventScroll: true });
84
+ });
85
+ };
86
+ });
87
+
88
+ $effect(() => {
89
+ void controller.panelElementsVersion;
90
+ if (!open || !ref || typeof ResizeObserver === "undefined") return;
91
+ const root = controller.mobile.getRootElement();
92
+ const main = controller.mobile.getMainElement();
93
+ if (!root || !main) return;
94
+ const coveredElements = coverPanelIds.flatMap((id) => {
95
+ const element = controller.getPanelElement(id);
96
+ return element ? [element] : [];
97
+ });
98
+
99
+ const updateInsets = () => {
100
+ const rootBounds = root.getBoundingClientRect();
101
+ const paddingLeft = rootBounds.left + root.clientLeft;
102
+ const paddingRight = paddingLeft + root.clientWidth;
103
+ const paddingTop = rootBounds.top + root.clientTop;
104
+ const paddingBottom = paddingTop + root.clientHeight;
105
+ const visibleBounds = [main, ...coveredElements]
106
+ .map((element) => element.getBoundingClientRect())
107
+ .filter((bounds) => bounds.width > 0 && bounds.height > 0);
108
+ if (visibleBounds.length === 0) return;
109
+ const left = Math.min(...visibleBounds.map((bounds) => bounds.left));
110
+ const right = Math.max(...visibleBounds.map((bounds) => bounds.right));
111
+ const top = main.getBoundingClientRect().top;
112
+ const bottom = main.getBoundingClientRect().bottom;
113
+ const rtl = getComputedStyle(root).direction === "rtl";
114
+ inlineStart = Math.max(
115
+ 0,
116
+ rtl ? paddingRight - right : left - paddingLeft,
117
+ );
118
+ inlineEnd = Math.max(0, rtl ? left - paddingLeft : paddingRight - right);
119
+ blockStart = Math.max(0, top - paddingTop);
120
+ blockEnd = Math.max(0, paddingBottom - bottom);
121
+ };
122
+
123
+ let frame = requestAnimationFrame(updateInsets);
124
+ const scheduleUpdate = () => {
125
+ cancelAnimationFrame(frame);
126
+ frame = requestAnimationFrame(updateInsets);
127
+ };
128
+ const observer = new ResizeObserver(scheduleUpdate);
129
+ observer.observe(root);
130
+ observer.observe(main);
131
+ for (const element of coveredElements) observer.observe(element);
132
+ return () => {
133
+ cancelAnimationFrame(frame);
134
+ observer.disconnect();
135
+ };
136
+ });
137
+ </script>
138
+
139
+ {#if open}
140
+ <div
141
+ bind:this={ref}
142
+ {...restProps}
143
+ class={["ui-minimal-app-shell__surface-layer", className]
144
+ .filter(Boolean)
145
+ .join(" ")}
146
+ data-ui-component="app-shell"
147
+ data-ui-part="surface-layer"
148
+ style:inset-inline-start={`${inlineStart}px`}
149
+ style:inset-inline-end={`${inlineEnd}px`}
150
+ style:inset-block-start={`${blockStart}px`}
151
+ style:inset-block-end={`${blockEnd}px`}
152
+ >
153
+ <button
154
+ type="button"
155
+ tabindex="-1"
156
+ class="ui-minimal-app-shell__surface-layer-scrim"
157
+ data-ui-component="app-shell"
158
+ data-ui-part="surface-layer-scrim"
159
+ aria-label={dismissLabel}
160
+ onclick={() => onDismiss?.()}
161
+ ></button>
162
+ <section
163
+ bind:this={surfaceElement}
164
+ class="ui-minimal-app-shell__surface-layer-surface"
165
+ data-ui-component="app-shell"
166
+ data-ui-part="surface-layer-surface"
167
+ aria-label={label}
168
+ tabindex="-1"
169
+ >
170
+ {@render children?.()}
171
+ </section>
172
+ </div>
173
+ {/if}
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { WithElementRef } from "../../../lib/utils.js";
4
+ import { ScrollArea } from "../../shadcn/scroll-area/index.js";
5
+ import { useAppShell } from "./app-shell-context.svelte.js";
6
+
7
+ let {
8
+ ref = $bindable(null),
9
+ class: className,
10
+ children,
11
+ ...restProps
12
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
13
+
14
+ useAppShell();
15
+ </script>
16
+
17
+ <div
18
+ bind:this={ref}
19
+ {...restProps}
20
+ class={["ui-minimal-app-shell__surface-layer-body", className]
21
+ .filter(Boolean)
22
+ .join(" ")}
23
+ data-ui-component="app-shell"
24
+ data-ui-part="surface-layer-body"
25
+ >
26
+ <ScrollArea
27
+ type="auto"
28
+ class="ui-minimal-app-shell__surface-layer-scroll-area"
29
+ >
30
+ {@render children?.()}
31
+ </ScrollArea>
32
+ </div>
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { WithElementRef } from "../../../lib/utils.js";
4
+ import { useAppShell } from "./app-shell-context.svelte.js";
5
+
6
+ let {
7
+ ref = $bindable(null),
8
+ class: className,
9
+ children,
10
+ ...restProps
11
+ }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
12
+
13
+ useAppShell();
14
+ </script>
15
+
16
+ <header
17
+ bind:this={ref}
18
+ {...restProps}
19
+ class={["ui-minimal-app-shell__surface-layer-header", className]
20
+ .filter(Boolean)
21
+ .join(" ")}
22
+ data-ui-component="app-shell"
23
+ data-ui-part="surface-layer-header"
24
+ >
25
+ {@render children?.()}
26
+ </header>
@@ -15,9 +15,7 @@ export function setAppShellContext(
15
15
  * Call this during component initialization, as required by Svelte context.
16
16
  */
17
17
  export function useAppShell(): AppShellController {
18
- const controller = getContext<AppShellController | undefined>(
19
- APP_SHELL_CONTEXT,
20
- );
18
+ const controller = useOptionalAppShell();
21
19
  if (!controller) {
22
20
  throw new Error(
23
21
  "AppShell components must be rendered inside AppShell.Root",
@@ -25,3 +23,8 @@ export function useAppShell(): AppShellController {
25
23
  }
26
24
  return controller;
27
25
  }
26
+
27
+ /** Return the nearest root controller when a compound action may live outside it. */
28
+ export function useOptionalAppShell(): AppShellController | undefined {
29
+ return getContext<AppShellController | undefined>(APP_SHELL_CONTEXT);
30
+ }
@@ -14,6 +14,7 @@ export type AppShellDisplayMode = "auto" | "desktop" | "mobile";
14
14
  export type AppShellResolvedDisplayMode = Exclude<AppShellDisplayMode, "auto">;
15
15
  export type AppShellMobileStage = "left" | "main" | "right";
16
16
  export type AppShellMobilePanelKind = "sidebar" | "body-sidebar";
17
+ export type AppShellConstrainedPresentation = "preview" | "replace-main";
17
18
 
18
19
  export interface AppShellMobilePanelRegistration {
19
20
  /** Stable id used by mobile selectors and targeted toggle actions. */
@@ -26,6 +27,19 @@ export interface AppShellMobilePanelRegistration {
26
27
  readonly kind: AppShellMobilePanelKind;
27
28
  /** Current panel landmark, when mounted in a browser. */
28
29
  readonly element?: HTMLElement | null;
30
+ /** Lower values leave inline desktop flow before higher values. */
31
+ readonly constraintPriority?: number;
32
+ /** Presentation used when protected main width displaces this panel. */
33
+ readonly constrainedPresentation?: AppShellConstrainedPresentation;
34
+ }
35
+
36
+ export interface AppShellSurfaceLayerRegistration {
37
+ /** Unique mounted layer owner. Only one may be active per root. */
38
+ readonly token: symbol;
39
+ /** Sidebar ids included in the covered, inert surface. */
40
+ readonly coverPanelIds: readonly string[];
41
+ /** Sidebar ids removed transiently without changing durable layout. */
42
+ readonly suspendPanelIds: readonly string[];
29
43
  }
30
44
 
31
45
  export const APP_SHELL_DEFAULT_SIDEBAR_WIDTH = 288;
@@ -171,14 +185,17 @@ export class AppShellMobileController {
171
185
  if (returnFocus) this.#returnFocus = returnFocus;
172
186
  this.#rootElement?.focus({ preventScroll: true });
173
187
  this.stage = side;
174
- queueMicrotask(() => this.activePanel(side)?.element?.focus());
188
+ queueMicrotask(() =>
189
+ this.activePanel(side)?.element?.focus({ preventScroll: true }),
190
+ );
175
191
  }
176
192
 
177
193
  showMain(restoreFocus = true): void {
178
194
  const focusTarget = this.#returnFocus ?? this.#mainElement;
179
195
  this.#rootElement?.focus({ preventScroll: true });
180
196
  this.stage = "main";
181
- if (restoreFocus) queueMicrotask(() => focusTarget?.focus());
197
+ if (restoreFocus)
198
+ queueMicrotask(() => focusTarget?.focus({ preventScroll: true }));
182
199
  this.#returnFocus = null;
183
200
  }
184
201
 
@@ -208,11 +225,21 @@ export class AppShellMobileController {
208
225
  this.#mainElement = element;
209
226
  }
210
227
 
228
+ /** @internal Return the mounted main landmark. */
229
+ getMainElement(): HTMLElement | null {
230
+ return this.#mainElement;
231
+ }
232
+
211
233
  /** @internal Register the root as a safe focus waypoint between lanes. */
212
234
  setRootElement(element: HTMLElement | null): void {
213
235
  this.#rootElement = element;
214
236
  }
215
237
 
238
+ /** @internal Return the mounted shell root. */
239
+ getRootElement(): HTMLElement | null {
240
+ return this.#rootElement;
241
+ }
242
+
216
243
  #ensureActivePanel(side: AppShellSide): void {
217
244
  const panels = this.panelsFor(side);
218
245
  const current = this.activePanelId(side);
@@ -231,6 +258,7 @@ type AppShellSidebarLayoutChangeSource = Exclude<
231
258
  type AppShellSidebarLayoutChangeListener = (
232
259
  source: AppShellSidebarLayoutChangeSource,
233
260
  ) => void;
261
+ type AppShellProjectionChangeListener = () => void;
234
262
 
235
263
  /** Reactive state for one side of an App Shell. */
236
264
  export class AppShellSidebarController {
@@ -449,6 +477,11 @@ export class AppShellController {
449
477
  readonly right: AppShellSidebarController;
450
478
  readonly mobile = new AppShellMobileController();
451
479
  layoutReady = $state(false);
480
+ constrainedPanelIds = $state<string[]>([]);
481
+ desktopPreviewPanelIds = $state<string[]>([]);
482
+ activeSurfaceLayer = $state<AppShellSurfaceLayerRegistration | null>(null);
483
+ projectionVersion = $state(0);
484
+ panelElementsVersion = $state(0);
452
485
 
453
486
  readonly #persistence: AppShellLayoutPersistence | undefined;
454
487
  readonly #saveDebounceMs: number;
@@ -458,10 +491,14 @@ export class AppShellController {
458
491
  readonly #panels = new Map<string, AppShellSidebarController>();
459
492
  readonly #panelIds = new Map<AppShellSidebarController, string>();
460
493
  readonly #panelDisposers = new Map<string, () => void>();
494
+ readonly #panelElements = new Map<string, HTMLElement>();
495
+ readonly #projectionChangeListeners =
496
+ new Set<AppShellProjectionChangeListener>();
461
497
  #restoredPanels = new Map<string, AppShellSidebarLayout>();
462
498
  #hydrating = false;
463
499
  #restorePromise: Promise<void> | null = null;
464
500
  #saveTimer: ReturnType<typeof setTimeout> | null = null;
501
+ #projectionFrame: number | null = null;
465
502
  #pendingSaveEvent: AppShellLayoutChangeEvent | null = null;
466
503
  #saveChain: Promise<void> = Promise.resolve();
467
504
 
@@ -522,6 +559,133 @@ export class AppShellController {
522
559
  return this.#panelIds.get(sidebar);
523
560
  }
524
561
 
562
+ /** Whether protected desktop width has displaced this panel from inline flow. */
563
+ isPanelConstrained(id: string | undefined): boolean {
564
+ return id !== undefined && this.constrainedPanelIds.includes(id);
565
+ }
566
+
567
+ /** Return the mounted panel's configured constrained presentation. */
568
+ getConstrainedPresentation(
569
+ id: string | undefined,
570
+ ): AppShellConstrainedPresentation {
571
+ if (!id) return "preview";
572
+ return (
573
+ this.mobile.panels.find((panel) => panel.id === id)
574
+ ?.constrainedPresentation ?? "preview"
575
+ );
576
+ }
577
+
578
+ /** Whether a constrained preview panel is currently presented. */
579
+ isPanelDesktopPreviewed(id: string | undefined): boolean {
580
+ return id !== undefined && this.desktopPreviewPanelIds.includes(id);
581
+ }
582
+
583
+ /** Present or dismiss one constrained desktop preview without durable changes. */
584
+ setPanelDesktopPreviewed(id: string, previewed: boolean): void {
585
+ if (!this.isPanelConstrained(id)) previewed = false;
586
+ const current = this.desktopPreviewPanelIds.includes(id);
587
+ if (current === previewed) return;
588
+ this.desktopPreviewPanelIds = previewed
589
+ ? [...this.desktopPreviewPanelIds, id]
590
+ : this.desktopPreviewPanelIds.filter((candidate) => candidate !== id);
591
+ this.#projectionChanged();
592
+ }
593
+
594
+ /** @internal Install the current constrained projection from AppShell.Root. */
595
+ setConstrainedPanelIds(ids: readonly string[]): void {
596
+ const next = [...new Set(ids)];
597
+ if (
598
+ next.length === this.constrainedPanelIds.length &&
599
+ next.every((id, index) => this.constrainedPanelIds[index] === id)
600
+ ) {
601
+ return;
602
+ }
603
+ this.constrainedPanelIds = next;
604
+ this.desktopPreviewPanelIds = this.desktopPreviewPanelIds.filter((id) =>
605
+ next.includes(id),
606
+ );
607
+ this.#projectionChanged();
608
+ }
609
+
610
+ /** @internal Subscribe mounted shell parts to transient projection changes. */
611
+ onProjectionChange(listener: AppShellProjectionChangeListener): () => void {
612
+ this.#projectionChangeListeners.add(listener);
613
+ return () => this.#projectionChangeListeners.delete(listener);
614
+ }
615
+
616
+ /** @internal Register a mounted sidebar landmark for shell geometry. */
617
+ setPanelElement(id: string | undefined, element: HTMLElement | null): void {
618
+ if (!id) return;
619
+ if (element) {
620
+ if (this.#panelElements.get(id) === element) return;
621
+ this.#panelElements.set(id, element);
622
+ this.#syncPanelProjection(id);
623
+ } else {
624
+ if (!this.#panelElements.delete(id)) return;
625
+ }
626
+ this.panelElementsVersion += 1;
627
+ }
628
+
629
+ /** @internal Return a mounted sidebar landmark. */
630
+ getPanelElement(id: string): HTMLElement | null {
631
+ void this.panelElementsVersion;
632
+ return this.#panelElements.get(id) ?? null;
633
+ }
634
+
635
+ /** @internal Reconcile every mounted panel with durable and transient state. */
636
+ syncPanelProjections(): void {
637
+ for (const id of this.#panelElements.keys()) {
638
+ this.#syncPanelProjection(id);
639
+ }
640
+ }
641
+
642
+ /** Whether the active structural layer temporarily removes this panel. */
643
+ isPanelSuspended(id: string | undefined): boolean {
644
+ return (
645
+ id !== undefined &&
646
+ (this.activeSurfaceLayer?.suspendPanelIds.includes(id) ?? false)
647
+ );
648
+ }
649
+
650
+ /** Whether the active structural layer covers this mounted panel. */
651
+ isPanelCovered(id: string | undefined): boolean {
652
+ return (
653
+ id !== undefined &&
654
+ (this.activeSurfaceLayer?.coverPanelIds.includes(id) ?? false)
655
+ );
656
+ }
657
+
658
+ /** Whether a structural layer or replacement panel currently covers main. */
659
+ get mainOccluded(): boolean {
660
+ if (this.activeSurfaceLayer) return true;
661
+ return this.constrainedPanelIds.some((id) => {
662
+ const panel = this.getPanel(id);
663
+ return (
664
+ panel !== undefined &&
665
+ !panel.closed &&
666
+ !this.isPanelSuspended(id) &&
667
+ this.getConstrainedPresentation(id) === "replace-main"
668
+ );
669
+ });
670
+ }
671
+
672
+ /** @internal Activate the root's single structural surface layer. */
673
+ activateSurfaceLayer(registration: AppShellSurfaceLayerRegistration): void {
674
+ const current = this.activeSurfaceLayer;
675
+ if (current && current.token !== registration.token) {
676
+ throw new Error("AppShell.Root supports one active SurfaceLayer.");
677
+ }
678
+ this.activeSurfaceLayer = registration;
679
+ this.#projectionChanged();
680
+ }
681
+
682
+ /** @internal Remove a mounted structural surface layer. */
683
+ deactivateSurfaceLayer(token: symbol): void {
684
+ if (this.activeSurfaceLayer?.token !== token) return;
685
+ this.activeSurfaceLayer = null;
686
+ this.#projectionChanged();
687
+ }
688
+
525
689
  /** Create and register an independently persisted same-side panel. */
526
690
  createSidebar(
527
691
  id: string,
@@ -563,6 +727,15 @@ export class AppShellController {
563
727
  this.#panelDisposers.delete(panelId);
564
728
  this.#panels.delete(panelId);
565
729
  this.#panelIds.delete(sidebar);
730
+ this.#panelElements.delete(panelId);
731
+ this.constrainedPanelIds = this.constrainedPanelIds.filter(
732
+ (id) => id !== panelId,
733
+ );
734
+ this.desktopPreviewPanelIds = this.desktopPreviewPanelIds.filter(
735
+ (id) => id !== panelId,
736
+ );
737
+ this.panelElementsVersion += 1;
738
+ this.#projectionChanged();
566
739
  this.#requestSave({ source: "unregister", panelId });
567
740
  };
568
741
  }
@@ -619,6 +792,18 @@ export class AppShellController {
619
792
  this.#panelDisposers.clear();
620
793
  this.#panels.clear();
621
794
  this.#panelIds.clear();
795
+ this.#panelElements.clear();
796
+ this.constrainedPanelIds = [];
797
+ this.desktopPreviewPanelIds = [];
798
+ this.activeSurfaceLayer = null;
799
+ this.#projectionChangeListeners.clear();
800
+ if (
801
+ this.#projectionFrame !== null &&
802
+ typeof cancelAnimationFrame !== "undefined"
803
+ ) {
804
+ cancelAnimationFrame(this.#projectionFrame);
805
+ this.#projectionFrame = null;
806
+ }
622
807
  }
623
808
 
624
809
  #attachPanel(id: string, sidebar: AppShellSidebarController): void {
@@ -627,6 +812,7 @@ export class AppShellController {
627
812
  this.#panelDisposers.set(
628
813
  id,
629
814
  sidebar.onLayoutChange((source) => {
815
+ this.#projectionChanged();
630
816
  this.#requestSave({ source, panelId: id });
631
817
  }),
632
818
  );
@@ -659,6 +845,64 @@ export class AppShellController {
659
845
  void this.flushSave();
660
846
  }, this.#saveDebounceMs);
661
847
  }
848
+
849
+ #projectionChanged(): void {
850
+ this.projectionVersion += 1;
851
+ for (const listener of this.#projectionChangeListeners) listener();
852
+ const sync = () => {
853
+ this.#projectionFrame = null;
854
+ this.syncPanelProjections();
855
+ };
856
+ if (typeof requestAnimationFrame === "undefined") {
857
+ queueMicrotask(sync);
858
+ return;
859
+ }
860
+ if (this.#projectionFrame !== null)
861
+ cancelAnimationFrame(this.#projectionFrame);
862
+ this.#projectionFrame = requestAnimationFrame(sync);
863
+ }
864
+
865
+ #syncPanelProjection(id: string): void {
866
+ const element = this.#panelElements.get(id);
867
+ const sidebar = this.#panels.get(id);
868
+ if (!element || !sidebar) return;
869
+ const suspended = this.isPanelSuspended(id);
870
+ const covered = suspended || this.isPanelCovered(id);
871
+ const constrained = this.isPanelConstrained(id);
872
+ const mobile = this.mobile.resolvedMode === "mobile";
873
+ const presentation = this.getConstrainedPresentation(id);
874
+ const previewed = this.isPanelDesktopPreviewed(id) || sidebar.previewed;
875
+ const consumerAriaHidden = element.getAttribute(
876
+ "data-consumer-aria-hidden",
877
+ );
878
+
879
+ element.toggleAttribute("data-suspended", suspended);
880
+ element.toggleAttribute("data-desktop-constrained", constrained);
881
+ element.dataset.state = sidebar.state;
882
+ element.dataset.presentation = mobile
883
+ ? "mobile"
884
+ : constrained
885
+ ? previewed
886
+ ? "overlay"
887
+ : presentation === "replace-main"
888
+ ? "replace-main"
889
+ : "inline"
890
+ : "inline";
891
+ element.hidden =
892
+ element.hasAttribute("data-consumer-hidden") ||
893
+ suspended ||
894
+ (!mobile &&
895
+ (sidebar.closed ||
896
+ (constrained && presentation === "preview" && !previewed)));
897
+ element.inert = element.hasAttribute("data-consumer-inert") || covered;
898
+ if (covered || consumerAriaHidden === "true") {
899
+ element.setAttribute("aria-hidden", "true");
900
+ } else if (consumerAriaHidden === null) {
901
+ element.removeAttribute("aria-hidden");
902
+ } else {
903
+ element.setAttribute("aria-hidden", consumerAriaHidden);
904
+ }
905
+ }
662
906
  }
663
907
 
664
908
  function normalizeAppShellLayout(
@@ -12,6 +12,7 @@ export interface AppShellSidebarContext {
12
12
  readonly side: AppShellSide;
13
13
  readonly closeable: boolean;
14
14
  readonly controller: AppShellSidebarController;
15
+ readonly syncLayout: () => void;
15
16
  readonly dismissOverlay: () => void;
16
17
  }
17
18