diff --git a/blocks/form/canvas-header/canvas-header.css b/blocks/form/canvas-header/canvas-header.css
new file mode 100644
index 00000000..96a8c3cf
--- /dev/null
+++ b/blocks/form/canvas-header/canvas-header.css
@@ -0,0 +1,63 @@
+:host {
+ display: block;
+ box-sizing: border-box;
+ font-family: var(
+ --s2-font-family,
+ adobe-clean,
+ 'Source Sans Pro',
+ 'Trebuchet MS',
+ sans-serif
+ );
+ color: var(--s2-gray-800);
+}
+
+.bar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ box-sizing: border-box;
+ height: var(--canvas-header-height, 48px);
+ padding: 0 12px;
+ background-color: light-dark(#fff, var(--s2-gray-25));
+ border-bottom: 1px solid var(--s2-gray-200);
+}
+
+.group {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+.icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ min-width: 24px;
+ min-height: 24px;
+ padding: 0 4px;
+ margin: 0;
+ border: none;
+ border-radius: 8px;
+ color: var(--s2-gray-800);
+ background: transparent;
+ cursor: pointer;
+}
+
+.icon-btn:hover {
+ background-color: var(--s2-gray-75);
+}
+
+.icon-btn:focus-visible {
+ outline: 2px solid var(--s2-blue-800);
+ outline-offset: 2px;
+}
+
+.icon-btn svg.icon {
+ display: block;
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+ overflow: hidden;
+}
diff --git a/blocks/form/canvas-header/canvas-header.js b/blocks/form/canvas-header/canvas-header.js
new file mode 100644
index 00000000..5a5967c4
--- /dev/null
+++ b/blocks/form/canvas-header/canvas-header.js
@@ -0,0 +1,48 @@
+import { LitElement, html } from 'da-lit';
+
+import { getNx } from '../../../scripts/utils.js';
+
+const { loadStyle } = await import(`${getNx()}/utils/utils.js`);
+
+const style = await loadStyle(import.meta.url);
+
+const ICONS = { splitLeft: '/img/icons/s2-icon-splitleft-20-n.svg' };
+
+// The form's own header: a slim bar with the chat toggle. Emits
+// `form-toggle-chat`; exposes the toggle as a part so form.css can hide it.
+class CanvasHeader extends LitElement {
+ connectedCallback() {
+ super.connectedCallback();
+ this.shadowRoot.adoptedStyleSheets = [style];
+ }
+
+ _toggleChat() {
+ this.dispatchEvent(
+ new CustomEvent('form-toggle-chat', { bubbles: true, composed: true }),
+ );
+ }
+
+ _renderIcon(name) {
+ return html``;
+ }
+
+ render() {
+ return html`
+
+ `;
+ }
+}
+
+customElements.define('canvas-header', CanvasHeader);
diff --git a/blocks/form/form.css b/blocks/form/form.css
new file mode 100644
index 00000000..c63de9b0
--- /dev/null
+++ b/blocks/form/form.css
@@ -0,0 +1,30 @@
+/* Form workspace: the nx2 form fills main; nx-chat is docked on the left. */
+
+:root {
+ --canvas-header-height: 48px;
+ --form-content-top: 32px;
+
+ /* Top gap for scrolled-to groups + the sticky nav. The header is outside the
+ scroll region, so this only covers the gap, not the header. Apply it here
+ only — padding the .form scroll container would misalign the two. */
+ --nx-scroll-offset-top: var(--form-content-top);
+}
+
+/* Nested scroll (like /canvas): the form scrolls inside a fixed-height region,
+ so the header — a sibling above .form — stays put without `sticky`. */
+.form {
+ box-sizing: border-box;
+ height: calc(100vh - var(--s2-nav-height) - var(--canvas-header-height));
+ overflow-y: auto;
+}
+
+/* Never override the chat's `display` here, or its flex layout collapses. */
+aside.panel[data-position='before'] nx-chat {
+ height: 100%;
+}
+
+/* Hide the chat toggle while the chat is open (it has its own close). */
+html:has(aside.panel[data-position='before']:not([hidden]))
+ canvas-header::part(toggle-before) {
+ display: none;
+}
diff --git a/blocks/form/form.js b/blocks/form/form.js
new file mode 100644
index 00000000..9917f8cb
--- /dev/null
+++ b/blocks/form/form.js
@@ -0,0 +1,76 @@
+import { getNx } from '../../scripts/utils.js';
+import './canvas-header/canvas-header.js';
+
+const { loadStyle } = await import(`${getNx()}/utils/utils.js`);
+const { openPanel } = await import(`${getNx()}/utils/panel.js`);
+
+const style = await loadStyle(import.meta.url);
+document.adoptedStyleSheets = [...document.adoptedStyleSheets, style];
+
+// Engage the app-frame panel grid even when the page didn't declare it.
+function ensureAppFrame() {
+ let meta = document.head.querySelector('meta[name="template"]');
+ if (!meta) {
+ meta = document.createElement('meta');
+ meta.name = 'template';
+ document.head.append(meta);
+ }
+ meta.content = 'app-frame';
+}
+
+const WORKSPACE_NAV_PATH = '/fragments/exp-workspace/nav';
+
+// Use the workspace nav (breadcrumb + actions), like canvas. Read by nav.js,
+// which loads after this block decorates. Page nav-path wins.
+function ensureNavPath() {
+ if (document.head.querySelector('meta[name="nav-path"]')) return;
+ const meta = document.createElement('meta');
+ meta.name = 'nav-path';
+ meta.content = WORKSPACE_NAV_PATH;
+ document.head.append(meta);
+}
+
+// Open preview/publish at the structured-content renderer.
+function ensurePreviewRenderer() {
+ let meta = document.head.querySelector('meta[name="ew-preview-renderer"]');
+ if (!meta) {
+ meta = document.createElement('meta');
+ meta.name = 'ew-preview-renderer';
+ document.head.append(meta);
+ }
+ meta.content = 'sc';
+}
+
+function openChatPanel() {
+ return openPanel({
+ position: 'before',
+ width: '400px',
+ getContent: async () => {
+ await import(`${getNx()}/blocks/chat/chat.js`);
+ return document.createElement('nx-chat');
+ },
+ });
+}
+
+// Header sits before .form (outside its scroll region), so it stays put.
+function installHeader(block) {
+ const header = document.createElement('canvas-header');
+ header.addEventListener('form-toggle-chat', () => openChatPanel());
+ block.before(header);
+ return header;
+}
+
+// Requires `?nxver=2` so getNx() resolves to nx2 (where the form lives). The
+// form and chat both read the `#/org/site/path` hash, so they stay in sync.
+export default async function decorate(block) {
+ ensureAppFrame();
+ ensureNavPath();
+ ensurePreviewRenderer();
+
+ installHeader(block);
+
+ const { default: decorateForm } = await import(`${getNx()}/blocks/form/form.js`);
+ await decorateForm(block);
+
+ await openChatPanel();
+}
diff --git a/scripts/scripts.js b/scripts/scripts.js
index 88af3df2..64d069b9 100644
--- a/scripts/scripts.js
+++ b/scripts/scripts.js
@@ -14,6 +14,11 @@ import { initIms } from '../blocks/shared/utils.js';
import { setNx, nxJS, nxCSS } from './utils.js';
export function decorateArea({ area = document } = {}) {
+ // Render any `nx-form` block as the `form` workspace (the nx2 form in the
+ // center with nx-chat docked on the left). Runs before sections/blocks are
+ // collected, so NX loads the rewritten block.
+ area.querySelectorAll('.nx-form').forEach((el) => el.classList.replace('nx-form', 'form'));
+
// Find all dark & light images
const lcpImgs = [...area.querySelectorAll('[alt="light"], [alt="dark"]')].filter((img) => {
const pic = img.parentElement;