diff --git a/WORKLOG.md b/WORKLOG.md
index 244bc69d..dfdbd9a2 100644
--- a/WORKLOG.md
+++ b/WORKLOG.md
@@ -34,3 +34,44 @@ Decided to wrap nav and sidenav in semantic HTML elements:
### README.md updated
- Added "Context" section linking to AGENTS.md and WORKLOG.md with descriptions.
+
+## 2026-04-02
+
+### nx2 `blocks/panel/` (app-frame side panels)
+- Added `panel.js`: Lit `nx-panel` (shadow shell, default slot, resize handle in shadow), `createPanel` / `showPanel` (`{ width, beforeMain }`), `setPanelsGrid` for app-frame column/area CSS vars. Shell is `aside.panel` with `data-position` before/after main; `createPanel` / `showPanel` return the `nx-panel` element. Empty `aside` after removing `nx-panel` is dropped in `disconnectedCallback`.
+- `decorate(block)`: if the block has an anchor → `loadFragment(a.href)` → `createPanel`, move fragment children onto `nx-panel` with DOM APIs, remove the block.
+- Styling split: `styles.css` keeps app-frame grid (`--app-frame-*`, `body.app-frame` row); `panel.css` holds panel surface and resize affordance.
+- Mobile-first: default `body.app-frame` uses fixed panel insets + `:has(aside.panel)::before` scrim; `@media (width >= 600px)` restores grid layout and clears modal positioning. `setPanelsGrid` always sets `--app-frame-*` (only applied at 600px+).
+
+## 2026-04-03
+
+### utils.js rewrite — multi-environment DA service config
+- Replaced stub `DA_ORIGIN`/`daFetch` exports with real environment-aware origins for DA services (admin, collab, content, preview, etc.).
+- `getEnv(key, envs)` resolves origin per service: checks query param → localStorage → default (stage for dev/stage, prod for prod).
+- Removed `HashController` reactive controller; sidenav no longer uses it.
+- `parseWindowPath` now returns `null` for missing/invalid hashes and strips trailing `/index` from hash.
+
+### New api.js — extracted API layer
+- `daFetch` handles auth token injection, checks URL against `ALLOWED_TOKEN` origins before attaching bearer.
+- `ping`, `source`, `list`, `signout` — thin wrappers for DA/AEM endpoints.
+- Profile block now imports `signout` from api.js instead of inlining the fetch.
+
+### CSS: class selectors → meta-content selectors
+- Spectrum Edge and app-frame layouts no longer rely on JS adding classes (`spectrum-edge`, `app-frame`).
+- Replaced with `html:has(meta[content="edge-delivery"])` and `html:has(meta[content="app-frame"])` — pure CSS, no JS decoration needed.
+- Removed `spectrum-edge` class addition from `decorateDoc` in nx.js.
+- App-frame grid extracted to its own top-level rule block.
+
+### profile.js — handleScheme simplification
+- Color scheme toggle simplified: remove both classes, add the toggled one. No intermediate object.
+
+### AGENTS.md — "parse, don't validate" convention
+- Added to JS conventions section. Core idea: push validation to the boundary where data enters, return `null` or a well-formed result — no ambiguous middle ground. Downstream code trusts the shape without re-checking.
+- Codifies the distinct meaning of `null` (absent), `undefined` (not yet loaded), and `''` (explicitly cleared).
+- `parseWindowPath` is the canonical example: returns a clean `{ view, org, site, path }` or `null`.
+## 2026-04-04
+
+### Panel-aware default-content max-width
+- When either side panel is visible (`aside.panel:not([hidden])`), `.default-content` inside `main` now uses `max-width: 83.4%` instead of the fixed `--se-grid-container-width` value.
+- Uses sibling selectors: `main:has(~ aside.panel:not([hidden]))` for panels after main, `aside.panel:not([hidden]) ~ main` for panels before main.
+- The fixed `1200px` media query (`@media (width >= 1440px)`) remains for the no-panel case.
diff --git a/nx2/blocks/action-button/action-button.js b/nx2/blocks/action-button/action-button.js
new file mode 100644
index 00000000..fc961ba0
--- /dev/null
+++ b/nx2/blocks/action-button/action-button.js
@@ -0,0 +1,43 @@
+async function togglePanel(position) {
+ const existing = document.querySelector(`aside.panel[data-position="${position}"]`);
+ if (!existing) return false;
+ const { hidePanel, unhidePanel } = await import('../../utils/panel.js');
+ if (existing.hidden) unhidePanel(existing);
+ else hidePanel(existing);
+ return true;
+}
+
+async function loadPanelContent(value) {
+ if (value.includes('/fragments/')) {
+ const { loadFragment } = await import('../fragment/fragment.js');
+ return { content: await loadFragment(value), fragment: value };
+ }
+ const mod = await import(`../../../nx/blocks/${value}/${value}.js`);
+ return { content: await mod.getPanel() };
+}
+
+function decoratePanel(a, hash) {
+ const match = hash.match(/^#_(before|after)=(.+)$/);
+ if (!match) return;
+ const [, position, value] = match;
+ const beforeMain = position === 'before';
+
+ a.addEventListener('click', async (e) => {
+ e.preventDefault();
+ if (await togglePanel(position)) return;
+ const { content, fragment } = await loadPanelContent(value);
+ if (!content) return;
+ const { showPanel } = await import('../../utils/panel.js');
+ showPanel({ width: '400px', beforeMain, content, fragment });
+ });
+}
+
+const ACTIONS = [
+ { pathname: '/tools/widgets/panel', handler: decoratePanel },
+];
+
+export default async function decorate(a) {
+ const action = ACTIONS.find((entry) => entry.pathname === a.pathname);
+ if (!action) return;
+ action.handler(a, a.hash);
+}
diff --git a/nx2/blocks/panel/panel.js b/nx2/blocks/panel/panel.js
new file mode 100644
index 00000000..d662ca7f
--- /dev/null
+++ b/nx2/blocks/panel/panel.js
@@ -0,0 +1,166 @@
+import { LitElement, html } from 'da-lit';
+
+import { loadFragment } from '../fragment/fragment.js';
+import { loadStyle } from '../../utils/utils.js';
+
+const style = await loadStyle(import.meta.url);
+
+// Computes css variables to define grid areas
+export function setPanelsGrid() {
+ const { body } = document;
+ if (!body.classList.contains('app-frame')) return;
+
+ const beforeMain = [...body.querySelectorAll('aside.panel[data-position="before"]')];
+ const afterMain = [...body.querySelectorAll('aside.panel[data-position="after"]')];
+
+ beforeMain.forEach((el, i) => { el.style.gridArea = `nx-panel-before-${i}`; });
+ afterMain.forEach((el, i) => { el.style.gridArea = `nx-panel-after-${i}`; });
+
+ const colCount = 1 + beforeMain.length + 1 + afterMain.length;
+ const headerRow = Array(colCount).fill('header').join(' ');
+ const contentRow = [
+ 'sidenav',
+ ...beforeMain.map((_, i) => `nx-panel-before-${i}`),
+ 'main',
+ ...afterMain.map((_, i) => `nx-panel-after-${i}`),
+ ].join(' ');
+
+ const getWidth = (el) => {
+ const w = el.dataset.width?.trim();
+ return w ? `min(${w}, 40vw)` : 'minmax(0, auto)';
+ };
+ const columns = [
+ 'var(--s2-nav-width)',
+ ...beforeMain.map(getWidth),
+ '1fr',
+ ...afterMain.map(getWidth),
+ ].join(' ');
+
+ body.style.setProperty('--app-frame-areas', `"${headerRow}" var(--s2-nav-height) "${contentRow}" 1fr`);
+ body.style.setProperty('--app-frame-columns', columns);
+}
+
+const PANEL_WIDTH_MIN = 120;
+const PANEL_WIDTH_MAX = () => Math.min(1600, window.innerWidth * 0.4);
+
+function parsePanelWidth(aside) {
+ const w = aside.dataset.width?.trim();
+ if (w && /^\d+(\.\d+)?px$/i.test(w)) return parseFloat(w);
+ return aside.getBoundingClientRect().width;
+}
+
+function applyPanelWidth(aside, px) {
+ aside.dataset.width = `${Math.max(PANEL_WIDTH_MIN, Math.min(PANEL_WIDTH_MAX(), Math.round(px)))}px`;
+}
+
+class NXPanel extends LitElement {
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [style];
+ this._panelAside = this.closest('aside.panel');
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ const aside = this._panelAside;
+ this._panelAside = undefined;
+ if (aside?.isConnected && aside.childElementCount === 0) {
+ aside.remove();
+ }
+ setPanelsGrid();
+ }
+
+ _resizePointerDown(downEvent) {
+ const aside = this.closest('aside.panel');
+ if (!aside || downEvent.button !== 0) return;
+ const deltaSign = aside.dataset.position === 'before' ? 1 : -1;
+
+ const handle = downEvent.currentTarget;
+ handle.setPointerCapture(downEvent.pointerId);
+ const startX = downEvent.clientX;
+ const startW = parsePanelWidth(aside);
+ const prevUserSelect = document.body.style.userSelect;
+ document.body.style.userSelect = 'none';
+
+ const onPointerMove = (moveEvent) => {
+ const dx = moveEvent.clientX - startX;
+ applyPanelWidth(aside, startW + deltaSign * dx);
+ setPanelsGrid();
+ };
+
+ const onPointerUp = (upEvent) => {
+ handle.releasePointerCapture(upEvent.pointerId);
+ document.body.style.userSelect = prevUserSelect;
+ handle.removeEventListener('pointermove', onPointerMove);
+ handle.removeEventListener('pointerup', onPointerUp);
+ handle.removeEventListener('pointercancel', onPointerUp);
+ };
+
+ handle.addEventListener('pointermove', onPointerMove);
+ handle.addEventListener('pointerup', onPointerUp);
+ handle.addEventListener('pointercancel', onPointerUp);
+ }
+
+ render() {
+ const aside = this.closest('aside.panel');
+ const edge = aside?.dataset.position === 'before' ? 'trailing' : 'leading';
+
+ return html`
+
+
+ `;
+ }
+}
+
+customElements.define('nx-panel', NXPanel);
+
+function createPanel({ width, beforeMain }) {
+ const aside = document.createElement('aside');
+ aside.classList.add('panel');
+ aside.dataset.width = width;
+ aside.dataset.position = beforeMain ? 'before' : 'after';
+
+ const nx = document.createElement('nx-panel');
+ aside.append(nx);
+
+ if (beforeMain) {
+ document.querySelector('main').before(aside);
+ } else {
+ document.querySelector('main').after(aside);
+ }
+
+ return nx;
+}
+
+export function showPanel({ width = '400px', beforeMain = false } = {}) {
+ const nx = createPanel({ width, beforeMain });
+ setPanelsGrid();
+ return nx;
+}
+
+export default async function decorate(block) {
+ const a = block.querySelector('a');
+ if (!a) return;
+
+ const fragment = await loadFragment(a.href);
+
+ const nx = createPanel({ width: '400px', beforeMain: false });
+ if (fragment && nx) {
+ nx.replaceChildren();
+ while (fragment.firstChild) {
+ nx.appendChild(fragment.firstChild);
+ }
+ }
+
+ block.remove();
+ setPanelsGrid();
+}
diff --git a/nx2/scripts/nx.js b/nx2/scripts/nx.js
index c4ed0754..2310ca8a 100644
--- a/nx2/scripts/nx.js
+++ b/nx2/scripts/nx.js
@@ -24,7 +24,7 @@ export function getLocale(locales) {
return { key, ...locales[key] };
}
-const env = (() => {
+export const env = (() => {
const { host } = window.location;
if (host.endsWith('.aem.live')) return 'prod';
if (!['--', 'local'].some((check) => host.includes(check))) return 'prod';
@@ -141,10 +141,10 @@ export function decorateLink(config, a) {
const { dnb } = decorateHash(a, url);
if (!dnb) {
- const { href, hash } = a;
+ const { pathname, hash } = a;
const found = config.linkBlocks.some((pattern) => {
const key = Object.keys(pattern)[0];
- if (!href.includes(pattern[key])) return false;
+ if (!pathname.includes(pattern[key])) return false;
const blockName = key === 'fragment' && hash ? 'dialog' : key;
a.classList.add(blockName, 'auto-block');
return true;
@@ -248,10 +248,14 @@ function loadSession() {
document.body.classList.add('session');
}
-function decorateDoc() {
- decorateNav();
+async function decorateDoc() {
+ // Fast track IMS if returning from sign in
+ if (window.location.hash.startsWith('#old_hash')) {
+ const { loadIms } = await import('../utils/ims.js');
+ await loadIms();
+ }
- document.documentElement.classList.add('spectrum-edge');
+ decorateNav();
const template = getMetadata('template');
if (template) document.body.classList.add(template);
@@ -261,12 +265,17 @@ function decorateDoc() {
const pageId = window.location.hash?.replace('#', '');
if (pageId) localStorage.setItem('lazyhash', pageId);
+
+ if (localStorage.getItem('nx-panels')) {
+ const { restorePanels } = await import('../utils/panel.js');
+ await restorePanels();
+ }
}
export async function loadArea({ area } = { area: document }) {
const isDoc = area === document;
const isSession = sessionStorage.getItem('session');
- if (isDoc) decorateDoc();
+ if (isDoc) await decorateDoc();
await decoratePlaceholders(area, isDoc);
decoratePictures(area);
const { decorateArea } = getConfig();
diff --git a/nx2/scripts/scripts.js b/nx2/scripts/scripts.js
index 6a2dfdcd..8f069f3e 100644
--- a/nx2/scripts/scripts.js
+++ b/nx2/scripts/scripts.js
@@ -22,6 +22,7 @@ const locales = {
const linkBlocks = [
{ fragment: '/fragments/' },
+ { 'action-button': '/tools/widgets/panel' },
];
const imsClientId = 'nexter';
diff --git a/nx2/styles/styles.css b/nx2/styles/styles.css
index 90fdd144..e4dd3c9d 100644
--- a/nx2/styles/styles.css
+++ b/nx2/styles/styles.css
@@ -308,8 +308,10 @@
}
}
-/* --- Edge Delivery-specific decoration hiding --- */
+/* --- Spectrum Edge pre-decoration hiding --- */
html:has(meta[content="edge-delivery"]) {
+ display: block;
+
main > div, div[data-status] {
display: none;
}
@@ -322,11 +324,16 @@ html:has(meta[content="edge-delivery"]) {
margin: 0 auto;
}
}
+
+ main:has(~ aside.panel:not([hidden])) .default-content,
+ aside.panel:not([hidden]) ~ main .default-content {
+ max-width: 83.4%;
+ }
}
-/* --- Spectrum Eco default styles --- */
+/* --- Spectrum Edge default styles --- */
@layer spectrum-edge {
- html.spectrum-edge {
+ html:has(meta[content="edge-delivery"]) {
body {
margin: 0;
background-color: light-dark(#fff, #000);
@@ -343,7 +350,6 @@ html:has(meta[content="edge-delivery"]) {
display: none;
}
- /* Don't let fonts load unless there's a session */
&.session {
font-family: var(--s2-font-family);
}
@@ -351,31 +357,6 @@ html:has(meta[content="edge-delivery"]) {
&.no-header {
--s2-nav-height: 0;
}
-
- /* Everything for the app frame */
- &.app-frame {
- display: grid;
- height: 100dvh;
- grid-template:
- "header" var(--s2-nav-height)
- "main" 1fr;
- background-color: light-dark(rgb(240 240 240), rgb(17 17 17));
-
- header {
- grid-area: header;
- }
-
- nav {
- grid-area: sidenav;
- }
-
- main {
- grid-area: main;
- background-color: light-dark(#fff, #000);
- overflow-y: scroll;
- max-height: 100%;
- }
- }
}
h1 { font-size: var(--s2-heading-size-xxl); }
@@ -434,23 +415,198 @@ html:has(meta[content="edge-delivery"]) {
}
}
+ html:has(meta[content="app-frame"]) {
+ body {
+ display: grid;
+ height: 100vh;
+ grid-template:
+ "header" var(--s2-nav-height)
+ "main" 1fr;
+ background-color: light-dark(rgb(240 240 240), rgb(17 17 17));
+
+ header {
+ grid-area: header;
+ }
+
+ nav {
+ grid-area: sidenav;
+ }
+
+ main {
+ grid-area: main;
+ background-color: light-dark(#fff, #000);
+ max-height: 100%;
+ }
+
+ .section {
+ &.container {
+ .default-content, .block-content {
+ max-width: var(--section-container-width);
+ margin-inline: auto;
+ }
+ }
+ }
+
+ &:has(aside.panel)::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ background-color: light-dark(rgb(0 0 0 / 40%), rgb(0 0 0 / 55%));
+ pointer-events: auto;
+ }
+
+ aside.panel {
+ position: fixed;
+ inset: calc(var(--s2-nav-height) + 12px) 12px 12px;
+ z-index: 70;
+ width: auto;
+ height: auto;
+ margin: 0;
+ max-height: none;
+
+ .panel-wrapper {
+ --panel-bottom-margin: 12px;
+
+ display: block;
+ position: relative;
+ box-sizing: border-box;
+ height: 100%;
+ max-height: 100%;
+ margin: 0;
+ border-radius: 24px;
+ background-color: light-dark(#fff, #000);
+
+ .panel-shell {
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ min-height: 0;
+ height: 100%;
+ border-radius: inherit;
+ overflow: hidden;
+ }
+
+ .panel-body {
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ }
+
+ .panel-body h2 {
+ flex-shrink: 0;
+ margin: 12px 0 8px;
+ font-size: var(--s2-heading-size-xl, 1.25rem);
+ font-weight: var(--s2-heading-font-weight, 700);
+ }
+
+ .panel-resize-handle {
+ display: none;
+ position: absolute;
+ top: 50%;
+ translate: 0 -50%;
+ z-index: 2;
+ box-sizing: border-box;
+ width: 12px;
+ height: 56px;
+ padding: 0;
+ margin: 0;
+ border: none;
+ cursor: ew-resize;
+ touch-action: none;
+ background: transparent;
+ color: inherit;
+ }
+
+ .panel-resize-handle::before {
+ content: '';
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ translate: -50% -50%;
+ width: 4px;
+ height: 48px;
+ border-radius: 999px;
+ background-color: var(--s2-gray-600);
+ }
+
+ .panel-resize-handle:hover::before,
+ .panel-resize-handle:focus-visible::before {
+ background-color: var(--s2-gray-900);
+ }
+
+ .panel-resize-handle:focus-visible {
+ outline: 2px solid var(--s2-blue-800);
+ outline-offset: 2px;
+ }
+
+ .panel-resize-handle-trailing {
+ right: -12px;
+ }
+
+ .panel-resize-handle-leading {
+ left: -12px;
+ }
+ }
+ }
+
+ footer {
+ display: none;
+ }
+ }
+
+ helix-sidekick, aem-sidekick {
+ display: none !important;
+ }
+ }
+
@media (width >= 600px) {
- html.spectrum-edge {
- body.app-frame {
+ :root {
+ --app-frame-areas: "header header" var(--s2-nav-height) "sidenav main" 1fr;
+ --app-frame-columns: var(--s2-nav-width) 1fr;
+ }
+
+ html:has(meta[content="app-frame"]) {
+ body {
grid-template:
"header header" var(--s2-nav-height)
"sidenav main" 1fr / var(--s2-nav-width) 1fr;
- height: 100dvh;
+ grid-template: var(--app-frame-areas) / var(--app-frame-columns);
+ height: 100vh;
nav {
display: unset;
}
main {
+ display: grid;
margin: 0 12px 0 0;
- border-radius: 32px 32px 0 0;
- overflow-y: scroll;
+ border-radius: var(--s2-corner-radius-800) var(--s2-corner-radius-800) 0 0;
max-height: 100%;
+ overflow-y: auto;
+
+ }
+
+ &:has(aside.panel)::before {
+ display: none;
+ pointer-events: none;
+ }
+
+ aside.panel {
+ position: static;
+
+ .panel-wrapper {
+ height: calc(100vh - var(--s2-nav-height) - var(--panel-bottom-margin));
+ max-height: none;
+ margin: 0 12px var(--panel-bottom-margin) 0;
+ border-radius: var(--s2-corner-radius-800);
+
+ .panel-resize-handle {
+ display: block;
+ }
+ }
}
}
}
diff --git a/nx2/utils/panel.js b/nx2/utils/panel.js
new file mode 100644
index 00000000..7091aec5
--- /dev/null
+++ b/nx2/utils/panel.js
@@ -0,0 +1,194 @@
+import { getMetadata } from '../scripts/nx.js';
+
+const PANEL_WIDTH_MIN = 120;
+const PANEL_WIDTH_MAX = () => Math.min(1600, window.innerWidth * 0.4);
+
+function parsePanelWidth(aside) {
+ const w = aside.dataset.width?.trim();
+ if (w && /^\d+(\.\d+)?px$/i.test(w)) return parseFloat(w);
+ return aside.getBoundingClientRect().width;
+}
+
+function applyPanelWidth(aside, px) {
+ const clamped = `${Math.max(PANEL_WIDTH_MIN, Math.min(PANEL_WIDTH_MAX(), Math.round(px)))}px`;
+ aside.dataset.width = clamped;
+ aside.style.width = clamped;
+}
+
+const PANEL_STORAGE_KEY = 'nx-panels';
+
+function getPanelStore() {
+ try {
+ return JSON.parse(localStorage.getItem(PANEL_STORAGE_KEY)) || {};
+ } catch {
+ return {};
+ }
+}
+
+function savePanelState(position, { width, fragment }) {
+ const store = getPanelStore();
+ store[position] = { width, fragment };
+ localStorage.setItem(PANEL_STORAGE_KEY, JSON.stringify(store));
+}
+
+function removePanelState(position) {
+ const store = getPanelStore();
+ delete store[position];
+ localStorage.setItem(PANEL_STORAGE_KEY, JSON.stringify(store));
+}
+
+export function setPanelsGrid() {
+ const { body } = document;
+ if (getMetadata('template') !== 'app-frame') return;
+
+ const before = body.querySelector('aside.panel[data-position="before"]:not([hidden])');
+ const after = body.querySelector('aside.panel[data-position="after"]:not([hidden])');
+
+ const getWidth = (el) => {
+ const w = el?.dataset.width?.trim();
+ return w ? `min(${w}, 40vw)` : 'minmax(0, auto)';
+ };
+
+ const header = ['header'];
+ const content = ['sidenav'];
+ const columns = ['var(--s2-nav-width)'];
+
+ if (before) {
+ before.style.gridArea = 'panel-before';
+ header.push('header');
+ content.push('panel-before');
+ columns.push(getWidth(before));
+ }
+
+ header.push('header');
+ content.push('main');
+ columns.push('1fr');
+
+ if (after) {
+ after.style.gridArea = 'panel-after';
+ header.push('header');
+ content.push('panel-after');
+ columns.push(getWidth(after));
+ }
+
+ body.style.setProperty('--app-frame-areas', `"${header.join(' ')}" var(--s2-nav-height) "${content.join(' ')}" 1fr`);
+ body.style.setProperty('--app-frame-columns', columns.join(' '));
+}
+
+function resizePointerDown(downEvent) {
+ const handle = downEvent.currentTarget;
+ const aside = handle.closest('aside.panel');
+ if (!aside || downEvent.button !== 0) return;
+ const deltaSign = aside.dataset.position === 'before' ? 1 : -1;
+
+ handle.setPointerCapture(downEvent.pointerId);
+ const startX = downEvent.clientX;
+ const startW = parsePanelWidth(aside);
+ const prevUserSelect = document.body.style.userSelect;
+ document.body.style.userSelect = 'none';
+
+ const onPointerMove = (moveEvent) => {
+ const dx = moveEvent.clientX - startX;
+ applyPanelWidth(aside, startW + deltaSign * dx);
+ setPanelsGrid();
+ };
+
+ const onPointerUp = (upEvent) => {
+ handle.releasePointerCapture(upEvent.pointerId);
+ document.body.style.userSelect = prevUserSelect;
+ handle.removeEventListener('pointermove', onPointerMove);
+ handle.removeEventListener('pointerup', onPointerUp);
+ handle.removeEventListener('pointercancel', onPointerUp);
+ savePanelState(aside.dataset.position, {
+ width: aside.dataset.width,
+ fragment: aside.dataset.fragment,
+ });
+ };
+
+ handle.addEventListener('pointermove', onPointerMove);
+ handle.addEventListener('pointerup', onPointerUp);
+ handle.addEventListener('pointercancel', onPointerUp);
+}
+
+function buildPanelDOM(aside) {
+ const edge = aside.dataset.position === 'before' ? 'trailing' : 'leading';
+
+ const wrapper = document.createElement('div');
+ wrapper.className = 'panel-wrapper';
+
+ const shell = document.createElement('div');
+ shell.className = 'panel-shell';
+
+ const body = document.createElement('div');
+ body.className = 'panel-body';
+
+ const handle = document.createElement('button');
+ handle.type = 'button';
+ handle.className = `panel-resize-handle panel-resize-handle-${edge}`;
+ handle.setAttribute('aria-label', 'Resize panel');
+ handle.addEventListener('pointerdown', resizePointerDown);
+
+ shell.append(body);
+ wrapper.append(shell, handle);
+ aside.append(wrapper);
+}
+
+export function createPanel({ width = '400px', beforeMain = false, content, fragment } = {}) {
+ const aside = document.createElement('aside');
+ aside.classList.add('panel');
+ aside.dataset.width = width;
+ aside.style.width = width;
+ const position = beforeMain ? 'before' : 'after';
+ aside.dataset.position = position;
+ if (fragment) aside.dataset.fragment = fragment;
+
+ buildPanelDOM(aside);
+
+ if (content) aside.querySelector('.panel-body').append(content);
+
+ savePanelState(position, { width, fragment });
+
+ if (beforeMain) {
+ document.querySelector('main').before(aside);
+ } else {
+ document.querySelector('main').after(aside);
+ }
+
+ return aside;
+}
+
+export function hidePanel(aside) {
+ removePanelState(aside.dataset.position);
+ aside.hidden = true;
+ setPanelsGrid();
+}
+
+export function unhidePanel(aside) {
+ aside.hidden = false;
+ savePanelState(aside.dataset.position, {
+ width: aside.dataset.width,
+ fragment: aside.dataset.fragment,
+ });
+ setPanelsGrid();
+}
+
+export { getPanelStore };
+
+export function showPanel(opts) {
+ const aside = createPanel(opts);
+ setPanelsGrid();
+ return aside;
+}
+
+export async function restorePanels() {
+ const panels = getPanelStore();
+ if (!panels.before && !panels.after) return;
+ const { loadFragment } = await import('../blocks/fragment/fragment.js');
+ for (const [position, { width, fragment }] of Object.entries(panels)) {
+ const content = await loadFragment(fragment);
+ if (content) {
+ const beforeMain = position === 'before';
+ showPanel({ width, beforeMain, content, fragment });
+ }
+ }
+}