diff --git a/nx/blocks/nav/nav.js b/nx/blocks/nav/nav.js
index 01e84e81..34905439 100644
--- a/nx/blocks/nav/nav.js
+++ b/nx/blocks/nav/nav.js
@@ -5,10 +5,17 @@ import getSvg from '../../utils/svg.js';
const { nxBase } = getConfig();
const ICONS = [
- `${nxBase}/img/logos/aec.svg`,
`${nxBase}/img/icons/S2IconHelp20N-icon.svg`,
];
+async function loadBrandLogoSvg() {
+ const resp = await fetch(`${nxBase}/img/logos/adobe-branding.svg`);
+ if (!resp.ok) return null;
+ const text = await resp.text();
+ const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
+ return doc.querySelector('svg');
+}
+
function getDefaultPath() {
const { origin } = new URL(import.meta.url);
return `${origin}/fragments/nx-nav`;
@@ -34,11 +41,17 @@ class Nav extends HTMLElement {
await loadArea(doc.body);
const sections = doc.querySelectorAll('body > .section');
- // Grab the first link as it will be the main branding
const brandLink = doc.querySelector('a');
- brandLink.innerHTML = `${brandLink.innerHTML}`;
brandLink.classList.add('nx-nav-brand');
- brandLink.insertAdjacentHTML('afterbegin', '');
+ brandLink.setAttribute('aria-label', 'Home');
+ brandLink.textContent = '';
+ const brandSvg = await loadBrandLogoSvg();
+ if (brandSvg) {
+ const svg = brandSvg.cloneNode(true);
+ svg.classList.add('icon');
+ svg.setAttribute('aria-hidden', 'true');
+ brandLink.append(svg);
+ }
const inner = document.createElement('div');
inner.className = 'nx-nav-inner';
diff --git a/nx/img/logos/adobe-branding.svg b/nx/img/logos/adobe-branding.svg
new file mode 100644
index 00000000..c6c5a461
--- /dev/null
+++ b/nx/img/logos/adobe-branding.svg
@@ -0,0 +1,4 @@
+
diff --git a/nx2/blocks/action-button/action-button.js b/nx2/blocks/action-button/action-button.js
index 13966cf7..0991ec48 100644
--- a/nx2/blocks/action-button/action-button.js
+++ b/nx2/blocks/action-button/action-button.js
@@ -13,7 +13,11 @@ function decoratePanel(a, hash) {
else panel.hidePanel(existing);
return;
}
- await panel.openPanelWithFragment({ width: '400px', beforeMain, fragment: value });
+ await panel.openPanelWithFragment({
+ width: panel.getDefaultPanelWidthCss(),
+ beforeMain,
+ fragment: value,
+ });
});
}
diff --git a/nx2/blocks/canvas-actions/canvas-actions.css b/nx2/blocks/canvas-actions/canvas-actions.css
index baf4b248..581a7e30 100644
--- a/nx2/blocks/canvas-actions/canvas-actions.css
+++ b/nx2/blocks/canvas-actions/canvas-actions.css
@@ -6,7 +6,14 @@
color: var(--s2-gray-800);
}
-.canvas-actions button {
+.canvas-actions {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 12px;
+}
+
+.canvas-actions .publish-btn {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -25,22 +32,21 @@
cursor: pointer;
}
-.canvas-actions button:focus-visible {
+.canvas-actions .publish-btn:focus-visible {
outline: 2px solid var(--s2-blue-800);
outline-offset: 2px;
}
-.canvas-actions button:disabled {
+.canvas-actions .publish-btn:disabled {
color: var(--s2-gray-400);
background-color: var(--s2-gray-200);
cursor: not-allowed;
}
-.canvas-actions button:hover:not(:disabled) {
+.canvas-actions .publish-btn:hover:not(:disabled) {
background-color: var(--s2-blue-1000);
}
-.canvas-actions button:active:not(:disabled) {
+.canvas-actions .publish-btn:active:not(:disabled) {
background-color: var(--s2-blue-1100);
}
-
diff --git a/nx2/blocks/canvas-actions/canvas-actions.js b/nx2/blocks/canvas-actions/canvas-actions.js
index 87ac73a3..20e59e54 100644
--- a/nx2/blocks/canvas-actions/canvas-actions.js
+++ b/nx2/blocks/canvas-actions/canvas-actions.js
@@ -13,7 +13,7 @@ class NXCanvasActions extends LitElement {
render() {
return html`
-
+
`;
}
diff --git a/nx2/blocks/canvas/canvas.js b/nx2/blocks/canvas/canvas.js
index 656e263b..73e79ebc 100644
--- a/nx2/blocks/canvas/canvas.js
+++ b/nx2/blocks/canvas/canvas.js
@@ -1,5 +1,10 @@
import { loadStyle } from '../../utils/utils.js';
-import { hidePanel, unhidePanel, openPanelWithFragment } from '../../utils/panel.js';
+import {
+ getDefaultPanelWidthCss,
+ hidePanel,
+ unhidePanel,
+ openPanelWithFragment,
+} from '../../utils/panel.js';
import './nx-canvas-header/nx-canvas-header.js';
const style = await loadStyle(import.meta.url);
@@ -30,7 +35,7 @@ async function openCanvasPanel(position) {
// Case 3: Panel does not exist yet
const aside = await openPanelWithFragment({
- width: '400px',
+ width: getDefaultPanelWidthCss(),
beforeMain: position === 'before',
fragment: FRAGMENTS[position],
});
diff --git a/nx2/blocks/nav/nav.css b/nx2/blocks/nav/nav.css
index e74ec930..3c5dfa88 100644
--- a/nx2/blocks/nav/nav.css
+++ b/nx2/blocks/nav/nav.css
@@ -16,21 +16,9 @@
}
}
-.brand-area {
- a {
- display: block;
- height: 100%;
- color: inherit;
- text-decoration: none;
- }
-
- svg {
- height: 100%;
- }
-}
-
.action-area {
display: none;
+ flex-shrink: 0;
ul {
height: 100%;
@@ -84,3 +72,138 @@
display: unset;
}
}
+
+.brand-area {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ flex: 1;
+ gap: 0;
+
+ a {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ color: inherit;
+ text-decoration: none;
+ }
+
+ /* Logo link — 12px to breadcrumb */
+ > a {
+ flex-shrink: 0;
+ margin-inline-end: 12px;
+ }
+
+ a > svg {
+ width: 24px;
+ height: 24px;
+ max-width: 24px;
+ max-height: 24px;
+ flex-shrink: 0;
+ display: block;
+ box-sizing: border-box;
+ }
+
+ .nav-home-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ margin: 0;
+ margin-inline: 16px 12px;
+ border: none;
+ border-radius: var(--s2-corner-radius-500);
+ background: transparent;
+ color: var(--s2-gray-800);
+ cursor: pointer;
+ }
+
+ .nav-home-btn:hover {
+ background-color: var(--s2-gray-100);
+ }
+
+ .nav-home-btn:focus-visible {
+ outline: 2px solid var(--s2-blue-800);
+ outline-offset: 2px;
+ }
+
+ .nav-home-btn svg {
+ display: block;
+ }
+}
+
+.workspace-breadcrumb {
+ min-width: 0;
+ overflow: hidden;
+
+ /* Typography: Component M, Regular (S2 component tokens) */
+
+ /* Color: content / neutral-subdued — default --s2-gray-700, hover --s2-gray-800 */
+ ol {
+ display: flex;
+ align-items: center;
+ flex-wrap: nowrap;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ gap: 0;
+ font-family: var(--s2-font-family);
+ font-size: var(--s2-component-m-regular-font-size);
+ line-height: var(--s2-component-m-regular-line-height);
+ font-weight: var(--s2-component-m-regular-font-weight);
+ color: var(--s2-gray-700);
+ }
+
+ .crumb {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ }
+
+ .crumb-separator {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-inline: 4px 6px;
+ list-style: none;
+
+ /* design #505050 — matches --s2-gray-700 in light; token in dark */
+ color: var(--s2-gray-700);
+ pointer-events: none;
+ user-select: none;
+ }
+
+ .crumb-chevron {
+ display: block;
+ transform: translateY(1px);
+ }
+
+ .crumb-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ font-weight: inherit;
+ color: var(--s2-gray-700);
+ text-decoration: none;
+ }
+
+ a.crumb-label:hover {
+ color: var(--s2-gray-800);
+ }
+
+ .crumb-label.current {
+ cursor: default;
+ color: var(--s2-gray-800);
+ font-weight: var(--s2-component-m-bold-font-weight);
+ }
+
+}
diff --git a/nx2/blocks/nav/nav.js b/nx2/blocks/nav/nav.js
index f019970f..7d9830d3 100644
--- a/nx2/blocks/nav/nav.js
+++ b/nx2/blocks/nav/nav.js
@@ -1,19 +1,65 @@
-import { LitElement, html } from 'da-lit';
+import { LitElement, html, nothing } from 'da-lit';
import { getMetadata } from '../../scripts/nx.js';
-import { loadStyle } from '../../utils/utils.js';
+import { loadStyle, HashController } from '../../utils/utils.js';
import { loadFragment } from '../fragment/fragment.js';
-import { loadHrefSvg } from '../../utils/svg.js';
+import { loadHrefSvg, ICONS_BASE } from '../../utils/svg.js';
+
+const HOME_ICON_HREF = `${ICONS_BASE}S2_Icon_Home_20_N.svg`;
const DEFAULT_NAV_PATH = '/nx/fragments/nav';
const style = await loadStyle(import.meta.url);
+/** Resolve against this module so ?nx=local fetches from the Nexter dev origin, not da.live. */
+function getBrandLogoHref() {
+ return new URL('../../../nx/img/logos/adobe-branding.svg', import.meta.url).href;
+}
+
+function normalizeBrandSvg(svg) {
+ if (!svg) return;
+ svg.setAttribute('width', '24');
+ svg.setAttribute('height', '24');
+ svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
+}
+
+/** Hash segments after `#/`: org, site, then path parts → Folder > Folder > Page */
+function workspaceBreadcrumbSegments(state) {
+ if (!state?.org) return [];
+ const segments = [state.org];
+ if (state.site) segments.push(state.site);
+ if (state.path) {
+ segments.push(...state.path.split('/').filter(Boolean));
+ }
+ return segments;
+}
+
+function formatBreadcrumbLabel(raw, isLast) {
+ try {
+ const decoded = decodeURIComponent(raw);
+ if (isLast && decoded.endsWith('.html')) return decoded.slice(0, -5);
+ return decoded;
+ } catch {
+ return raw;
+ }
+}
+
+/** Chevron between breadcrumb segments; fill uses currentColor (see `.crumb-separator`). */
+const BREADCRUMB_CHEVRON = html`
+
+`;
+
class NXNav extends LitElement {
+ details = new HashController(this);
+
static properties = {
path: { attribute: false },
_brand: { state: true },
_actions: { state: true },
+ _homeIcon: { state: true },
};
connectedCallback() {
@@ -22,6 +68,16 @@ class NXNav extends LitElement {
this.loadNav();
}
+ async firstUpdated() {
+ const svg = await loadHrefSvg(HOME_ICON_HREF);
+ if (svg) {
+ svg.setAttribute('width', '20');
+ svg.setAttribute('height', '20');
+ svg.setAttribute('aria-hidden', 'true');
+ this._homeIcon = svg;
+ }
+ }
+
change(props) {
if (props.has('path') && this.path) {
this.loadNav();
@@ -37,19 +93,18 @@ class NXNav extends LitElement {
}
async decorateBrand(brandSection) {
- // The first link will always be at least an icon
const brandLink = brandSection.querySelector('a');
if (!brandLink) return null;
- const { href, textContent } = brandLink;
-
- // Attempt to find a lockup svg
- const hasLockup = href.includes('.svg');
- if (hasLockup) {
- brandLink.setAttribute('aria-label', textContent);
- brandLink.textContent = '';
- const lockup = await loadHrefSvg(href);
- brandLink.append(lockup);
+
+ brandLink.setAttribute('aria-label', 'Adobe');
+ brandLink.textContent = '';
+
+ const graphic = await loadHrefSvg(getBrandLogoHref());
+ if (graphic) {
+ normalizeBrandSvg(graphic);
+ brandLink.append(graphic);
}
+
brandLink.href = '/';
return brandLink;
@@ -73,10 +128,64 @@ class NXNav extends LitElement {
return getMetadata('nav-path') || this.path || DEFAULT_NAV_PATH;
}
+ async _onHomeClick() {
+ const { setPanelsGrid } = await import('../../utils/panel.js');
+ if (document.body.classList.contains('sidenav-collapsed')) {
+ document.body.classList.remove('sidenav-collapsed');
+ sessionStorage.setItem('nx-sidenav-visible', 'true');
+ } else {
+ document.body.classList.add('sidenav-collapsed');
+ sessionStorage.removeItem('nx-sidenav-visible');
+ }
+ setPanelsGrid();
+ this.requestUpdate();
+ }
+
+ _renderWorkspaceBreadcrumb() {
+ const segments = workspaceBreadcrumbSegments(this.details.value);
+ if (segments.length < 2) return nothing;
+
+ return html`
+
+ `;
+ }
+
render() {
return html`
+
${this._brand}
+ ${this._renderWorkspaceBreadcrumb()}
${this._actions}
diff --git a/nx2/scripts/nx.js b/nx2/scripts/nx.js
index d010e89c..ce5ee563 100644
--- a/nx2/scripts/nx.js
+++ b/nx2/scripts/nx.js
@@ -260,6 +260,15 @@ async function decorateDoc() {
const template = getMetadata('template');
if (template) document.body.classList.add(template);
+ if (template === 'app-frame') {
+ if (sessionStorage.getItem('nx-sidenav-visible') !== 'true') {
+ document.body.classList.add('sidenav-collapsed');
+ }
+ const { setPanelsGrid, ensureAppFrameSurfaceElevation } = await import('../utils/panel.js');
+ setPanelsGrid();
+ ensureAppFrameSurfaceElevation();
+ }
+
const scheme = localStorage.getItem('color-scheme');
if (scheme) document.body.classList.add(scheme);
diff --git a/nx2/styles/styles.css b/nx2/styles/styles.css
index baa5062a..830eda9c 100644
--- a/nx2/styles/styles.css
+++ b/nx2/styles/styles.css
@@ -208,6 +208,14 @@
--s2-corner-radius-700: 10px;
--s2-corner-radius-800: 16px;
+ /* Drop shadow / elevated — layered (alias/drop-shadow: ambient, transition, elevated-key) */
+ --nx-drop-shadow-ambient: 0 4px 12px 0 rgb(0 0 0 / 8%);
+ --nx-drop-shadow-transition: 0 2px 6px 0 rgb(0 0 0 / 4%);
+ --nx-drop-shadow-elevated-key: 0 0 2px 0 rgb(0 0 0 / 12%);
+
+ /* App-frame `main` + side panels (desktop + mobile shell) */
+ --nx-app-shell-radius: 12px;
+
/* heading */
--s2-heading-size-xs: 18px;
--s2-heading-size-s: 20px;
@@ -422,7 +430,11 @@ html:has(meta[content="edge-delivery"]) {
grid-template:
"header" var(--s2-nav-height)
"main" 1fr;
- background-color: light-dark(rgb(240 240 240), rgb(17 17 17));
+ background-color: var(--s2-gray-50);
+
+ &.nx-panel-resize-active {
+ cursor: ew-resize;
+ }
header {
grid-area: header;
@@ -436,24 +448,22 @@ html:has(meta[content="edge-delivery"]) {
grid-area: main;
background-color: light-dark(#fff, #000);
max-height: 100%;
+ border-radius: var(--nx-app-shell-radius) var(--nx-app-shell-radius) 0 0;
}
- .section {
- &.container {
- .default-content, .block-content {
- max-width: var(--section-container-width);
- margin-inline: auto;
- }
- }
+ /* Elevated surface: center `main` or side panels (pointer-activated; see panel.js) */
+ main,
+ aside.panel .panel-wrapper {
+ transition: box-shadow 0.15s ease;
}
- &:has(aside.panel:not([hidden]))::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;
+ main.nx-main-raised {
+ position: relative;
+ z-index: 1;
+ box-shadow:
+ var(--nx-drop-shadow-ambient),
+ var(--nx-drop-shadow-transition),
+ var(--nx-drop-shadow-elevated-key);
}
aside.panel {
@@ -471,10 +481,16 @@ html:has(meta[content="edge-delivery"]) {
display: block;
position: relative;
box-sizing: border-box;
+
+ /* Programmatic / click focus; visual is layered shadow via :focus-within */
+ &:focus {
+ outline: none;
+ }
+
height: 100%;
max-height: 100%;
margin: 0;
- border-radius: 24px;
+ border-radius: var(--nx-app-shell-radius);
background-color: light-dark(#fff, #000);
.panel-shell {
@@ -505,12 +521,11 @@ html:has(meta[content="edge-delivery"]) {
.panel-resize-handle {
display: none;
position: absolute;
- top: 50%;
- translate: 0 -50%;
+ top: 4px;
+ bottom: 4px;
z-index: 2;
box-sizing: border-box;
- width: 12px;
- height: 56px;
+ width: 16px;
padding: 0;
margin: 0;
border: none;
@@ -520,38 +535,95 @@ html:has(meta[content="edge-delivery"]) {
color: inherit;
}
+ /* Gutter pill: gray-400, only when pointer is in the resize zone (or dragging / touch) */
.panel-resize-handle::before {
content: '';
position: absolute;
left: 50%;
- top: 50%;
- translate: -50% -50%;
- width: 4px;
- height: 48px;
+ top: 0;
+ bottom: 0;
+ translate: -50% 0;
+ width: 2px;
border-radius: 999px;
- background-color: var(--s2-gray-600);
+ background-color: var(--s2-gray-400);
+ opacity: 0;
+ transition: opacity 0.12s ease 0s;
+ }
+
+ .panel-resize-handle:focus-visible {
+ outline: none;
}
- .panel-resize-handle:hover::before,
.panel-resize-handle:focus-visible::before {
- background-color: var(--s2-gray-900);
+ background-color: var(--s2-gray-400);
+ opacity: 1;
}
- .panel-resize-handle:focus-visible {
- outline: 2px solid var(--s2-blue-800);
- outline-offset: 2px;
+ .panel-resize-handle:active::before {
+ background-color: var(--s2-gray-400);
+ opacity: 1;
+ }
+
+ /* Show gutter pill only when pointer is in the handle hit zone (between main & panel) */
+ @media (hover: hover) {
+ /* Short dwell so the pill doesn’t flash on fast pointer moves across the hit zone */
+ .panel-resize-handle:hover::before {
+ opacity: 1;
+ background-color: var(--s2-gray-400);
+ transition: opacity 0.12s ease 0.2s;
+ }
+ }
+
+ @media (hover: none) {
+ .panel-resize-handle::before {
+ opacity: 1;
+ }
}
.panel-resize-handle-trailing {
- right: -12px;
+ right: -14px;
}
.panel-resize-handle-leading {
- left: -12px;
+ left: -14px;
}
}
}
+ /* During drag, only the handle being dragged stays visible (see panel.js) */
+ aside.panel.nx-panel-resizing .panel-wrapper .panel-resize-handle::before {
+ opacity: 1;
+ }
+
+ /* Side panels: `.nx-panel-raised` matches main behavior (see panel.js) */
+ aside.panel .panel-wrapper:focus-within,
+ aside.panel .panel-wrapper.nx-panel-raised {
+ position: relative;
+ z-index: 2;
+ box-shadow:
+ var(--nx-drop-shadow-ambient),
+ var(--nx-drop-shadow-transition),
+ var(--nx-drop-shadow-elevated-key);
+ }
+
+ .section {
+ &.container {
+ .default-content, .block-content {
+ max-width: var(--section-container-width);
+ margin-inline: auto;
+ }
+ }
+ }
+
+ &:has(aside.panel:not([hidden]))::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;
+ }
+
footer {
display: none;
}
@@ -580,15 +652,31 @@ html:has(meta[content="edge-delivery"]) {
display: unset;
}
+ &.sidenav-collapsed > nav {
+ visibility: hidden;
+ overflow: hidden;
+ width: 0;
+ min-width: 0;
+ pointer-events: none;
+ }
+
main {
display: grid;
- margin: 0 12px 0 0;
- border-radius: var(--s2-corner-radius-800) var(--s2-corner-radius-800) 0 0;
+ margin: 0 12px;
max-height: 100%;
overflow-y: auto;
}
+ /* Single 12px gutter: `.panel-wrapper` already uses margin-inline 12px */
+ &:has(aside.panel[data-position="before"]:not([hidden])) main {
+ margin-left: 0;
+ }
+
+ &:has(aside.panel[data-position="after"]:not([hidden])) main {
+ margin-right: 0;
+ }
+
&:has(aside.panel:not([hidden]))::before {
display: none;
pointer-events: none;
@@ -597,11 +685,15 @@ html:has(meta[content="edge-delivery"]) {
aside.panel {
position: static;
+ /* Match panel.js: 240px surface + 12px `.panel-wrapper` margin each side */
+ min-width: calc(240px + 24px);
+
.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);
+ margin-block: 0 var(--panel-bottom-margin);
+ margin-inline: 12px;
+ border-radius: var(--nx-app-shell-radius);
.panel-resize-handle {
display: block;
diff --git a/nx2/utils/panel.js b/nx2/utils/panel.js
index a4ecf412..be4be730 100644
--- a/nx2/utils/panel.js
+++ b/nx2/utils/panel.js
@@ -1,7 +1,49 @@
import { getMetadata } from '../scripts/nx.js';
-const PANEL_WIDTH_MIN = 120;
-const PANEL_WIDTH_MAX = () => Math.min(1600, window.innerWidth * 0.4);
+/** Default painted panel surface width (the white column users see inside wrapper margins). */
+const PANEL_SURFACE_DEFAULT_PX = 400;
+/** Minimum width of the painted panel surface (inside `.panel-wrapper` margins). */
+const PANEL_SURFACE_MIN_PX = 240;
+/**
+ * Desktop `.panel-wrapper` uses `margin-inline: 12px`; the grid sizes `aside`, so
+ * track min = surface min + those insets (otherwise ~216px “looks” like the minimum).
+ */
+const PANEL_WRAPPER_MARGIN_INLINE_PX = 12;
+
+/**
+ * Default grid track width (`aside` column): on desktop includes wrapper side margins so
+ * the painted surface matches `PANEL_SURFACE_DEFAULT_PX`; on small viewports margins are 0.
+ */
+export function getDefaultPanelTrackWidthPx() {
+ if (typeof window === 'undefined') {
+ return PANEL_SURFACE_DEFAULT_PX + 2 * PANEL_WRAPPER_MARGIN_INLINE_PX;
+ }
+ return window.matchMedia('(min-width: 600px)').matches
+ ? PANEL_SURFACE_DEFAULT_PX + 2 * PANEL_WRAPPER_MARGIN_INLINE_PX
+ : PANEL_SURFACE_DEFAULT_PX;
+}
+
+export function getDefaultPanelWidthCss() {
+ return `${getDefaultPanelTrackWidthPx()}px`;
+}
+/** Desktop grid track min so the painted surface is still `PANEL_SURFACE_MIN_PX` after margins. */
+const DESKTOP_PANEL_TRACK_MIN_PX = PANEL_SURFACE_MIN_PX + 2 * PANEL_WRAPPER_MARGIN_INLINE_PX;
+/** `main` and any peer side panel **track** each stay at least this wide when you resize. */
+const REGION_MIN_WIDTH_PX = PANEL_SURFACE_MIN_PX;
+/**
+ * Approximate horizontal margin budget (sidenav gap, main/panel `margin-inline`, gutters).
+ * Kept in sync-ish with app-frame `styles.css` (~12px rhythm).
+ */
+const APP_FRAME_WIDTH_MARGIN_BUDGET = 72;
+
+const NAV_WIDTH_PX = 56;
+
+/** Below 600px the shell uses full-bleed panel chrome (`margin: 0` on `.panel-wrapper`). */
+function panelTrackMinPx() {
+ return window.matchMedia('(min-width: 600px)').matches
+ ? DESKTOP_PANEL_TRACK_MIN_PX
+ : PANEL_SURFACE_MIN_PX;
+}
function parsePanelWidth(aside) {
const w = aside.dataset.width?.trim();
@@ -9,12 +51,49 @@ function parsePanelWidth(aside) {
return aside.getBoundingClientRect().width;
}
+/**
+ * Max width for this panel’s column: user can grow until `main` and the peer panel (if any)
+ * would go below REGION_MIN_WIDTH_PX. No arbitrary hard cap beyond the viewport budget.
+ */
+function getPanelWidthMaxPx(aside) {
+ const inner = window.innerWidth;
+ const sidenav = document.body.classList.contains('sidenav-collapsed') ? 0 : NAV_WIDTH_PX;
+ const before = document.body.querySelector('aside.panel[data-position="before"]:not([hidden])');
+ const after = document.body.querySelector('aside.panel[data-position="after"]:not([hidden])');
+ const isBefore = aside.dataset.position === 'before';
+
+ const trackMin = panelTrackMinPx();
+
+ let peerOtherPx = 0;
+ if (isBefore) {
+ if (after && after !== aside) {
+ peerOtherPx = Math.max(trackMin, parsePanelWidth(after));
+ }
+ } else if (before && before !== aside) {
+ peerOtherPx = Math.max(trackMin, parsePanelWidth(before));
+ }
+
+ const reserved = sidenav + peerOtherPx + REGION_MIN_WIDTH_PX + APP_FRAME_WIDTH_MARGIN_BUDGET;
+ const fromLayout = inner - reserved;
+ return Math.max(trackMin, fromLayout);
+}
+
function applyPanelWidth(aside, px) {
- const clamped = `${Math.max(PANEL_WIDTH_MIN, Math.min(PANEL_WIDTH_MAX(), Math.round(px)))}px`;
+ const max = getPanelWidthMaxPx(aside);
+ const min = panelTrackMinPx();
+ const clamped = `${Math.max(min, Math.min(max, Math.round(px)))}px`;
aside.dataset.width = clamped;
aside.style.width = clamped;
}
+/** If viewport or peer panels changed, clamp stored widths so layout stays valid. */
+export function clampAllPanelWidthsToLayout() {
+ document.body.querySelectorAll('aside.panel:not([hidden])').forEach((aside) => {
+ const el = /** @type {HTMLElement} */ (aside);
+ applyPanelWidth(el, parsePanelWidth(el));
+ });
+}
+
const PANEL_STORAGE_KEY = 'nx-panels';
function getPanelStore() {
@@ -43,15 +122,16 @@ export function setPanelsGrid() {
const before = body.querySelector('aside.panel[data-position="before"]:not([hidden])');
const after = body.querySelector('aside.panel[data-position="after"]:not([hidden])');
+ const sidenavCollapsed = body.classList.contains('sidenav-collapsed');
const getWidth = (el) => {
const w = el?.dataset.width?.trim();
- return w ? `min(${w}, 40vw)` : 'minmax(0, auto)';
+ return w && /^\d+(\.\d+)?px$/i.test(w) ? w : 'minmax(0, auto)';
};
const header = ['header'];
const content = ['sidenav'];
- const columns = ['var(--s2-nav-width)'];
+ const columns = [sidenavCollapsed ? '0px' : 'var(--s2-nav-width)'];
if (before) {
before.style.gridArea = 'panel-before';
@@ -75,10 +155,16 @@ export function setPanelsGrid() {
body.style.setProperty('--app-frame-columns', columns.join(' '));
}
+const RESIZE_ACTIVE_CLASS = 'nx-panel-resize-active';
+const PANEL_RESIZING_CLASS = 'nx-panel-resizing';
+
function resizePointerDown(downEvent) {
- const handle = downEvent.currentTarget;
+ const handle = /** @type {HTMLButtonElement} */ (downEvent.currentTarget);
const aside = handle.closest('aside.panel');
if (!aside || downEvent.button !== 0) return;
+
+ downEvent.preventDefault();
+
const deltaSign = aside.dataset.position === 'before' ? 1 : -1;
handle.setPointerCapture(downEvent.pointerId);
@@ -86,28 +172,195 @@ function resizePointerDown(downEvent) {
const startW = parsePanelWidth(aside);
const prevUserSelect = document.body.style.userSelect;
document.body.style.userSelect = 'none';
+ document.body.classList.add(RESIZE_ACTIVE_CLASS);
+ aside.classList.add(PANEL_RESIZING_CLASS);
- const onPointerMove = (moveEvent) => {
- const dx = moveEvent.clientX - startX;
- applyPanelWidth(aside, startW + deltaSign * dx);
+ let rafId = 0;
+ let pendingDx = 0;
+
+ const flushMove = () => {
+ rafId = 0;
+ applyPanelWidth(aside, startW + deltaSign * pendingDx);
setPanelsGrid();
};
- const onPointerUp = (upEvent) => {
- handle.releasePointerCapture(upEvent.pointerId);
+ const onPointerMove = (moveEvent) => {
+ pendingDx = moveEvent.clientX - startX;
+ if (!rafId) {
+ rafId = requestAnimationFrame(flushMove);
+ }
+ };
+
+ const captureId = downEvent.pointerId;
+
+ let ended = false;
+ function endResize() {
+ if (ended) return;
+ ended = true;
+ if (rafId) cancelAnimationFrame(rafId);
+ rafId = 0;
+ flushMove();
document.body.style.userSelect = prevUserSelect;
+ document.body.classList.remove(RESIZE_ACTIVE_CLASS);
+ aside.classList.remove(PANEL_RESIZING_CLASS);
handle.removeEventListener('pointermove', onPointerMove);
- handle.removeEventListener('pointerup', onPointerUp);
- handle.removeEventListener('pointercancel', onPointerUp);
+ handle.removeEventListener('pointerup', endResize);
+ handle.removeEventListener('pointercancel', endResize);
+ handle.removeEventListener('lostpointercapture', endResize);
+ try {
+ handle.releasePointerCapture(captureId);
+ } catch {
+ /* already released */
+ }
savePanelState(aside.dataset.position, {
width: aside.dataset.width,
fragment: aside.dataset.fragment,
});
- };
+ }
handle.addEventListener('pointermove', onPointerMove);
- handle.addEventListener('pointerup', onPointerUp);
- handle.addEventListener('pointercancel', onPointerUp);
+ handle.addEventListener('pointerup', endResize);
+ handle.addEventListener('pointercancel', endResize);
+ handle.addEventListener('lostpointercapture', endResize);
+}
+
+function resetPanelTrackToDefaultWidth(aside) {
+ applyPanelWidth(aside, getDefaultPanelTrackWidthPx());
+ setPanelsGrid();
+ savePanelState(aside.dataset.position, {
+ width: aside.dataset.width,
+ fragment: aside.dataset.fragment,
+ });
+}
+
+const PANEL_RAISED_CLASS = 'nx-panel-raised';
+const MAIN_RAISED_CLASS = 'nx-main-raised';
+
+function panelWrapperFromAside(aside) {
+ return aside?.querySelector(':scope > .panel-wrapper') ?? null;
+}
+
+function lowerAppFrameMain() {
+ document.querySelector('main')?.classList.remove(MAIN_RAISED_CLASS);
+}
+
+function raiseAppFrameMain() {
+ document.querySelector('main')?.classList.add(MAIN_RAISED_CLASS);
+}
+
+/** If no side panel is elevated, treat the canvas `main` as active (center column). */
+function syncMainRaisedIfNoPanelRaised() {
+ if (getMetadata('template') !== 'app-frame') return;
+ const anyPanelRaised = document.querySelector(
+ `aside.panel:not([hidden]) .panel-wrapper.${PANEL_RAISED_CLASS}`,
+ );
+ if (!anyPanelRaised) {
+ raiseAppFrameMain();
+ }
+}
+
+/** Set elevated shadow on one panel only (or clear all when `panelAside` is null). */
+function setActivePanelAside(panelAside) {
+ if (panelAside) {
+ lowerAppFrameMain();
+ }
+ document.querySelectorAll('aside.panel:not([hidden])').forEach((aside) => {
+ const w = panelWrapperFromAside(aside);
+ if (!w) return;
+ if (panelAside && aside === panelAside) {
+ w.classList.add(PANEL_RAISED_CLASS);
+ } else {
+ w.classList.remove(PANEL_RAISED_CLASS);
+ }
+ });
+}
+
+function findPanelAsideInComposedPath(path) {
+ for (const node of path) {
+ if (
+ node instanceof Element
+ && node.localName === 'aside'
+ && node.classList.contains('panel')
+ && !node.hidden
+ ) {
+ return /** @type {HTMLElement} */ (node);
+ }
+ }
+ return null;
+}
+
+function composedPathIncludesMain(path) {
+ for (const node of path) {
+ if (node instanceof Element && node.localName === 'main') return true;
+ }
+ return false;
+}
+
+function onDocumentPointerDownForSurfaceRaise(event) {
+ if (event.button !== 0) return;
+ if (getMetadata('template') !== 'app-frame') return;
+ const path = event.composedPath();
+ const hitPanel = findPanelAsideInComposedPath(path);
+ if (hitPanel) {
+ setActivePanelAside(hitPanel);
+ return;
+ }
+ if (composedPathIncludesMain(path)) {
+ setActivePanelAside(null);
+ raiseAppFrameMain();
+ }
+}
+
+let panelRaisePointerHookInstalled = false;
+let panelLayoutResizeHookInstalled = false;
+
+function ensurePanelLayoutClampOnResize() {
+ if (panelLayoutResizeHookInstalled || typeof window === 'undefined') return;
+ panelLayoutResizeHookInstalled = true;
+ window.addEventListener('resize', () => {
+ if (getMetadata('template') !== 'app-frame') return;
+ clampAllPanelWidthsToLayout();
+ setPanelsGrid();
+ });
+}
+
+function ensurePanelRaisePointerHook() {
+ if (panelRaisePointerHookInstalled || typeof document === 'undefined') return;
+ panelRaisePointerHookInstalled = true;
+ document.addEventListener('pointerdown', onDocumentPointerDownForSurfaceRaise, true);
+}
+
+/** Default: center `main` is elevated until a side panel is clicked (app-frame only). */
+export function ensureAppFrameSurfaceElevation() {
+ if (getMetadata('template') !== 'app-frame') return;
+ ensurePanelRaisePointerHook();
+ ensurePanelLayoutClampOnResize();
+ syncMainRaisedIfNoPanelRaised();
+}
+
+/** Walk composedPath up to `wrapper`; if we hit a real control first, skip panel focus. */
+function panelPointerShouldFocusWrapper(event, wrapper) {
+ const path = event.composedPath();
+ const wrapIdx = path.indexOf(wrapper);
+ if (wrapIdx === -1) return false;
+ for (let i = 0; i < wrapIdx; i += 1) {
+ const node = path[i];
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ const el = /** @type {Element} */ (node);
+ if (
+ el.matches(
+ 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [contenteditable="true"]',
+ )
+ ) {
+ return false;
+ }
+ if (el !== wrapper && el.matches('[tabindex]')) {
+ const ti = Number.parseInt(el.getAttribute('tabindex') ?? '', 10);
+ if (!Number.isNaN(ti) && ti >= 0) return false;
+ }
+ }
+ }
+ return true;
}
function buildPanelDOM(aside) {
@@ -115,6 +368,13 @@ function buildPanelDOM(aside) {
const wrapper = document.createElement('div');
wrapper.className = 'panel-wrapper';
+ wrapper.tabIndex = 0;
+ wrapper.addEventListener('pointerdown', (e) => {
+ if (e.button !== 0) return;
+ if (!panelPointerShouldFocusWrapper(e, wrapper)) return;
+ if (document.activeElement === wrapper) return;
+ wrapper.focus({ preventScroll: true });
+ });
const shell = document.createElement('div');
shell.className = 'panel-shell';
@@ -125,15 +385,26 @@ function buildPanelDOM(aside) {
const handle = document.createElement('button');
handle.type = 'button';
handle.className = `panel-resize-handle panel-resize-handle-${edge}`;
- handle.setAttribute('aria-label', 'Resize panel');
+ handle.setAttribute('aria-label', 'Resize panel. Double-click to restore default width.');
handle.addEventListener('pointerdown', resizePointerDown);
+ handle.addEventListener('dblclick', (e) => {
+ e.preventDefault();
+ const panelAside = handle.closest('aside.panel');
+ if (!panelAside) return;
+ resetPanelTrackToDefaultWidth(panelAside);
+ });
shell.append(body);
wrapper.append(shell, handle);
aside.append(wrapper);
}
-export function createPanel({ width = '400px', beforeMain = false, content, fragment } = {}) {
+export function createPanel({
+ width = getDefaultPanelWidthCss(),
+ beforeMain = false,
+ content,
+ fragment,
+} = {}) {
const aside = document.createElement('aside');
aside.classList.add('panel');
aside.dataset.width = width;
@@ -146,38 +417,51 @@ export function createPanel({ width = '400px', beforeMain = false, content, frag
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);
}
+ ensurePanelRaisePointerHook();
+ ensurePanelLayoutClampOnResize();
+ applyPanelWidth(aside, parsePanelWidth(aside));
+ setPanelsGrid();
+ setActivePanelAside(aside);
+
+ savePanelState(position, {
+ width: aside.dataset.width,
+ fragment: fragment ?? aside.dataset.fragment,
+ });
+
return aside;
}
export function hidePanel(aside) {
+ panelWrapperFromAside(aside)?.classList.remove(PANEL_RAISED_CLASS);
removePanelState(aside.dataset.position);
aside.hidden = true;
setPanelsGrid();
+ syncMainRaisedIfNoPanelRaised();
}
export function unhidePanel(aside) {
aside.hidden = false;
+ ensurePanelRaisePointerHook();
+ ensurePanelLayoutClampOnResize();
+ applyPanelWidth(aside, parsePanelWidth(aside));
+ setPanelsGrid();
savePanelState(aside.dataset.position, {
width: aside.dataset.width,
fragment: aside.dataset.fragment,
});
- setPanelsGrid();
+ setActivePanelAside(aside);
}
export { getPanelStore };
export function showPanel(opts) {
- const aside = createPanel(opts);
- setPanelsGrid();
- return aside;
+ return createPanel(opts);
}
export async function loadPanelContent(value) {
@@ -191,7 +475,11 @@ export async function loadPanelContent(value) {
return { content: await mod.getPanel(), fragment: undefined };
}
-export async function openPanelWithFragment({ width = '400px', beforeMain = false, fragment } = {}) {
+export async function openPanelWithFragment({
+ width = getDefaultPanelWidthCss(),
+ beforeMain = false,
+ fragment,
+} = {}) {
const { content, fragment: persistedFragment } = await loadPanelContent(fragment);
if (!content) return undefined;
return showPanel({ width, beforeMain, content, fragment: persistedFragment });