You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/05/22 23:47:42 UTC

(skywalking-horizon-ui) branch main updated: feat(admin): browser-local template authoring — create, preview, delete/disable (#9)

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new 4f5c164  feat(admin): browser-local template authoring — create, preview, delete/disable (#9)
4f5c164 is described below

commit 4f5c1648d41ca49cebd454676820c95bc20a61a3
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Sat May 23 07:46:24 2026 +0800

    feat(admin): browser-local template authoring — create, preview, delete/disable (#9)
    
    Dashboard authoring (layer + overview admin) now shares one model: edits
    live as a browser-local draft, the live page stays on the published OAP
    version, and "Check diff & push" publishes (create-or-update on OAP).
    
    - Create mirrors edit: "+ New dashboard" writes a local draft (id = name,
      checked unique), preview, then push. Remote-only dashboards (pushed, no
      bundled default) now render on the live page + sidebar, not just the
      editor.
    - Delete = soft-disable (OAP has no hard DELETE): local-only drafts are
      removed from the browser; on-OAP templates are disabled and drop from
      the picker live-state, sidebar, and live page. Layer disable hides the
      layer (menu route honors disabled templates); Reactivate re-enables from
      the bundled default via the OAP update path. Styled in-app confirm
      dialogs replace the native confirm box.
    - Overview editor rebuilt as a layer-style 12-col canvas (drag-reorder,
      corner-resize, click-to-edit drawer, selectable section-breaks/title,
      dashed selectable hints) mirroring the live grid; composite-metrics KPI
      rows are stacked cards so they read in the narrow drawer.
    - Layer editor: Diverged/Local filters moved inside the picker dropdown,
      one-line "Layer: <name> <key> <status>" header showing the same sync
      status the picker does.
    - Reliability: clear "Cannot reach the server" message when the BFF is
      down (API client no longer double-reads the response body); fixed the
      sidebar scrolling to the bottom on every navigation (the Debug-events
      toggle's active state was the scroll target).
    - Docs: rewrote the layer + overview template admin sections for the
      draft -> preview -> push model, create, and delete/disable.
---
 .claude/skills/local-boot/SKILL.md                 |   22 +-
 .gitignore                                         |    3 +
 CHANGELOG.md                                       |   93 +-
 .../bff/src/bundled_templates/layers/activemq.json |    2 +-
 apps/bff/src/http/admin/template-sync.ts           |   63 +
 apps/bff/src/http/config/bundle.ts                 |   14 +
 apps/bff/src/http/query/menu.ts                    |   37 +-
 apps/bff/src/rbac/route-policy.ts                  |    1 +
 apps/bff/src/server.ts                             |    6 +-
 apps/ui/src/api/client.ts                          |   32 +-
 apps/ui/src/api/scopes/template-sync.ts            |   10 +
 apps/ui/src/controls/configBundle.ts               |   55 +-
 apps/ui/src/controls/localTemplateEdits.ts         |  100 ++
 apps/ui/src/controls/previewMode.ts                |   51 +
 apps/ui/src/controls/previewOverride.ts            |   76 +
 apps/ui/src/controls/sidebar.ts                    |   56 +
 .../src/features/admin/_shared/SyncAllButton.vue   |  191 ---
 .../features/admin/_shared/useTemplateSources.ts   |   94 ++
 .../src/features/admin/_shared/useTemplateSync.ts  |   42 +-
 .../admin/layer-templates/LayerDashboardsAdmin.vue | 1489 +++++++++++++++++---
 .../overview-templates/OverviewTemplatesAdmin.vue  | 1457 +++++++++++++------
 apps/ui/src/layer/LayerShell.vue                   |   69 +-
 apps/ui/src/shell/AppShell.vue                     |   15 +-
 apps/ui/src/shell/AppSidebar.vue                   |  136 +-
 apps/ui/src/shell/AppTopbar.vue                    |   39 +-
 apps/ui/src/shell/PreviewModeBanner.vue            |  110 ++
 apps/ui/src/shell/TemplateConflictPrompt.vue       |  163 ++-
 apps/ui/src/shell/layerFromTemplate.ts             |   94 ++
 apps/ui/src/shell/router/index.ts                  |   21 +
 apps/ui/src/shell/useLayers.ts                     |   50 +-
 apps/ui/src/utils/stableJson.ts                    |   38 +
 docs/customization/layer-templates.md              |   23 +-
 docs/customization/overview-templates.md           |   41 +-
 33 files changed, 3678 insertions(+), 1015 deletions(-)

diff --git a/.claude/skills/local-boot/SKILL.md b/.claude/skills/local-boot/SKILL.md
index 1c16348..df4ba15 100644
--- a/.claude/skills/local-boot/SKILL.md
+++ b/.claude/skills/local-boot/SKILL.md
@@ -76,12 +76,21 @@ Then open **`http://127.0.0.1:9091`** and log in as `admin` / `admin`.
 
 ## Boot against the public demo OAP
 
-The demo OAP needs basic-auth. The password is NOT committed — ask the
-developer for it and export it before booting:
+The demo OAP needs basic-auth (network username `admin`). The password is
+NOT committed — it lives in `oap-password.local` next to this file, which
+is git-ignored via the repo-wide `*.local` rule. Source it before booting;
+if the file is missing, ask the developer and recreate it (one line, the
+password only):
 
 ```bash
 REPO="$(git rev-parse --show-toplevel)"
-read -rsp "Demo OAP password: " OAP_PASSWORD && export OAP_PASSWORD && echo
+SECRET="$REPO/.claude/skills/local-boot/oap-password.local"
+if [ -s "$SECRET" ]; then
+  OAP_PASSWORD="$(cat "$SECRET")"; export OAP_PASSWORD
+else
+  read -rsp "Demo OAP password: " OAP_PASSWORD && export OAP_PASSWORD && echo
+  printf '%s\n' "$OAP_PASSWORD" > "$SECRET" && chmod 600 "$SECRET"  # cache for next boot
+fi
 
 pkill -f "tsx watch src/server.ts" 2>/dev/null
 pkill -f "tsx/dist/cli.mjs watch" 2>/dev/null; pkill -f vite 2>/dev/null
@@ -94,9 +103,10 @@ HORIZON_CONFIG="$REPO/.claude/skills/local-boot/horizon.demo.yaml" \
       node_modules/.bin/vite --host 127.0.0.1 & )
 ```
 
-When invoked as an agent and `OAP_PASSWORD` is not already set, ask the
-developer for it (do not guess, do not hardcode it into a file). The OAP
-network username is fixed as `admin` in `horizon.demo.yaml`.
+The cached `oap-password.local` is git-ignored, so it is safe to keep on
+disk between boots. If it is absent and `OAP_PASSWORD` is unset, ask the
+developer rather than guessing. The OAP network username is fixed as
+`admin` in `horizon.demo.yaml`.
 
 ## Boot against an LDAP directory (test)
 
diff --git a/.gitignore b/.gitignore
index 0db8ab1..694f426 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,6 @@ horizon.yaml
 horizon-audit.jsonl
 horizon-wire.jsonl
 horizon-setup.json
+
+# Release
+scripts/.release-work
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e6d349..0539b63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,98 @@ packages) plus the BFF's `HORIZON_VERSION` default.
 
 ## 0.6.0
 
-(In development — fill in highlights here before cutting the release.)
+### Dashboard authoring — browser-local drafts + preview
+
+The Layer dashboards and Overview templates admin pages now share one
+editing model where your work-in-progress lives in your browser, and the
+live, shared version is whatever OAP serves.
+
+- **Edits are local to your browser.** "Save (local)" stores your draft in
+  this browser only — it is never written to the server and nobody else
+  sees it. Everyone (including you, in normal viewing) keeps seeing the
+  published remote dashboard until you publish.
+- **Load any source into the editor.** A single **Reset to ▾** control loads
+  the **Bundled** (shipped default) or **Remote** (OAP live) version into the
+  editor; editing from there becomes your local draft.
+- **Preview any source on the real page.** A **Preview ▾** control opens the
+  actual layer / overview page in a new tab rendering your **Local** draft,
+  the **Bundled** default, or **Remote** — via `?mode=preview&source=…`,
+  which stays in the URL and propagates as you navigate the menu. A banner
+  names what you're previewing (dismiss with × or Esc).
+- **Publish with a diff.** **Check diff & push** shows a side-by-side
+  local→remote diff and publishes to OAP; it's enabled only when your local
+  draft actually differs from remote. Bundled can also be pushed straight to
+  OAP. Resetting to remote clears the local draft.
+- Preview faithfully reflects your draft's **enabled components / menu
+  labels** — disabled tabs disappear and renamed nouns ("Nodes", "Topics")
+  show through — without pushing anything to the server. Preview works even
+  for layers OAP currently reports no services for.
+- An editors-only reminder lists any unpublished local drafts with quick
+  links to the relevant edit page (no more "use local vs remote" prompt —
+  remote is always the live source).
+- **Create mirrors edit.** "+ New dashboard" writes a local draft (the id
+  is the template name, checked unique) — edit and preview it, then
+  **Check diff & push** publishes it. A pushed dashboard with no bundled
+  default is **remote-only** and now renders everywhere (live page +
+  sidebar), not just in the editor.
+- **Delete = soft-disable** (OAP has no hard delete). A local-only draft is
+  removed from the browser; a dashboard on OAP is disabled — dropped from
+  the picker's live state, the sidebar, and the live page. A **disabled**
+  status chip shows it. Confirmations are styled in-app dialogs, not the
+  browser's native box.
+
+### Layer dashboards editor
+
+- The layer picker is a single filterable dropdown showing alias + key +
+  sync status. A live **menu preview** sits beside the Alias / Components /
+  **Menu labels** (per-layer slot aliases) editor; clicking a menu item
+  jumps to that component's config. Scope tabs and section headings read in
+  the layer's own vocabulary.
+- The service-list metrics editor gains a **sample-data preview** (plus a
+  faithful **landing KPI tile** preview that reuses the real header
+  components), the column remove-button alignment is fixed, and the landing
+  KPI tile config makes clear it just picks which existing column feeds the
+  headline + sparkline.
+- The picker's Diverged / Local filters now sit **inside the dropdown**
+  (next to the search), and the editor header reads on one line —
+  `Layer: <name> <key> <status>` — showing the same sync-status chip the
+  picker does.
+- **Disable / Reactivate a layer.** Disabling soft-disables the layer on
+  OAP and drops it from the sidebar (the menu honors disabled templates);
+  a disabled layer offers **Reactivate**, which re-enables it from the
+  bundled default (the OAP update path clears the disabled flag).
+
+### Overview templates editor
+
+- Rebuilt as a **layer-style canvas**: a 12-column grid you drag to
+  reorder and corner-drag to resize, click-to-edit in a right drawer that
+  appears on selection (Esc / deselect hides it), with section-breaks and
+  the dashboard title selectable and unselected widgets hinted by a dashed
+  outline. The canvas mirrors the live grid (fixed row height), so the
+  layout matches the real page — including side-by-side widgets like
+  topology + alarms.
+- The **composite-metrics** KPI editor stacks each row as a card
+  (label / source / MQE / unit / aggr / style / max) so nothing truncates
+  in the narrow drawer.
+
+### Navigation & shell
+
+- The **main sidebar folds** to a narrow rail to reclaim width; the logo
+  moves into the topbar while folded (the original wordmark is unchanged).
+- The active menu item now reliably expands, highlights, and scrolls into
+  view on entering a page (route matching is case-insensitive). Fixed a
+  regression where the sidebar scrolled to the very bottom on every
+  navigation (the "Debug events" toggle's active state was being treated
+  as the scroll target).
+- **Zipkin trace mode** drops the per-layer service-KPI header — the Zipkin
+  explorer is a self-contained, cross-service view.
+
+### Reliability
+
+- When the BFF is unreachable the UI now shows a clear "Cannot reach the
+  server" message instead of the cryptic "body stream already read" — the
+  API client reads each error response body once and surfaces the real
+  status/text (or a wrapped network error).
 
 ## 0.5.0
 
diff --git a/apps/bff/src/bundled_templates/layers/activemq.json b/apps/bff/src/bundled_templates/layers/activemq.json
index 74435c9..e82e783 100644
--- a/apps/bff/src/bundled_templates/layers/activemq.json
+++ b/apps/bff/src/bundled_templates/layers/activemq.json
@@ -119,7 +119,7 @@
           "latest(aggregate_labels(meter_activemq_cluster_heap_memory_usage_max,sum))/1024/1024"
         ],
         "span": 3,
-        "rowSpan": 1
+        "rowSpan": 2
       },
       {
         "id": "enqueue_dequeue",
diff --git a/apps/bff/src/http/admin/template-sync.ts b/apps/bff/src/http/admin/template-sync.ts
index 99b08b1..364e576 100644
--- a/apps/bff/src/http/admin/template-sync.ts
+++ b/apps/bff/src/http/admin/template-sync.ts
@@ -328,6 +328,69 @@ export function registerTemplateSyncAdminRoutes(
       });
     }
   });
+
+  /* POST /api/admin/templates/disable — "delete" a template. OAP has no
+   * hard DELETE, so deletion is a soft `disable` of the remote UI-template
+   * (a disabled row is dropped from the bundle, so the template vanishes
+   * from the UI). Irreversible from the UI by design — there is no
+   * re-enable entrance.
+   *
+   *   - remote present       → disable that row.
+   *   - bundled, no remote    → create a remote from the bundled config
+   *     first (so OAP has a row to flag), then disable it; otherwise a
+   *     bundled-fallback has no id to disable and the bundle keeps serving.
+   *   - neither               → nothing on OAP (a local-only browser draft
+   *     is removed client-side); no-op.
+   *
+   * Local-only drafts never reach here — the SPA removes them from the
+   * browser directly (a true hard delete; they were never on OAP). */
+  app.post<{ Body: { name?: string } }>(
+    '/api/admin/templates/disable',
+    { preHandler: auth },
+    async (req, reply) => {
+      const { name } = req.body ?? {};
+      if (typeof name !== 'string') {
+        return reply.code(400).send({
+          code: 'invalid_disable_body',
+          message: 'body must be { name: string }',
+        });
+      }
+      if (!parseName(name)) {
+        return reply.code(400).send({
+          code: 'invalid_template_name',
+          message: `expected horizon.<overview|layer|alert>.<key>, got ${JSON.stringify(name)}`,
+        });
+      }
+      const status = await loadStatus(deps);
+      if (status.unreachable) {
+        return reply.code(409).send({
+          code: 'oap_unreachable',
+          message: 'OAP admin port unreachable — templates are read-only',
+        });
+      }
+      const row = status.rows.find((r) => r.name === name);
+      if (!row?.remote && !row?.bundled) {
+        // Nothing on OAP and no bundle — already gone.
+        resync();
+        return reply.send(await loadStatus(deps));
+      }
+      try {
+        if (row.remote) {
+          await deps.uiTemplateClient().disable(row.remote.id);
+        } else if (row.bundled) {
+          // Bundled-fallback (no remote yet): materialise a remote from the
+          // bundled config so there's a row to flag disabled.
+          const created = await deps.uiTemplateClient().create(row.bundled.configuration);
+          await deps.uiTemplateClient().disable(created.id);
+        }
+        resync();
+        return reply.send(await loadStatus(deps));
+      } catch (err) {
+        logger.warn({ err: errMsg(err), name }, 'disable on OAP failed');
+        return reply.code(502).send({ code: 'oap_write_failed', message: errMsg(err) });
+      }
+    },
+  );
 }
 
 async function loadStatus(deps: TemplateSyncAdminDeps): Promise<SyncStatus> {
diff --git a/apps/bff/src/http/config/bundle.ts b/apps/bff/src/http/config/bundle.ts
index 7db97c4..49dc90c 100644
--- a/apps/bff/src/http/config/bundle.ts
+++ b/apps/bff/src/http/config/bundle.ts
@@ -142,11 +142,25 @@ async function buildBundle(deps: ConfigBundleDeps, preferLocal = false): Promise
   }
 
   const overviews: OverviewDashboard[] = [];
+  const diskOverviewIds = new Set<string>();
   for (const dash of loadOverviewDashboards()) {
+    diskOverviewIds.add(dash.id);
     const effective = pickOverviewContent(dash, remoteByName, preferLocal);
     if (effective === null) continue; // disabled
     overviews.push(effective);
   }
+  // Remote-only overviews: dashboards that live on OAP with no on-disk
+  // base — created in the admin UI and pushed. The disk loop can't see
+  // them, so surface them straight from the remote envelope. (Layers can't
+  // be remote-only: every layer ships a bundled template.)
+  for (const row of sync.rows) {
+    if (row.kind !== 'overview' || row.status === 'disabled' || !row.remote) continue;
+    const env = parseEnvelope(row.remote.configuration);
+    if (!env || !isOverviewLike(env.content)) continue;
+    const dash = env.content as OverviewDashboard;
+    if (diskOverviewIds.has(dash.id)) continue; // already handled above
+    overviews.push(dash);
+  }
 
   const syncStatus: BundleSyncStatus = {
     unreachable: sync.unreachable,
diff --git a/apps/bff/src/http/query/menu.ts b/apps/bff/src/http/query/menu.ts
index a122fc5..39965cd 100644
--- a/apps/bff/src/http/query/menu.ts
+++ b/apps/bff/src/http/query/menu.ts
@@ -22,12 +22,16 @@ import type {
   LayerDef,
   LayerSlots,
   MenuResponse,
+  UITemplateClient,
 } from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import { buildOapOpts, graphqlPost, type GraphqlOptions } from '../../client/graphql.js';
 import { allLayerTemplates, getLayerTemplate, type LayerComponentFlags } from '../../logic/layers/loader.js';
+import { getSyncStatus } from '../../logic/templates/sync.js';
+import { iterateBundledTemplates } from '../../logic/templates/aggregator.js';
+import { logger } from '../../logger.js';
 
 /**
  * Map the JSON config's `components.*` flags onto the wire `caps`
@@ -63,6 +67,34 @@ export interface MenuRouteDeps {
   config: ConfigSource;
   sessions: SessionStore;
   fetch?: FetchLike;
+  /** OAP UI-template client — lets the menu honor disabled layer templates
+   *  (a layer disabled in the admin disappears from the sidebar). Optional
+   *  so tests can omit it; without it no layer is filtered as disabled. */
+  uiTemplateClient?: () => UITemplateClient;
+}
+
+/** Canonical layer keys disabled on OAP (`horizon.layer.<KEY>` rows flagged
+ *  disabled). Reads the shared 30s sync cache; soft-fails to an empty set so
+ *  the sidebar never breaks because the template status couldn't be read. */
+async function disabledLayerKeys(deps: MenuRouteDeps): Promise<Set<string>> {
+  const out = new Set<string>();
+  if (!deps.uiTemplateClient) return out;
+  try {
+    const sync = await getSyncStatus({
+      client: deps.uiTemplateClient(),
+      bundled: () => iterateBundledTemplates(),
+      logger,
+    });
+    if (sync.unreachable) return out;
+    for (const row of sync.rows) {
+      if (row.kind === 'layer' && row.status === 'disabled') {
+        out.add(canonical(row.key.toUpperCase()));
+      }
+    }
+  } catch {
+    // Status unavailable — show every layer rather than hide wrongly.
+  }
+  return out;
 }
 
 // `listLayers` — active layers in this deployment.
@@ -295,8 +327,11 @@ export function registerMenuRoute(app: FastifyInstance, deps: MenuRouteDeps): vo
       // feedback. Add more keys here if other internal-only layers
       // need the same treatment.
       const HIDDEN_LAYERS = new Set(['BANYANDB']);
+      // Layers whose template was disabled in the admin — soft-deleted, so
+      // drop them from the sidebar (matches how disabled overviews vanish).
+      const disabled = await disabledLayerKeys(deps);
       const layers = ordered
-        .filter((key) => !HIDDEN_LAYERS.has(key))
+        .filter((key) => !HIDDEN_LAYERS.has(key) && !disabled.has(key))
         .map((key) =>
           deriveLayer(
             key,
diff --git a/apps/bff/src/rbac/route-policy.ts b/apps/bff/src/rbac/route-policy.ts
index e4dd6cb..0658daa 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -205,6 +205,7 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'POST /api/admin/templates/resync':              'overview:write',
   'POST /api/admin/templates/save':                'overview:write',
   'POST /api/admin/templates/save-local':          'overview:write',
+  'POST /api/admin/templates/disable':             'overview:write',
   'POST /api/admin/templates/revert-local':        'overview:write',
   'POST /api/admin/templates/:name/push-bundled':  'overview:write',
   'POST /api/admin/templates/sync-all':            'overview:write',
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 330c03e..1ca49f9 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -145,7 +145,11 @@ registerAuthHealthRoute(app, { config: source, ldapHealth });
 
 // ── Query ──────────────────────────────────────────────────────────
 registerOapInfoRoute(app, { config: source, sessions });
-registerMenuRoute(app, { config: source, sessions });
+registerMenuRoute(app, {
+  config: source,
+  sessions,
+  uiTemplateClient: () => buildOapClients(source.current).uiTemplate(),
+});
 registerLandingRoute(app, { config: source, sessions });
 registerInstanceRoute(app, { config: source, sessions });
 registerEndpointRoute(app, { config: source, sessions });
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index bf0bf22..b447c15 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -614,12 +614,16 @@ export class BffClient {
       // fetch() doesn't throw on HTTP-level errors (4xx/5xx) — only on
       // these. They're the "blocked" symptom operators see when the
       // BFF or its upstream is unreachable.
-      pushEvent(
-        'api',
-        'err',
-        `${method} ${path} · network ${err instanceof Error ? err.message : String(err)}`,
+      const detail = err instanceof Error ? err.message : String(err);
+      pushEvent('api', 'err', `${method} ${path} · network ${detail}`);
+      // Wrap into a clear, actionable message — the raw "Failed to fetch" /
+      // "Load failed" reads as a mystery to operators. status 0 = no HTTP
+      // response reached us at all (BFF down / unreachable).
+      throw new BffApiError(
+        0,
+        `Cannot reach the server — the BFF is unreachable (${detail}).`,
+        null,
       );
-      throw err;
     }
     if (res.status === 401) {
       // 401 is normal-ish (session expired) — log at 'info' rather
@@ -630,11 +634,19 @@ export class BffClient {
       throw new BffApiError(401, 'unauthenticated', null);
     }
     if (!res.ok) {
-      let parsed: unknown = null;
-      try {
-        parsed = await res.json();
-      } catch {
-        parsed = await res.text();
+      // Read the body ONCE as text, then try to parse it as JSON. Calling
+      // res.json() then res.text() in a fallback double-reads the stream
+      // ("body stream already read") and masks the real error — which is
+      // exactly what happens when a non-JSON error (e.g. a Vite proxy 502
+      // page when the BFF is down) comes back.
+      const raw = await res.text().catch(() => '');
+      let parsed: unknown = raw;
+      if (raw) {
+        try {
+          parsed = JSON.parse(raw);
+        } catch {
+          parsed = raw;
+        }
       }
       // Surface the BFF's `code` / `message` envelope when present so
       // the operator gets the actionable reason instead of just "500".
diff --git a/apps/ui/src/api/scopes/template-sync.ts b/apps/ui/src/api/scopes/template-sync.ts
index 88b311e..09abb43 100644
--- a/apps/ui/src/api/scopes/template-sync.ts
+++ b/apps/ui/src/api/scopes/template-sync.ts
@@ -78,6 +78,16 @@ export class TemplateSyncApi {
     });
   }
 
+  /** "Delete" a template — OAP has no hard DELETE, so this soft-disables
+   *  the remote UI-template. The BFF refuses (409 `bundled_not_deletable`)
+   *  when the template ships a bundled default; only remote-only templates
+   *  are genuinely removable. Returns the fresh sync status. */
+  disable(name: string): Promise<TemplateSyncStatus> {
+    return this.bff.request<TemplateSyncStatus>('POST', '/api/admin/templates/disable', {
+      name,
+    });
+  }
+
   /** Operator wants the bundled JSON to overwrite whatever OAP has for
    *  this name. Used for "adopt my code defaults" from the diff view. */
   pushBundled(name: string): Promise<TemplateSyncStatus> {
diff --git a/apps/ui/src/controls/configBundle.ts b/apps/ui/src/controls/configBundle.ts
index 95e73b4..877f0da 100644
--- a/apps/ui/src/controls/configBundle.ts
+++ b/apps/ui/src/controls/configBundle.ts
@@ -37,9 +37,35 @@ import { bffClient } from '@/api/client';
 import { pushEvent } from '@/controls/eventLog';
 import { debug } from '@/utils/debug';
 import { useTemplatePreference } from '@/controls/templatePreference';
+import { useLocalTemplateEdits, layerEditName, overviewEditName } from '@/controls/localTemplateEdits';
+import { usePreviewMode, getPreviewSource } from '@/controls/previewMode';
+import { usePreviewOverride } from '@/controls/previewOverride';
 import type { ConfigBundle, BundleScopeMap } from '@/api/scopes/configs';
 import type { DashboardWidget, OverviewDashboard } from '@skywalking-horizon-ui/api-client';
 
+// Browser-side unpublished drafts. Overlaid on live pages ONLY while the
+// route is in `?mode=preview` — the editor's explicit preview entrance.
+// Normal viewing always renders remote; the draft is never shown to other
+// users or in plain review.
+const localEdits = useLocalTemplateEdits();
+const previewMode = usePreviewMode();
+// The Preview dropdown can preview ANY source (local/bundled/remote); it
+// writes the chosen content here and this takes precedence over the draft.
+const previewOverride = usePreviewOverride();
+
+/** Content to overlay for `name` while previewing, or undefined.
+ *  `source=local` reads the LIVE local draft (so it follows Reset/Save —
+ *  no stale snapshot); `bundled`/`remote` read the override snapshot the
+ *  Preview dropdown captured. */
+function previewContentFor<T>(name: string): T | undefined {
+  if (!previewMode.value) return undefined;
+  const src = getPreviewSource();
+  if (src === 'local') return localEdits.get<T>(name);
+  if (src === 'bundled' || src === 'remote') return previewOverride.get<T>(name);
+  // No explicit source (hand-typed ?mode=preview) — override then draft.
+  return previewOverride.get<T>(name) ?? localEdits.get<T>(name);
+}
+
 /** `local` only when the operator opted to preview unpublished edits;
  *  otherwise `remote` (the default runtime source of truth). */
 function preferParam(): 'local' | 'remote' {
@@ -157,15 +183,40 @@ export function getDashboardConfig(
   layerKey: string,
   scope: 'service' | 'instance' | 'endpoint',
 ): DashboardWidget[] | null {
+  // In preview mode, overlay the previewed source's content for this layer.
+  type LayerContent = { dashboards?: Record<string, DashboardWidget[]>; widgets?: DashboardWidget[] };
+  const draft = previewContentFor<LayerContent>(layerEditName(layerKey));
+  if (draft) {
+    const d = draft.dashboards?.[scope] ?? (scope === 'service' ? draft.widgets : undefined);
+    if (d !== undefined) return d;
+  }
   const b = state.value;
   if (!b) return null;
   const layer = b.layers[layerKey.toLowerCase()] as BundleScopeMap | undefined;
   return layer?.[scope] ?? null;
 }
 
-/** Sync lookup. Returns null when the bundle hasn't loaded yet. */
+/** Sync lookup. Returns null when the bundle hasn't loaded yet. Local
+ *  browser drafts replace the matching overview for the editing operator. */
 export function getOverviews(): OverviewDashboard[] | null {
-  return state.value?.overviews ?? null;
+  const base = state.value?.overviews ?? null;
+  if (!base) return null;
+  if (!previewMode.value) return base;
+  // Overlay each overview with its previewed source's content…
+  const out = base.map((ov) => previewContentFor<OverviewDashboard>(overviewEditName(ov.id)) ?? ov);
+  // …and inject previewed overviews absent from the bundle.
+  const seen = new Set(base.map((o) => o.id));
+  for (const name of [...previewOverride.names(), ...localEdits.names()]) {
+    if (!name.startsWith('horizon.overview.')) continue;
+    const id = name.slice('horizon.overview.'.length);
+    if (seen.has(id)) continue;
+    const content = previewContentFor<OverviewDashboard>(name);
+    if (content) {
+      out.push(content);
+      seen.add(id);
+    }
+  }
+  return out;
 }
 
 export function useConfigBundle(): {
diff --git a/apps/ui/src/controls/localTemplateEdits.ts b/apps/ui/src/controls/localTemplateEdits.ts
new file mode 100644
index 0000000..041df09
--- /dev/null
+++ b/apps/ui/src/controls/localTemplateEdits.ts
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Browser-side TEMP store for in-progress dashboard-template edits.
+ *
+ * An operator's edit is a *local draft* that lives only in this browser
+ * (localStorage) until they explicitly push it to OAP. It is NOT written
+ * to the BFF's on-disk bundled files (those stay the pristine shipped
+ * defaults) and it is NOT shared with other users. The config bundle
+ * overlays these drafts on top of the remote/bundled content so the
+ * editing operator previews their own edits on live pages; everyone else
+ * keeps seeing remote.
+ *
+ * Keyed by the canonical template name the sync layer uses:
+ *   `horizon.layer.<KEY>`  /  `horizon.overview.<id>`
+ *
+ * Push-to-remote clears the entry (it's now the live version); reset-to-
+ * bundled also clears it (the operator abandoned the draft).
+ */
+
+import { ref, computed, type ComputedRef } from 'vue';
+
+const STORAGE_KEY = 'horizon:localTemplateEdits:v1';
+
+type EditMap = Record<string, unknown>;
+
+function read(): EditMap {
+  if (typeof localStorage === 'undefined') return {};
+  try {
+    const raw = localStorage.getItem(STORAGE_KEY);
+    return raw ? (JSON.parse(raw) as EditMap) : {};
+  } catch {
+    return {};
+  }
+}
+
+const edits = ref<EditMap>(read());
+
+function persist(): void {
+  if (typeof localStorage === 'undefined') return;
+  try {
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(edits.value));
+  } catch {
+    /* quota / disabled — in-memory copy still works for this session */
+  }
+}
+
+/** Canonical name builders — match the sync layer's `horizon.<kind>.<key>`. */
+export function layerEditName(key: string): string {
+  return `horizon.layer.${key.toUpperCase()}`;
+}
+export function overviewEditName(id: string): string {
+  return `horizon.overview.${id}`;
+}
+
+export function useLocalTemplateEdits(): {
+  /** Reactive map of name → draft content. */
+  edits: typeof edits;
+  /** Count of unpushed drafts (drives badges / the conflict prompt). */
+  count: ComputedRef<number>;
+  has: (name: string) => boolean;
+  get: <T = unknown>(name: string) => T | undefined;
+  set: (name: string, content: unknown) => void;
+  remove: (name: string) => void;
+  names: () => string[];
+} {
+  return {
+    edits,
+    count: computed(() => Object.keys(edits.value).length),
+    has: (name) => Object.prototype.hasOwnProperty.call(edits.value, name),
+    get: <T = unknown>(name: string) => edits.value[name] as T | undefined,
+    set: (name, content) => {
+      edits.value = { ...edits.value, [name]: content };
+      persist();
+    },
+    remove: (name) => {
+      if (!Object.prototype.hasOwnProperty.call(edits.value, name)) return;
+      const next = { ...edits.value };
+      delete next[name];
+      edits.value = next;
+      persist();
+    },
+    names: () => Object.keys(edits.value),
+  };
+}
diff --git a/apps/ui/src/controls/previewMode.ts b/apps/ui/src/controls/previewMode.ts
new file mode 100644
index 0000000..73453c0
--- /dev/null
+++ b/apps/ui/src/controls/previewMode.ts
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Whether the current route is previewing unpublished template content
+ * (`?mode=preview`), and from which source (`&source=local|bundled|remote`).
+ * Browser-side template drafts (controls/localTemplateEdits) +
+ * preview-overrides (controls/previewOverride) overlay live pages ONLY
+ * while this is on. The router keeps it sticky across in-app (menu)
+ * navigation and re-applies the query so the URL always reflects it —
+ * see shell/router/index.ts. Normal viewing always renders remote.
+ */
+
+import { ref } from 'vue';
+
+export type PreviewSource = 'local' | 'bundled' | 'remote';
+
+const previewMode = ref(false);
+const previewSource = ref<PreviewSource | null>(null);
+
+export function setPreviewMode(on: boolean, source?: string | null): void {
+  previewMode.value = on;
+  if (!on) {
+    previewSource.value = null;
+    return;
+  }
+  if (source === 'local' || source === 'bundled' || source === 'remote') {
+    previewSource.value = source;
+  }
+}
+
+export function usePreviewMode(): typeof previewMode {
+  return previewMode;
+}
+export function getPreviewSource(): PreviewSource | null {
+  return previewSource.value;
+}
diff --git a/apps/ui/src/controls/previewOverride.ts b/apps/ui/src/controls/previewOverride.ts
new file mode 100644
index 0000000..d8c4e27
--- /dev/null
+++ b/apps/ui/src/controls/previewOverride.ts
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Per-template "preview this exact content" override, used by the admin's
+ * Preview dropdown to render ANY source (local / bundled / remote) on the
+ * real page. The admin writes the chosen source's content here, then opens
+ * the live page in a new tab with `?mode=preview`; the config bundle
+ * overlay reads this override (taking precedence over the local draft).
+ *
+ * Stored in localStorage — NOT sessionStorage — because the preview opens
+ * in a new tab, which does not share sessionStorage. Keyed by the canonical
+ * template name (`horizon.layer.<KEY>` / `horizon.overview.<id>`).
+ */
+
+import { ref } from 'vue';
+
+const STORAGE_KEY = 'horizon:previewOverride:v1';
+type OverrideMap = Record<string, unknown>;
+
+function read(): OverrideMap {
+  if (typeof localStorage === 'undefined') return {};
+  try {
+    return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as OverrideMap;
+  } catch {
+    return {};
+  }
+}
+
+const overrides = ref<OverrideMap>(read());
+
+function persist(): void {
+  if (typeof localStorage === 'undefined') return;
+  try {
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides.value));
+  } catch {
+    /* quota / disabled — in-memory copy still works for this tab */
+  }
+}
+
+export function usePreviewOverride(): {
+  get: <T = unknown>(name: string) => T | undefined;
+  set: (name: string, content: unknown) => void;
+  clear: (name: string) => void;
+  names: () => string[];
+} {
+  return {
+    names: () => Object.keys(overrides.value),
+    get: <T = unknown>(name: string) => overrides.value[name] as T | undefined,
+    set: (name, content) => {
+      overrides.value = { ...overrides.value, [name]: content };
+      persist();
+    },
+    clear: (name) => {
+      if (!Object.prototype.hasOwnProperty.call(overrides.value, name)) return;
+      const next = { ...overrides.value };
+      delete next[name];
+      overrides.value = next;
+      persist();
+    },
+  };
+}
diff --git a/apps/ui/src/controls/sidebar.ts b/apps/ui/src/controls/sidebar.ts
new file mode 100644
index 0000000..1628ff3
--- /dev/null
+++ b/apps/ui/src/controls/sidebar.ts
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Collapse state for the main navigation sidebar. Shared between the
+ * shell grid (AppShell drives the `.sw` column width) and the sidebar
+ * itself (AppSidebar renders the expand/collapse affordance). Sticky
+ * per browser via localStorage so the operator's fold choice survives
+ * reloads. Defaults expanded.
+ */
+
+import { ref, watch } from 'vue';
+
+const STORAGE_KEY = 'horizon:sidebarCollapsed:v1';
+
+function detectInitial(): boolean {
+  if (typeof localStorage === 'undefined') return false;
+  return localStorage.getItem(STORAGE_KEY) === '1';
+}
+
+const collapsed = ref<boolean>(detectInitial());
+
+watch(collapsed, (on) => {
+  if (typeof localStorage === 'undefined') return;
+  try {
+    localStorage.setItem(STORAGE_KEY, on ? '1' : '0');
+  } catch {
+    /* private mode / quota — degrade silently */
+  }
+});
+
+export function useSidebar(): {
+  collapsed: typeof collapsed;
+  toggle: () => void;
+} {
+  return {
+    collapsed,
+    toggle: () => {
+      collapsed.value = !collapsed.value;
+    },
+  };
+}
diff --git a/apps/ui/src/features/admin/_shared/SyncAllButton.vue b/apps/ui/src/features/admin/_shared/SyncAllButton.vue
deleted file mode 100644
index 88d3e3d..0000000
--- a/apps/ui/src/features/admin/_shared/SyncAllButton.vue
+++ /dev/null
@@ -1,191 +0,0 @@
-<!--
-  Licensed to the Apache Software Foundation (ASF) under one or more
-  contributor license agreements.  See the NOTICE file distributed with
-  this work for additional information regarding copyright ownership.
-  The ASF licenses this file to You under the Apache License, Version 2.0
-  (the "License"); you may not use this file except in compliance with
-  the License.  You may obtain a copy of the License at
-
-      http://www.apache.org/licenses/LICENSE-2.0
-
-  Unless required by applicable law or agreed to in writing, software
-  distributed under the License is distributed on an "AS IS" BASIS,
-  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-  See the License for the specific language governing permissions and
-  limitations under the License.
--->
-<!--
-  "Sync all to OAP" for one template family. Pushes the bundled copy of
-  every template that differs from OAP (diverged) or is missing on OAP
-  (bundled-fallback) — already-synced templates are left untouched. The
-  push always goes through a manual confirm listing exactly what will be
-  written, since it mutates OAP's shared template store.
--->
-<script setup lang="ts">
-import { computed, ref } from 'vue';
-import Modal from '@/features/operate/_shared/Modal.vue';
-import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
-import { refreshConfigBundle, setTemplateRenderMode } from '@/controls/configBundle';
-import { useTemplatePreference } from '@/controls/templatePreference';
-import { bffClient } from '@/api/client';
-import type { TemplateKind } from '@/api/scopes/configs';
-
-const props = defineProps<{ kind: TemplateKind }>();
-
-const sync = useTemplateSync({ kind: props.kind });
-const pref = useTemplatePreference();
-
-// Effective render source for diverged templates (null defaults to remote).
-const renderMode = computed<'local' | 'remote'>(() => (pref.mode === 'local' ? 'local' : 'remote'));
-function setMode(m: 'local' | 'remote'): void {
-  if (renderMode.value !== m) void setTemplateRenderMode(m);
-}
-
-/** Templates whose bundled copy differs from OAP (push targets). */
-const diffNames = computed<string[]>(() => {
-  const s = sync.status.value;
-  if (!s) return [];
-  return s.badges
-    .filter(
-      (b) => b.kind === props.kind && (b.status === 'diverged' || b.status === 'bundled-fallback'),
-    )
-    .map((b) => b.name)
-    .sort();
-});
-
-const open = ref(false);
-const busy = ref(false);
-const result = ref<{ synced: number; failed: { name: string; error: string }[] } | null>(null);
-
-const disabled = computed(() => sync.readOnly.value || diffNames.value.length === 0);
-const buttonTitle = computed(() => {
-  if (sync.readOnly.value) return 'OAP unreachable — page is read-only';
-  if (diffNames.value.length === 0) return 'Everything already matches OAP — nothing to push';
-  return `Push ${diffNames.value.length} changed template(s) to OAP`;
-});
-
-function openConfirm(): void {
-  if (disabled.value) return;
-  result.value = null;
-  open.value = true;
-}
-
-async function confirmSync(): Promise<void> {
-  if (busy.value) return;
-  busy.value = true;
-  try {
-    const res = await bffClient.templateSync.syncAll(props.kind);
-    await refreshConfigBundle();
-    result.value = { synced: res.synced.length, failed: res.failed };
-    if (res.failed.length === 0) {
-      // Clean success — close shortly so the operator sees the count.
-      setTimeout(() => {
-        open.value = false;
-        result.value = null;
-      }, 1400);
-    }
-  } catch (err) {
-    result.value = { synced: 0, failed: [{ name: '—', error: err instanceof Error ? err.message : String(err) }] };
-  } finally {
-    busy.value = false;
-  }
-}
-</script>
-
-<template>
-  <span class="sab__display" title="Which version this session renders for templates that differ from OAP.">
-    <span class="sab__display-label">Showing</span>
-    <button class="sab__seg" :class="{ on: renderMode === 'local' }" type="button" @click="setMode('local')">Local</button>
-    <button class="sab__seg" :class="{ on: renderMode === 'remote' }" type="button" @click="setMode('remote')">Remote</button>
-  </span>
-  <button
-    class="sw-btn"
-    type="button"
-    :disabled="disabled"
-    :title="buttonTitle"
-    @click="openConfirm"
-  >
-    Sync all to OAP<span v-if="diffNames.length" class="sab__count">{{ diffNames.length }}</span>
-  </button>
-
-  <Modal :open="open" title="Sync all to OAP" @close="open = false">
-    <div v-if="!result" class="sab__body">
-      <p class="sab__lede">
-        Push the bundled copy of these <b>{{ diffNames.length }}</b> template(s) to OAP,
-        overwriting what OAP currently stores. Already-synced templates are not touched.
-        This affects every operator using this OAP.
-      </p>
-      <ul class="sab__list">
-        <li v-for="n in diffNames" :key="n" class="mono">{{ n }}</li>
-      </ul>
-    </div>
-    <div v-else class="sab__body">
-      <p class="sab__lede">
-        Pushed <b>{{ result.synced }}</b> template(s).
-        <span v-if="result.failed.length" class="sab__err">{{ result.failed.length }} failed.</span>
-      </p>
-      <ul v-if="result.failed.length" class="sab__list">
-        <li v-for="f in result.failed" :key="f.name" class="mono sab__err">{{ f.name }}: {{ f.error }}</li>
-      </ul>
-    </div>
-
-    <template #footer>
-      <button class="sw-btn ghost" type="button" @click="open = false">{{ result ? 'Close' : 'Cancel' }}</button>
-      <button
-        v-if="!result"
-        class="sw-btn primary"
-        type="button"
-        :disabled="busy || diffNames.length === 0"
-        @click="confirmSync"
-      >
-        {{ busy ? 'Pushing…' : `Push ${diffNames.length} to OAP` }}
-      </button>
-    </template>
-  </Modal>
-</template>
-
-<style scoped>
-.sab__count {
-  margin-left: 6px;
-  padding: 0 6px;
-  border-radius: 8px;
-  background: var(--sw-accent);
-  color: #1a1a1a;
-  font-size: 10px;
-  font-weight: 700;
-}
-.sab__display {
-  display: inline-flex;
-  align-items: center;
-  gap: 4px;
-  margin-right: 8px;
-  font-size: 11px;
-}
-.sab__display-label { color: var(--sw-fg-3); margin-right: 2px; }
-.sab__seg {
-  padding: 2px 8px;
-  border: 1px solid var(--sw-line);
-  background: transparent;
-  color: var(--sw-fg-2);
-  font: inherit;
-  font-size: 11px;
-  cursor: pointer;
-}
-.sab__seg:first-of-type { border-radius: 5px 0 0 5px; }
-.sab__seg:last-of-type { border-radius: 0 5px 5px 0; border-left: none; }
-.sab__seg.on { background: var(--sw-accent); color: #1a1a1a; border-color: var(--sw-accent); font-weight: 600; }
-.sab__body { padding: 4px 2px; }
-.sab__lede { margin: 0 0 10px; font-size: 12px; color: var(--sw-fg-2); line-height: 1.5; }
-.sab__list {
-  margin: 0;
-  padding: 8px 10px;
-  list-style: none;
-  max-height: 40vh;
-  overflow: auto;
-  border: 1px solid var(--sw-line);
-  border-radius: 6px;
-  background: var(--sw-bg-2);
-}
-.sab__list li { font-size: 11.5px; padding: 2px 0; color: var(--sw-fg-1); }
-.sab__err { color: var(--sw-err); }
-</style>
diff --git a/apps/ui/src/features/admin/_shared/useTemplateSources.ts b/apps/ui/src/features/admin/_shared/useTemplateSources.ts
new file mode 100644
index 0000000..c794ad9
--- /dev/null
+++ b/apps/ui/src/features/admin/_shared/useTemplateSources.ts
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Fetches the BFF sync rows for one template kind and exposes, per
+ * template name, the BUNDLED and REMOTE content objects — the two
+ * server-side versions of a template. The admin pages combine these
+ * with the browser LOCAL draft (controls/localTemplateEdits) to drive
+ * the Local / Bundled / Remote source selector in the editor.
+ *
+ * The row's `configuration` is the canonical envelope
+ * `{ name, kind, version, content }`; we return `content` (the actual
+ * template object).
+ */
+
+import { computed } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import { bff } from '@/api/client';
+import type { TemplateKind } from '@/api/scopes/configs';
+
+function envelopeContent<T>(configuration: string | undefined | null): T | null {
+  if (!configuration) return null;
+  try {
+    const env = JSON.parse(configuration) as { content?: unknown };
+    return (env?.content ?? null) as T | null;
+  } catch {
+    return null;
+  }
+}
+
+export function useTemplateSources(kind: TemplateKind) {
+  const q = useQuery({
+    queryKey: ['admin/template-sources', kind],
+    queryFn: () => bff.templateSync.syncStatus(),
+    staleTime: 30_000,
+  });
+
+  const rowsByName = computed(() => {
+    const m = new Map<string, { bundled: string | null; remote: string | null }>();
+    for (const r of q.data.value?.rows ?? []) {
+      if (r.kind !== kind) continue;
+      m.set(r.name, {
+        bundled: r.bundled?.configuration ?? null,
+        remote: r.remote?.configuration ?? null,
+      });
+    }
+    return m;
+  });
+
+  return {
+    isLoading: q.isLoading,
+    refetch: q.refetch,
+    /** Bundled (shipped) content for a template name, or null. */
+    bundled<T>(name: string): T | null {
+      return envelopeContent<T>(rowsByName.value.get(name)?.bundled);
+    },
+    /** Remote (OAP live) content for a template name, or null. */
+    remote<T>(name: string): T | null {
+      return envelopeContent<T>(rowsByName.value.get(name)?.remote);
+    },
+    /** True when OAP currently serves this template. */
+    hasRemote(name: string): boolean {
+      return !!rowsByName.value.get(name)?.remote;
+    },
+    /** Names of every template OAP currently serves (this kind). Lets an
+     *  admin page surface remote-only templates — ones that exist on OAP
+     *  with no bundled/disk base, e.g. a dashboard created + pushed here. */
+    remoteNames(): string[] {
+      const out: string[] = [];
+      for (const [name, row] of rowsByName.value) if (row.remote) out.push(name);
+      return out;
+    },
+    /** True when this template ships a bundled (built-in) default. Such a
+     *  template can't be deleted — removing the OAP copy just falls back
+     *  to the bundle, so the row would reappear. */
+    hasBundled(name: string): boolean {
+      return !!rowsByName.value.get(name)?.bundled;
+    },
+  };
+}
diff --git a/apps/ui/src/features/admin/_shared/useTemplateSync.ts b/apps/ui/src/features/admin/_shared/useTemplateSync.ts
index 76bb3e5..69370b8 100644
--- a/apps/ui/src/features/admin/_shared/useTemplateSync.ts
+++ b/apps/ui/src/features/admin/_shared/useTemplateSync.ts
@@ -37,6 +37,7 @@
 
 import { computed, type ComputedRef } from 'vue';
 import { useConfigBundle } from '@/controls/configBundle';
+import { useLocalTemplateEdits } from '@/controls/localTemplateEdits';
 import type {
   BundleSyncStatus,
   TemplateBadge,
@@ -54,6 +55,8 @@ export interface SyncBanner {
   detail?: string;
   /** Per-status counts for the kinds owned by this page. */
   counts: Partial<Record<TemplateStatus, number>>;
+  /** Unpublished local browser drafts for this page's kind. */
+  localCount: number;
 }
 
 export interface UseTemplateSyncOptions {
@@ -71,6 +74,12 @@ export interface UseTemplateSyncReturn {
 
 export function useTemplateSync(opts: UseTemplateSyncOptions): UseTemplateSyncReturn {
   const { bundle } = useConfigBundle();
+  const localEdits = useLocalTemplateEdits();
+
+  // Unpublished local browser drafts for this kind (`horizon.<kind>.*`).
+  const localCount = computed<number>(
+    () => localEdits.names().filter((n) => n.startsWith(`horizon.${opts.kind}.`)).length,
+  );
 
   const status = computed<BundleSyncStatus | null>(() => bundle.value?.syncStatus ?? null);
 
@@ -82,14 +91,21 @@ export function useTemplateSync(opts: UseTemplateSyncOptions): UseTemplateSyncRe
 
   const readOnly = computed<boolean>(() => status.value?.unreachable === true);
 
+  // Shown on diverged + clean banners so the operator always knows what
+  // the two axes mean.
+  const GLOSSARY =
+    'Diverged = the bundled (shipped) default differs from the version live on OAP — OAP wins at render time. ' +
+    'Local = unpublished edits saved only in this browser; publish with “Check diff & push”.';
+  const localSuffix = computed(() =>
+    localCount.value > 0
+      ? ` · ${localCount.value} local draft${localCount.value === 1 ? '' : 's'} in this browser`
+      : '',
+  );
+
   const banner = computed<SyncBanner>(() => {
     const s = status.value;
     if (!s) {
-      return {
-        severity: 'unknown',
-        message: 'Loading template sync status…',
-        counts: {},
-      };
+      return { severity: 'unknown', message: 'Loading template sync status…', counts: {}, localCount: localCount.value };
     }
     const counts: Partial<Record<TemplateStatus, number>> = {};
     for (const b of ownBadges.value) counts[b.status] = (counts[b.status] ?? 0) + 1;
@@ -106,28 +122,32 @@ export function useTemplateSync(opts: UseTemplateSyncOptions): UseTemplateSyncRe
           ? `Last successful sync: ${last}`
           : 'No successful sync yet since this BFF started.',
         counts,
+        localCount: localCount.value,
       };
     }
     const diverged = counts.diverged ?? 0;
     const remoteOnly = counts['remote-only'] ?? 0;
     const disabled = counts.disabled ?? 0;
-    if (diverged + remoteOnly + disabled > 0) {
+    if (diverged + remoteOnly + disabled > 0 || localCount.value > 0) {
       const parts: string[] = [];
       if (diverged > 0) parts.push(`${diverged} diverged`);
       if (remoteOnly > 0) parts.push(`${remoteOnly} remote-only`);
       if (disabled > 0) parts.push(`${disabled} disabled`);
+      if (localCount.value > 0) parts.push(`${localCount.value} local`);
       return {
-        severity: 'diverged',
-        message: `Synced from OAP — ${parts.join(', ')}.`,
-        detail:
-          'Diverged rows can be inspected and reconciled inline. Bundled is the seed; OAP is the source of truth at runtime.',
+        severity: localCount.value > 0 || diverged > 0 ? 'diverged' : 'clean',
+        message: `Synced from OAP — ${parts.length ? parts.join(', ') : 'all match bundled'}.`,
+        detail: GLOSSARY,
         counts,
+        localCount: localCount.value,
       };
     }
     return {
       severity: 'clean',
-      message: `Synced from OAP — all ${ownBadges.value.length} templates match bundled.`,
+      message: `Synced from OAP — all ${ownBadges.value.length} templates match bundled.${localSuffix.value}`,
+      detail: GLOSSARY,
       counts,
+      localCount: localCount.value,
     };
   });
 
diff --git a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index 9e2d24a..81ffedf 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -46,16 +46,21 @@ import type {
  *  ProcessRelation MQE. */
 type AdminScope = DashboardScope | 'networkProfiling';
 import { bff, bffClient } from '@/api/client';
+import { useLocalTemplateEdits, layerEditName } from '@/controls/localTemplateEdits';
+import { useTemplateSources } from '@/features/admin/_shared/useTemplateSources';
+import { usePreviewOverride } from '@/controls/previewOverride';
 import TimeChart from '@/components/charts/TimeChart.vue';
 import TopList from '@/components/charts/TopList.vue';
+import Sparkline from '@/components/charts/Sparkline.vue';
 import { fmtMetric } from '@/utils/formatters';
+import { stableStringify } from '@/utils/stableJson';
 import { mockCardValue, mockLineSeries, mockRecordRows, mockTopGroups } from './widget-mock';
 import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
-import SyncAllButton from '@/features/admin/_shared/SyncAllButton.vue';
 import { refreshConfigBundle } from '@/controls/configBundle';
 import TemplateStatusBadge from '@/features/admin/_shared/TemplateStatusBadge.vue';
-import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue';
 import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
+import Modal from '@/features/operate/_shared/Modal.vue';
+import MonacoDiff from '@/features/operate/_shared/MonacoDiff.vue';
 
 // OAP UI-template sync status for layer dashboards. Drives the banner +
 // Save gating + per-row badge below.
@@ -100,25 +105,29 @@ const selectedKey = ref<string>('');
 const activeScope = ref<AdminScope>('service');
 const isSaving = ref(false);
 const saveMsg = ref<string | null>(null);
-/** When false the layers rail collapses to a thin dot-strip so the
- *  editor can claim the full width. The toggle in the rail header
- *  flips this; layer switching still works via dot click. */
-const layerListOpen = ref(true);
+/** When false the browse rail is hidden entirely and layer switching
+ *  moves to the top dropdown inside the editor — the editor claims the
+ *  full width. Defaults collapsed; the rail is an opt-in browse mode.
+ *  Selecting a layer from the rail re-collapses it (see `selectLayer`). */
+const layerListOpen = ref(false);
 /** Free-text filter for the layers rail — matches alias or key. */
 const layerSearch = ref('');
 // When on, the list shows only layers whose bundled copy differs from
 // OAP (diverged) or isn't on OAP yet (bundled-fallback) — i.e. the set
 // "Sync all to OAP" would push. Off shows every layer.
 const divergedOnly = ref(false);
+const localOnly = ref(false);
 function isDivergedRow(key: string): boolean {
   const s = sync.badgeFor(`horizon.layer.${key}`);
   return s === 'diverged' || s === 'bundled-fallback';
 }
 const divergedCount = computed(() => templates.value.filter((t) => isDivergedRow(t.key)).length);
+const localCount = computed(() => templates.value.filter((t) => localEdits.has(layerEditName(t.key))).length);
 const filteredTemplates = computed<AdminLayerTemplate[]>(() => {
   const q = layerSearch.value.trim().toLowerCase();
   return templates.value.filter((t) => {
     if (divergedOnly.value && !isDivergedRow(t.key)) return false;
+    if (localOnly.value && !localEdits.has(layerEditName(t.key))) return false;
     if (!q) return true;
     return (t.alias ?? '').toLowerCase().includes(q) || t.key.toLowerCase().includes(q);
   });
@@ -128,6 +137,15 @@ const filteredTemplates = computed<AdminLayerTemplate[]>(() => {
  *  the Save / Reset state. */
 const draft = reactive<{ template: AdminLayerTemplate | null }>({ template: null });
 
+// Unpublished edits live in THIS browser (localStorage), not on the BFF
+// disk. The editor seeds from the browser draft when one exists, and the
+// config bundle overlays it on live pages — see controls/localTemplateEdits.
+const localEdits = useLocalTemplateEdits();
+// Server-side bundled + remote content for the Reset-to / Preview editor
+// sources. Local (browser draft) comes from `localEdits`.
+const sources = useTemplateSources('layer');
+const previewOverride = usePreviewOverride();
+
 async function loadAll(): Promise<void> {
   isLoading.value = true;
   error.value = null;
@@ -179,11 +197,125 @@ watch(
   },
 );
 
-function syncDraft(): void {
-  const tpl = templates.value.find((t) => t.key === selectedKey.value);
-  draft.template = tpl ? JSON.parse(JSON.stringify(tpl)) : null;
+/* ── Editor sources ───────────────────────────────────────────────
+ * The editor loads from one of three sources: LOCAL (browser draft),
+ * BUNDLED (shipped default), or REMOTE (OAP live). `editorSource` tracks
+ * which is loaded; `loadedSnapshot` is the content at load/save time and
+ * is the baseline for `dirty` (= unsaved edits since the last load/save).
+ * Saving always writes LOCAL; you then publish LOCAL → OAP.
+ * ─────────────────────────────────────────────────────────────────── */
+const editorSource = ref<'local' | 'bundled' | 'remote'>('bundled');
+const loadedSnapshot = ref<string>('');
+const editName = computed(() => layerEditName(selectedKey.value));
+const hasLocalDraft = computed(() => localEdits.has(editName.value));
+const remoteAvailable = computed(() => sources.hasRemote(editName.value));
+// Ships a bundled default → not deletable (delete would fall back to the
+// bundle, so the row reappears). Drives the disabled delete button + the
+// guard in deleteCurrentLayer. Effectively true for every shipped layer.
+const bundledExists = computed(() => sources.hasBundled(editName.value));
+/** Whether a given layer key has an unpublished local browser draft —
+ *  drives the "local" badge in the picker (the sync badge only reflects
+ *  bundled-vs-remote, not your local draft). */
+function hasLocalDraftFor(key: string): boolean {
+  return localEdits.has(layerEditName(key));
+}
+
+function bundledContent(): AdminLayerTemplate | undefined {
+  return (
+    sources.bundled<AdminLayerTemplate>(editName.value) ??
+    templates.value.find((t) => t.key === selectedKey.value)
+  );
+}
+function sourceContent(src: 'local' | 'bundled' | 'remote'): AdminLayerTemplate | null | undefined {
+  if (src === 'local') return localEdits.get<AdminLayerTemplate>(editName.value);
+  if (src === 'remote') return sources.remote<AdminLayerTemplate>(editName.value);
+  return bundledContent();
+}
+function loadFrom(src: 'local' | 'bundled' | 'remote'): void {
+  const c = sourceContent(src);
+  draft.template = c ? JSON.parse(JSON.stringify(c)) : null;
+  loadedSnapshot.value = draft.template ? JSON.stringify(draft.template) : '';
+  editorSource.value = src;
   saveMsg.value = null;
 }
+/** Seed the editor when the selected layer changes: your local draft if
+ *  you have one, else the bundled default (always available; "Reset to →
+ *  remote" pulls the live version). */
+function syncDraft(): void {
+  loadFrom(hasLocalDraft.value ? 'local' : 'bundled');
+}
+
+/** Write the editor content to the local draft. If it equals remote, the
+ *  draft is cleared instead (no point keeping a draft identical to live) —
+ *  this is what makes "Reset to remote" + an edit back to remote disable
+ *  Push. Drives `hasLocalDraft` / `localDiffersFromRemote` reactively. */
+function persistLocal(content: AdminLayerTemplate): void {
+  const remote = sources.remote<AdminLayerTemplate>(editName.value);
+  if (remote && stableStringify(content) === stableStringify(remote)) {
+    localEdits.remove(editName.value);
+  } else {
+    localEdits.set(editName.value, JSON.parse(JSON.stringify(content)));
+  }
+  loadedSnapshot.value = JSON.stringify(content);
+}
+
+// "Reset to ▾" dropdown — reload the editor from a source AND adopt it as
+// the local draft (so the choice persists + Push/diff status updates).
+const resetDropdownOpen = ref(false);
+function resetTo(src: 'bundled' | 'remote'): void {
+  loadFrom(src);
+  if (draft.template) persistLocal(draft.template);
+  resetDropdownOpen.value = false;
+}
+
+/** Rail click: switch layer and collapse the rail so the editor reclaims
+ *  the full width (subsequent switching uses the top dropdown). */
+function selectLayer(key: string): void {
+  selectedKey.value = key;
+  layerListOpen.value = false;
+}
+
+/** Collapsed-mode switcher dropdown. Reuses the same `layerSearch` +
+ *  `filteredTemplates` as the rail so the two browse surfaces filter
+ *  identically — no native <select>. */
+const layerDropdownOpen = ref(false);
+function pickFromDropdown(key: string): void {
+  selectedKey.value = key;
+  layerDropdownOpen.value = false;
+}
+
+/** First per-layer tab segment for the live route, derived from enabled
+ *  components (mirrors the sidebar's firstLayerTab fallback order). */
+function firstTabFor(tpl: AdminLayerTemplate | null): string {
+  const c = tpl?.components ?? {};
+  if (c.service) return 'service';
+  if (c.instances) return 'instance';
+  if (c.endpoints) return 'endpoint';
+  if (c.topology) return 'topology';
+  if (c.endpointDependency) return 'dependency';
+  if (c.traces) return 'trace';
+  if (c.logs) return 'logs';
+  if (c.traceProfiling) return 'trace-profiling';
+  if (c.ebpfProfiling) return 'ebpf-profiling';
+  if (c.asyncProfiling) return 'async-profiling';
+  return 'service';
+}
+// "Preview ▾" dropdown — open the REAL layer page in a new tab rendering
+// the chosen source (local / bundled / remote). Writes the content to the
+// preview-override store so the new tab's overlay renders exactly it
+// (?mode=preview also lets LayerShell render layers OAP doesn't list).
+const previewDropdownOpen = ref(false);
+function previewLive(src: 'local' | 'bundled' | 'remote'): void {
+  const content = sourceContent(src);
+  if (!content) return;
+  previewOverride.set(editName.value, content);
+  const href = router.resolve({
+    path: `/layer/${selectedKey.value}/${firstTabFor(content)}`,
+    query: { mode: 'preview', source: src },
+  }).href;
+  previewDropdownOpen.value = false;
+  window.open(href, '_blank', 'noopener');
+}
 
 watch(selectedKey, syncDraft);
 onMounted(loadAll);
@@ -222,10 +354,10 @@ watch(visibleScopes, (scopes) => {
   }
 });
 
+// Unsaved keystrokes: editor differs from the content last loaded/saved.
 const dirty = computed(() => {
-  const original = templates.value.find((t) => t.key === selectedKey.value);
-  if (!original || !draft.template) return false;
-  return JSON.stringify(original) !== JSON.stringify(draft.template);
+  if (!draft.template) return false;
+  return JSON.stringify(draft.template) !== loadedSnapshot.value;
 });
 
 function widgetsFor(scope: AdminScope): DashboardWidget[] {
@@ -486,39 +618,49 @@ function textToExpressions(s: string): string[] {
     .filter((x) => x.length > 0);
 }
 
-// Diverged-row diff & reset: when OAP's copy of the selected layer
-// differs from bundled, opening the diff modal shows a side-by-side
-// and lets the operator reset OAP back to bundled (with
-// destructive-confirm — must type the layer key).
-const selectedStatus = computed(() =>
-  selectedKey.value ? sync.badgeFor(`horizon.layer.${selectedKey.value}`) : null,
-);
-const isDiverged = computed(() => selectedStatus.value === 'diverged');
-const diffModalOpen = ref(false);
-function openDiffModal(): void { diffModalOpen.value = true; }
-function onDiffReset(): void {
-  saveMsg.value = 'OAP reset to bundled. Reload to see widget changes.';
-  setTimeout(() => (saveMsg.value = null), 2400);
+
+/** Save the current editor state to the browser local draft. This is the
+ *  only "save" — it never writes OAP. Publish later with "Push local → OAP". */
+function save(): void {
+  if (!draft.template) return;
+  persistLocal(draft.template);
+  editorSource.value = 'local';
+  saveMsg.value = 'Saved to your browser (local). Publish with “Check diff & push”.';
+  setTimeout(() => (saveMsg.value = null), 6000);
 }
 
-async function save(): Promise<void> {
-  if (!draft.template || isSaving.value) return;
+// Push = publish local → remote. Available only when a local draft exists
+// AND differs from remote; the confirm modal shows the remote→local diff.
+const pushDiffOpen = ref(false);
+// Key-stable so a one-field edit doesn't read as "everything changed" —
+// OAP stores keys alphabetically; the local draft is in authored order.
+function prettyJson(o: unknown): string {
+  return o ? stableStringify(o, 2) : '';
+}
+const localDiffersFromRemote = computed<boolean>(() => {
+  const local = localEdits.get<AdminLayerTemplate>(editName.value);
+  if (!local) return false;
+  return stableStringify(local) !== stableStringify(sources.remote<AdminLayerTemplate>(editName.value) ?? null);
+});
+const pushLocalPretty = computed(() => prettyJson(localEdits.get(editName.value)));
+const pushRemotePretty = computed(() => prettyJson(sources.remote(editName.value)));
+
+/** Publish the local draft to OAP (live for everyone), then clear it and
+ *  reload from remote. */
+async function pushToOap(): Promise<void> {
+  const local = localEdits.get<AdminLayerTemplate>(editName.value);
+  if (!local || isSaving.value) return;
   isSaving.value = true;
   saveMsg.value = null;
   try {
-    // Save writes the LOCAL bundled template (not OAP) — the edit
-    // renders locally immediately and shows as diverged until it's
-    // published to OAP via "Sync all to OAP". Works even when OAP is
-    // unreachable.
-    await bff.templateSync.saveLocal(`horizon.layer.${draft.template.key}`, draft.template);
-    const idx = templates.value.findIndex((t) => t.key === selectedKey.value);
-    if (idx >= 0) {
-      templates.value[idx] = JSON.parse(JSON.stringify(draft.template));
-    }
-    syncDraft();
-    await refreshConfigBundle(); // refresh diverged badges + sidebar warning
-    saveMsg.value = 'Saved locally — not yet live for others. Use “Sync all to OAP” to publish this change.';
-    setTimeout(() => (saveMsg.value = null), 7000);
+    await bff.templateSync.save(editName.value, local);
+    localEdits.remove(editName.value);
+    previewOverride.clear(editName.value); // drop the now-stale preview snapshot
+    await Promise.all([sources.refetch(), refreshConfigBundle()]);
+    loadFrom('remote');
+    pushDiffOpen.value = false;
+    saveMsg.value = 'Published your local draft to OAP — now live for everyone.';
+    setTimeout(() => (saveMsg.value = null), 6000);
   } catch (err) {
     saveMsg.value = err instanceof Error ? err.message : String(err);
   } finally {
@@ -526,8 +668,105 @@ async function save(): Promise<void> {
   }
 }
 
-function reset(): void {
-  syncDraft();
+// ── Disable / reactivate (OAP has no hard DELETE) ──────────────────
+// Disabling soft-disables the layer on OAP: a disabled template drops out
+// of the bundle AND the menu, so the layer disappears from the sidebar.
+// Reactivating re-pushes the bundled default to OAP, which clears the
+// disabled flag and the layer reappears. A purely local draft (no OAP
+// presence) is just removed from the browser.
+//
+// Styled confirm (replaces window.confirm — native box breaks the dark
+// design). One modal serves both actions via a stored `confirmFn`.
+const isLayerDisabled = computed(() => sync.badgeFor(editName.value) === 'disabled');
+const deleteOpen = ref(false);
+const confirmTitle = ref('');
+const confirmMessage = ref('');
+const confirmLabel = ref('');
+const confirmIsDanger = ref(true);
+let confirmFn: (() => void | Promise<void>) | null = null;
+function runConfirm(): void {
+  deleteOpen.value = false;
+  const fn = confirmFn;
+  confirmFn = null;
+  if (fn) void fn();
+}
+function askDeleteLayer(): void {
+  const key = selectedKey.value;
+  if (!key || !editName.value) return;
+  const onOap = remoteAvailable.value || bundledExists.value;
+  if (!onOap) {
+    confirmTitle.value = 'Remove local draft?';
+    confirmMessage.value = `Remove the local draft for “${key}”? It was never published — this clears it from this browser only.`;
+    confirmLabel.value = 'Remove draft';
+  } else if (bundledExists.value) {
+    confirmTitle.value = 'Disable built-in layer?';
+    confirmMessage.value = `Disable the built-in “${key}” layer? It's soft-disabled on OAP and disappears from the sidebar for everyone. You can bring it back later with Reactivate.`;
+    confirmLabel.value = 'Disable';
+  } else {
+    confirmTitle.value = 'Delete layer template?';
+    confirmMessage.value = `Delete the “${key}” layer template? OAP has no hard delete, so it's soft-disabled — hidden from everyone.`;
+    confirmLabel.value = 'Delete';
+  }
+  confirmIsDanger.value = true;
+  confirmFn = doDeleteLayer;
+  deleteOpen.value = true;
+}
+function askReactivateLayer(): void {
+  const key = selectedKey.value;
+  if (!key || !editName.value) return;
+  confirmTitle.value = 'Reactivate layer?';
+  confirmMessage.value = `Reactivate the “${key}” layer? This re-enables it on OAP from the bundled default — it reappears in the sidebar for everyone.`;
+  confirmLabel.value = 'Reactivate';
+  confirmIsDanger.value = false;
+  confirmFn = doReactivateLayer;
+  deleteOpen.value = true;
+}
+async function doDeleteLayer(): Promise<void> {
+  const key = selectedKey.value;
+  const name = editName.value;
+  if (!key || !name) return;
+  const onOap = remoteAvailable.value || bundledExists.value;
+  if (!onOap) {
+    localEdits.remove(name);
+    saveMsg.value = `Removed local draft for "${key}".`;
+    setTimeout(() => (saveMsg.value = null), 6000);
+    return;
+  }
+  isSaving.value = true;
+  saveMsg.value = null;
+  try {
+    await bff.templateSync.disable(name);
+    localEdits.remove(name);
+    await Promise.all([sources.refetch(), refreshConfigBundle()]);
+    saveMsg.value = `Disabled "${key}" on OAP.`;
+    setTimeout(() => (saveMsg.value = null), 6000);
+  } catch (err) {
+    saveMsg.value = err instanceof Error ? err.message : String(err);
+  } finally {
+    isSaving.value = false;
+  }
+}
+async function doReactivateLayer(): Promise<void> {
+  const key = selectedKey.value;
+  const name = editName.value;
+  if (!key || !name) return;
+  // Re-push the bundled default; the BFF update clears OAP's disabled flag.
+  const content = bundledContent();
+  if (!content) return;
+  isSaving.value = true;
+  saveMsg.value = null;
+  try {
+    await bff.templateSync.save(name, content);
+    localEdits.remove(name);
+    await Promise.all([sources.refetch(), refreshConfigBundle()]);
+    loadFrom('remote');
+    saveMsg.value = `Reactivated "${key}".`;
+    setTimeout(() => (saveMsg.value = null), 6000);
+  } catch (err) {
+    saveMsg.value = err instanceof Error ? err.message : String(err);
+  } finally {
+    isSaving.value = false;
+  }
 }
 
 const selectedTpl = computed(() => draft.template);
@@ -729,6 +968,55 @@ function deleteMetricColumn(i: number): void {
   draft.template.metrics.columns.splice(i, 1);
 }
 
+/** Sample rows for the service-list preview — three fake services with
+ *  deterministic per-column values so the author sees how the configured
+ *  columns render (label, scale, precision, unit, default-sort marker)
+ *  without firing any MQE. */
+const SAMPLE_SERVICES = ['checkout', 'inventory', 'gateway'];
+function previewBase(seed: number): number {
+  return [42.37, 1280.5, 0.918][seed % 3] * (1 + (seed % 5) * 0.35);
+}
+function previewCell(col: { scale?: number; precision?: number; unit?: string }, seed: number): string {
+  const v = previewBase(seed) * (col.scale ?? 1);
+  const num = col.precision != null ? v.toFixed(col.precision) : fmtMetric(v);
+  return col.unit ? `${num} ${col.unit}` : num;
+}
+/** The metric the service list sorts by — explicit `orderBy`, else the
+ *  first column (mirrors the renderer's fallback). */
+const effectiveOrderBy = computed(
+  () => metricsModel.value?.orderBy ?? metricsColumns.value[0]?.metric,
+);
+
+type MetricColumn = NonNullable<NonNullable<AdminLayerTemplate['metrics']>['columns']>[number];
+/** Headline column for the landing KPI tile: explicit `overview.throughput`,
+ *  else the default-sort column. */
+const kpiHeadlineCol = computed<MetricColumn | undefined>(() => {
+  const key = overviewModel.value?.throughput ?? effectiveOrderBy.value;
+  return metricsColumns.value.find((c) => c.metric === key) ?? metricsColumns.value[0];
+});
+/** Trend column for the sparkline: explicit `overview.spark`, else the
+ *  headline column. */
+const kpiSparkCol = computed<MetricColumn | undefined>(() => {
+  const key = overviewModel.value?.spark ?? overviewModel.value?.throughput ?? effectiveOrderBy.value;
+  return metricsColumns.value.find((c) => c.metric === key) ?? kpiHeadlineCol.value;
+});
+/** Headline number — fmtMetric of a mock aggregate for the headline
+ *  column (the real tile shows the value bare; unit lives in the label). */
+const kpiHeadlineValue = computed(() =>
+  kpiHeadlineCol.value ? fmtMetric(previewBase(1) * (kpiHeadlineCol.value.scale ?? 1)) : '—',
+);
+/** Mock sparkline series for the real Sparkline component — deterministic
+ *  per spark-column so the trend reads as real movement without MQE. */
+const kpiSparkValues = computed<number[]>(() => {
+  const seed = (kpiSparkCol.value?.metric ?? 'x').length;
+  const n = 24;
+  const a: number[] = [];
+  for (let i = 0; i < n; i++) a.push(14 + Math.sin(i * 0.7 + seed) * 7 + Math.sin(i * 0.23 + seed) * 4);
+  return a;
+});
+/** Two-letter mark for the header icon tile (mirrors the live header). */
+const previewInitials = computed(() => (selectedTpl.value?.key ?? '??').slice(0, 2).toUpperCase());
+
 /**
  * Scope-aware `visibleWhen` placeholder + hover hint. Two supported
  * predicate forms:
@@ -800,6 +1088,105 @@ function toggleComponent(key: ComponentKey): void {
   const c = ensureComponents();
   c[key] = !c[key];
 }
+
+/** Inverse of SCOPE_COMPONENT, narrowed to the toggle keys — maps each
+ *  Components checkbox to the editor scope its config lives under, so a
+ *  menu-preview click can jump straight to that scope's widget editor. */
+const COMPONENT_SCOPE: Record<ComponentKey, AdminScope> = {
+  service: 'service',
+  instances: 'instance',
+  endpoints: 'endpoint',
+  endpointDependency: 'dependency',
+  topology: 'topology',
+  traces: 'trace',
+  logs: 'logs',
+  traceProfiling: 'traceProfiling',
+  ebpfProfiling: 'ebpfProfiling',
+  asyncProfiling: 'asyncProfiling',
+  // Legacy umbrella flag — no checkbox of its own (the three granular
+  // profiling toggles drive the menu), so this entry only satisfies the
+  // exhaustive Record; it is never surfaced as a menu item.
+  profiling: 'traceProfiling',
+};
+/** Component → slot-alias key. The layer's `slots` carry per-layer term
+ *  aliases (services → "ClickHouse clusters", instances → "Nodes", …);
+ *  the menu preview shows these instead of the generic component label,
+ *  matching what the real sidebar renders. */
+const COMPONENT_SLOT: Partial<Record<ComponentKey, keyof NonNullable<AdminLayerTemplate['slots']>>> = {
+  service: 'services',
+  instances: 'instances',
+  endpoints: 'endpoints',
+  endpointDependency: 'endpointDependency',
+};
+/** The layer's sidebar menu as the operator would see it — only the
+ *  enabled components, in COMPONENT_TOGGLES order, labelled with the
+ *  layer's slot aliases where defined. Drives the menu preview. */
+const menuItems = computed(() => {
+  const slots = draft.template?.slots ?? {};
+  return COMPONENT_TOGGLES.filter((t) => !!draft.template?.components?.[t.key]).map((t) => {
+    const slotKey = COMPONENT_SLOT[t.key];
+    return {
+      key: t.key,
+      label: (slotKey && slots[slotKey]) || t.label,
+      scope: COMPONENT_SCOPE[t.key],
+    };
+  });
+});
+/** Menu-preview click: focus the component's scope (if surfaced) and
+ *  scroll the scope editor into view so config + preview follow the
+ *  selection. */
+function jumpToComponent(scope: AdminScope): void {
+  if (visibleScopes.value.includes(scope)) activeScope.value = scope;
+  void nextTick(() => {
+    document.getElementById('scope-editor')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+  });
+}
+
+type SlotKey = keyof NonNullable<AdminLayerTemplate['slots']>;
+/** Scope → slot-alias key, for the scopes that carry a configurable
+ *  noun. Drives alias-aware scope-tab + section-heading labels so the
+ *  admin reads in the layer's own vocabulary (e.g. "Nodes" not
+ *  "instance"). */
+const SCOPE_SLOT: Partial<Record<AdminScope, SlotKey>> = {
+  service: 'services',
+  instance: 'instances',
+  endpoint: 'endpoints',
+  dependency: 'endpointDependency',
+};
+function scopeLabel(s: AdminScope): string {
+  const sk = SCOPE_SLOT[s];
+  return (sk && draft.template?.slots?.[sk]) || SCOPE_LABELS[s];
+}
+
+/** The four configurable slot aliases. Shown for the components the
+ *  layer actually exposes so the editor mirrors the menu. */
+const ALIAS_FIELDS: Array<{ slot: SlotKey; label: string; comp: ComponentKey; def: string }> = [
+  { slot: 'services', label: 'Services', comp: 'service', def: 'Service' },
+  { slot: 'instances', label: 'Instances', comp: 'instances', def: 'Instance' },
+  { slot: 'endpoints', label: 'Endpoints', comp: 'endpoints', def: 'Endpoint' },
+  { slot: 'endpointDependency', label: 'API dependency', comp: 'endpointDependency', def: 'Dependency' },
+];
+const visibleAliasFields = computed(() =>
+  ALIAS_FIELDS.filter((f) => !!draft.template?.components?.[f.comp]),
+);
+function ensureSlots(): NonNullable<AdminLayerTemplate['slots']> {
+  if (!draft.template) throw new Error('no template selected');
+  if (!draft.template.slots) (draft.template as AdminLayerTemplate).slots = {};
+  return draft.template.slots;
+}
+/** Write a slot alias. `slots` is the canonical field the loader reads;
+ *  mirror to the JSON's legacy `aliases` so the saved file stays
+ *  internally consistent (the loader prefers `slots`, but keeping both
+ *  in step avoids a confusing stale `aliases` block in the bundle). */
+function setSlot(slot: SlotKey, value: string): void {
+  const s = ensureSlots();
+  const v = value.trim();
+  if (v) s[slot] = v;
+  else delete s[slot];
+  const a = ((draft.template as { aliases?: Record<string, string> }).aliases ??= {});
+  if (v) a[slot] = v;
+  else delete a[slot];
+}
 function setVisibility(v: 'public' | 'operate'): void {
   if (!draft.template) return;
   // `public` is the implicit default — drop the field rather than
@@ -922,116 +1309,243 @@ const namingTest = computed<NamingTestResult>(() => {
 
     <SyncStatusBanner :banner="sync.banner.value" />
 
-    <div class="top-actions">
-      <label class="diverged-filter" :class="{ on: divergedOnly }" :title="divergedCount === 0 ? 'No layers differ from OAP' : `${divergedCount} layer(s) differ from OAP`">
-        <input v-model="divergedOnly" type="checkbox" :disabled="divergedCount === 0" />
-        Diverged only<span v-if="divergedCount" class="diverged-count">{{ divergedCount }}</span>
-      </label>
-      <SyncAllButton kind="layer" />
-    </div>
-
     <div v-if="error" class="banner err">{{ error }}</div>
     <div v-if="isLoading" class="empty">Loading templates…</div>
     <div v-else-if="templates.length === 0" class="empty">No layer templates loaded.</div>
 
     <div v-else class="grid" :class="{ 'list-collapsed': !layerListOpen }">
-      <aside class="sw-card layer-list" :class="{ collapsed: !layerListOpen }">
+      <!-- Browse rail. Shown only while expanded; selecting a layer
+           collapses it so the editor claims the full width. Once
+           collapsed, layer switching moves to the top dropdown inside
+           the detail pane (no left zone consumed). -->
+      <aside v-if="layerListOpen" class="sw-card layer-list">
         <div class="list-head">
           <button
             class="list-toggle"
             type="button"
-            :title="layerListOpen ? 'Collapse the layers list' : 'Expand the layers list'"
-            @click="layerListOpen = !layerListOpen"
+            title="Collapse the layers list"
+            @click="layerListOpen = false"
           >
-            <span class="caret" :class="{ open: layerListOpen }">›</span>
+            <span class="caret open">›</span>
           </button>
-          <h4 v-if="layerListOpen">Layers</h4>
-          <span v-if="layerListOpen" class="sub">
+          <h4>Layers</h4>
+          <span class="sub">
             {{ layerSearch.trim()
               ? `${filteredTemplates.length} / ${templates.length}`
               : `${templates.length} template${templates.length === 1 ? '' : 's'}` }}
           </span>
         </div>
-        <template v-if="layerListOpen">
-          <div class="list-search">
-            <input
-              v-model="layerSearch"
-              type="text"
-              class="list-search-input"
-              placeholder="Search layers…"
-              autocomplete="off"
-              spellcheck="false"
-            />
-          </div>
-          <button
-            v-for="t in filteredTemplates"
-            :key="t.key"
-            class="layer-row"
-            :class="{ active: selectedKey === t.key }"
-            @click="selectedKey = t.key"
-          >
-            <span class="dot" :style="{ background: t.color || 'var(--sw-fg-3)' }" />
-            <span class="name">{{ t.alias || t.key }}</span>
-            <TemplateStatusBadge :status="sync.badgeFor(`horizon.layer.${t.key}`)" />
-          </button>
-          <p v-if="filteredTemplates.length === 0" class="list-empty">
-            {{ divergedOnly && !layerSearch.trim() ? 'No layers differ from OAP.' : `No layers match “${layerSearch}”.` }}
-          </p>
-        </template>
-        <!-- Collapsed mode shows just colored dots for navigation; click
-             a dot to switch layers without expanding. -->
-        <template v-else>
-          <button
-            v-for="t in templates"
-            :key="t.key"
-            class="layer-row collapsed-row"
-            :class="{ active: selectedKey === t.key }"
-            :title="t.alias || t.key"
-            @click="selectedKey = t.key"
-          >
-            <span class="dot" :style="{ background: t.color || 'var(--sw-fg-3)' }" />
-          </button>
-        </template>
+        <div class="list-search">
+          <input
+            v-model="layerSearch"
+            type="text"
+            class="list-search-input"
+            placeholder="Search layers…"
+            autocomplete="off"
+            spellcheck="false"
+          />
+        </div>
+        <div class="dd-filters">
+          <label class="diverged-filter" :class="{ on: divergedOnly }" :title="divergedCount === 0 ? 'No layers differ from OAP' : `${divergedCount} layer(s) differ from the OAP-stored copy`">
+            <input v-model="divergedOnly" type="checkbox" :disabled="divergedCount === 0" />
+            Diverged<span v-if="divergedCount" class="diverged-count">{{ divergedCount }}</span>
+          </label>
+          <label class="diverged-filter local" :class="{ on: localOnly }" :title="localCount === 0 ? 'No unpublished local drafts in this browser' : `${localCount} layer(s) with an unpublished local draft`">
+            <input v-model="localOnly" type="checkbox" :disabled="localCount === 0" />
+            Local<span v-if="localCount" class="diverged-count local">{{ localCount }}</span>
+          </label>
+        </div>
+        <button
+          v-for="t in filteredTemplates"
+          :key="t.key"
+          class="layer-row"
+          :class="{ active: selectedKey === t.key }"
+          @click="selectLayer(t.key)"
+        >
+          <span class="dot" :style="{ background: t.color || 'var(--sw-fg-3)' }" />
+          <span class="name">{{ t.alias || t.key }}</span>
+          <code v-if="t.alias && t.alias !== t.key" class="key-tag">{{ t.key }}</code>
+          <span v-if="hasLocalDraftFor(t.key)" class="local-badge" title="Unpublished local draft">local</span>
+          <TemplateStatusBadge :status="sync.badgeFor(`horizon.layer.${t.key}`)" />
+        </button>
+        <p v-if="filteredTemplates.length === 0" class="list-empty">
+          {{ divergedOnly && !layerSearch.trim() ? 'No layers differ from OAP.' : `No layers match “${layerSearch}”.` }}
+        </p>
       </aside>
 
       <main v-if="selectedTpl" class="detail">
+        <!-- Collapsed-rail layer switcher: a filterable dropdown at the
+             top of the editor so the left zone stays free. Same search +
+             filter as the rail (not a native select). -->
+        <div v-if="!layerListOpen" class="layer-switch-bar">
+            <div class="layer-dd">
+              <button
+                class="layer-dd-btn"
+                type="button"
+                @click="layerDropdownOpen = !layerDropdownOpen"
+              >
+              <span class="dot inline" :style="{ background: selectedTpl.color || 'var(--sw-fg-3)' }" />
+              <span class="layer-dd-name">{{ selectedTpl.alias || selectedTpl.key }}</span>
+              <code v-if="selectedTpl.alias && selectedTpl.alias !== selectedTpl.key" class="key-tag">{{ selectedTpl.key }}</code>
+              <span v-if="hasLocalDraftFor(selectedTpl.key)" class="local-badge" title="Unpublished local draft in this browser">local</span>
+              <TemplateStatusBadge :status="sync.badgeFor(`horizon.layer.${selectedTpl.key}`)" />
+              <span class="caret dd-caret" :class="{ open: layerDropdownOpen }">›</span>
+            </button>
+            <template v-if="layerDropdownOpen">
+              <div class="layer-dd-backdrop" @click="layerDropdownOpen = false" />
+              <div class="layer-dd-pop sw-card">
+                <div class="list-search">
+                  <input
+                    v-model="layerSearch"
+                    type="text"
+                    class="list-search-input"
+                    placeholder="Search layers…"
+                    autocomplete="off"
+                    spellcheck="false"
+                  />
+                </div>
+                <div class="dd-filters">
+                  <label class="diverged-filter" :class="{ on: divergedOnly }" :title="divergedCount === 0 ? 'No layers differ from OAP' : `${divergedCount} layer(s) differ from the OAP-stored copy`">
+                    <input v-model="divergedOnly" type="checkbox" :disabled="divergedCount === 0" />
+                    Diverged<span v-if="divergedCount" class="diverged-count">{{ divergedCount }}</span>
+                  </label>
+                  <label class="diverged-filter local" :class="{ on: localOnly }" :title="localCount === 0 ? 'No unpublished local drafts in this browser' : `${localCount} layer(s) with an unpublished local draft`">
+                    <input v-model="localOnly" type="checkbox" :disabled="localCount === 0" />
+                    Local<span v-if="localCount" class="diverged-count local">{{ localCount }}</span>
+                  </label>
+                </div>
+                <div class="layer-dd-list">
+                  <button
+                    v-for="t in filteredTemplates"
+                    :key="t.key"
+                    class="layer-row"
+                    :class="{ active: selectedKey === t.key }"
+                    @click="pickFromDropdown(t.key)"
+                  >
+                    <span class="dot" :style="{ background: t.color || 'var(--sw-fg-3)' }" />
+                    <span class="name">{{ t.alias || t.key }}</span>
+                    <code v-if="t.alias && t.alias !== t.key" class="key-tag">{{ t.key }}</code>
+                    <span v-if="hasLocalDraftFor(t.key)" class="local-badge" title="Unpublished local draft">local</span>
+                    <TemplateStatusBadge :status="sync.badgeFor(`horizon.layer.${t.key}`)" />
+                  </button>
+                  <p v-if="filteredTemplates.length === 0" class="list-empty">
+                    {{ divergedOnly && !layerSearch.trim() ? 'No layers differ from OAP.' : `No layers match “${layerSearch}”.` }}
+                  </p>
+                </div>
+              </div>
+            </template>
+            </div>
+          <span class="sub">{{ templates.length }} layer{{ templates.length === 1 ? '' : 's' }}</span>
+        </div>
         <!-- Identity strip + save controls -->
         <section class="sw-card identity-card">
           <div class="identity-row">
             <span class="dot inline" :style="{ background: selectedTpl.color || 'var(--sw-fg-3)' }" />
-            <div>
+            <!-- Single-line identity: Layer: <name> <key> <local> <status>. -->
+            <div class="identity-title">
+              <span class="identity-label">Layer:</span>
               <h2>{{ selectedTpl.alias || selectedTpl.key }}</h2>
-              <div class="meta">
-                <code>{{ selectedTpl.key }}</code>
-              </div>
+              <code>{{ selectedTpl.key }}</code>
+              <!-- Same sync status the picker shows, so the editor and the
+                   dropdown agree (e.g. both read DISABLED, not one BUNDLED). -->
+              <span v-if="hasLocalDraft" class="local-badge" title="Unpublished local draft in this browser">local</span>
+              <TemplateStatusBadge :status="sync.badgeFor(editName)" />
             </div>
-            <div class="actions">
-              <span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
+            <!-- Disable / Reactivate. Sits by the title, away from the
+                 save/push cluster. A disabled layer offers Reactivate
+                 (re-enable on OAP); otherwise Disable/Delete. -->
+            <div class="identity-delete">
               <button
+                v-if="isLayerDisabled"
                 class="sw-btn"
                 type="button"
-                :disabled="!dirty || isSaving"
-                @click="reset"
+                :disabled="isSaving || sync.readOnly.value"
+                :title="sync.readOnly.value
+                  ? 'OAP unreachable — cannot reactivate'
+                  : `Reactivate the ${selectedTpl.key} layer (re-enable on OAP)`"
+                @click="askReactivateLayer"
               >
-                Reset
+                Reactivate
               </button>
               <button
-                v-if="isDiverged && !sync.readOnly.value"
+                v-else
+                class="sw-btn danger"
+                type="button"
+                :disabled="isSaving || (sync.readOnly.value && (remoteAvailable || bundledExists))"
+                :title="(sync.readOnly.value && (remoteAvailable || bundledExists))
+                  ? 'OAP unreachable — cannot delete'
+                  : bundledExists
+                    ? `Disable the built-in ${selectedTpl.key} layer (hidden from the sidebar; Reactivate to bring it back)`
+                    : remoteAvailable
+                      ? `Delete the ${selectedTpl.key} layer template (soft-disabled on OAP)`
+                      : `Remove the local draft for ${selectedTpl.key}`"
+                @click="askDeleteLayer"
+              >
+                {{ bundledExists ? 'Disable' : 'Delete' }}
+              </button>
+            </div>
+            <div class="actions">
+              <span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
+              <!-- Where the editor content was loaded from — distinct from
+                   the sync-status chip by the title (prefixed "from "). -->
+              <span class="src-tag" :title="`Editing from: ${editorSource}`">from {{ editorSource }}</span>
+              <!-- Reset the editor to a source (discard current content). -->
+              <div class="reset-dd">
+                <button class="sw-btn" type="button" @click="resetDropdownOpen = !resetDropdownOpen">
+                  Reset to <span class="caret" :class="{ open: resetDropdownOpen }">›</span>
+                </button>
+                <template v-if="resetDropdownOpen">
+                  <div class="reset-dd-backdrop" @click="resetDropdownOpen = false" />
+                  <div class="reset-dd-pop">
+                    <button class="reset-dd-item" type="button" title="Discard current edits and reload the bundled (shipped) default." @click="resetTo('bundled')">
+                      Bundled
+                    </button>
+                    <button
+                      class="reset-dd-item"
+                      type="button"
+                      :disabled="!remoteAvailable"
+                      :title="remoteAvailable ? 'Discard current edits and reload OAP\'s live version.' : 'OAP has no copy of this template yet.'"
+                      @click="resetTo('remote')"
+                    >
+                      Remote
+                    </button>
+                  </div>
+                </template>
+              </div>
+              <!-- Preview the real page rendering a chosen source. -->
+              <div class="reset-dd">
+                <button class="sw-btn" type="button" @click="previewDropdownOpen = !previewDropdownOpen">
+                  Preview <span class="caret" :class="{ open: previewDropdownOpen }">›</span>
+                </button>
+                <template v-if="previewDropdownOpen">
+                  <div class="reset-dd-backdrop" @click="previewDropdownOpen = false" />
+                  <div class="reset-dd-pop">
+                    <button class="reset-dd-item" type="button" :disabled="!hasLocalDraft" title="Preview your unpublished local draft." @click="previewLive('local')">Local</button>
+                    <button class="reset-dd-item" type="button" title="Preview the bundled (shipped) default." @click="previewLive('bundled')">Bundled</button>
+                    <button class="reset-dd-item" type="button" :disabled="!remoteAvailable" title="Preview OAP's live version." @click="previewLive('remote')">Remote</button>
+                  </div>
+                </template>
+              </div>
+              <!-- Publish local → remote. Enabled only when local differs
+                   from remote; click shows the diff before pushing. -->
+              <button
+                v-if="!sync.readOnly.value"
                 class="sw-btn"
                 type="button"
-                title="Show side-by-side diff vs OAP, and reset OAP back to bundled (with confirmation)."
-                @click="openDiffModal"
+                :disabled="!localDiffersFromRemote || isSaving"
+                :title="localDiffersFromRemote ? 'Review the local → remote diff, then publish to OAP.' : 'No local changes to publish — local matches remote.'"
+                @click="pushDiffOpen = true"
               >
-                Show diff &amp; reset
+                Check diff &amp; push
               </button>
               <button
                 class="sw-btn is-primary"
                 type="button"
                 :disabled="!dirty || isSaving"
-                title="Save this edit to the local bundled template. Publish it to OAP for everyone with “Sync all to OAP”."
+                title="Save the editor to your browser (local). Publish later with “Push local → OAP”."
                 @click="save"
               >
-                {{ isSaving ? 'Saving…' : 'Save locally' }}
+                {{ isSaving ? 'Saving…' : 'Save (local)' }}
               </button>
             </div>
           </div>
@@ -1081,30 +1595,92 @@ const namingTest = computed<NamingTestResult>(() => {
           </div>
         </section>
 
-        <!-- Components editor: which per-layer views exist. Each
-             toggle controls a sidebar entry + a route + (where the
-             component is service / instance / endpoint) the matching
-             dashboards.<scope> widget set. -->
-        <section class="sw-card components-card">
-          <div class="card-head">
-            <h4>Components</h4>
-            <span class="sub">which sub-views this layer exposes</span>
-          </div>
-          <div class="comp-grid">
-            <label
-              v-for="t in COMPONENT_TOGGLES"
-              :key="t.key"
-              class="comp-toggle"
-              :class="{ on: !!selectedTpl.components?.[t.key] }"
-              :title="t.hint"
-            >
-              <input
-                type="checkbox"
-                :checked="!!selectedTpl.components?.[t.key]"
-                @change="toggleComponent(t.key)"
-              />
-              <span class="comp-label">{{ t.label }}</span>
-            </label>
+        <!-- Layer setup: left = live menu preview (alias header + the
+             enabled components, in checkbox order — clicking an item
+             jumps to that component's config + preview below). Right =
+             alias edit (before Components, per request) + the Components
+             toggles that drive which menu entries exist. -->
+        <section class="sw-card setup-card">
+          <div class="setup-grid">
+            <div class="menu-preview">
+              <div class="menu-preview-head">
+                <span class="dot inline" :style="{ background: selectedTpl.color || 'var(--sw-fg-3)' }" />
+                <span class="menu-preview-title">{{ selectedTpl.alias || selectedTpl.key }}</span>
+                <code v-if="selectedTpl.alias && selectedTpl.alias !== selectedTpl.key" class="key-tag">{{ selectedTpl.key }}</code>
+              </div>
+              <p v-if="menuItems.length === 0" class="menu-preview-empty">
+                No components enabled — toggle one on the right to populate the menu.
+              </p>
+              <button
+                v-for="m in menuItems"
+                :key="m.key"
+                type="button"
+                class="menu-item"
+                :class="{ on: activeScope === m.scope }"
+                :title="`Jump to ${m.label} config`"
+                @click="jumpToComponent(m.scope)"
+              >
+                <span class="menu-item-label">{{ m.label }}</span>
+                <span class="menu-item-arrow">›</span>
+              </button>
+            </div>
+            <div class="setup-right">
+              <label class="alias-field">
+                <span>Alias</span>
+                <input
+                  v-model="selectedTpl.alias"
+                  type="text"
+                  class="alias-input"
+                  :placeholder="selectedTpl.key"
+                  spellcheck="false"
+                />
+                <span class="alias-hint">Display name in the sidebar, layer list, and landing KPI tile. Defaults to the layer key.</span>
+              </label>
+              <div class="setup-section-head">
+                <h4>Components</h4>
+                <span class="sub">which sub-views this layer exposes</span>
+              </div>
+              <div class="comp-grid">
+                <label
+                  v-for="t in COMPONENT_TOGGLES"
+                  :key="t.key"
+                  class="comp-toggle"
+                  :class="{ on: !!selectedTpl.components?.[t.key] }"
+                  :title="t.hint"
+                >
+                  <input
+                    type="checkbox"
+                    :checked="!!selectedTpl.components?.[t.key]"
+                    @change="toggleComponent(t.key)"
+                  />
+                  <span class="comp-label">{{ t.label }}</span>
+                </label>
+              </div>
+              <!-- Menu labels (slot aliases): rename the per-component
+                   nouns the way the layer's own vocabulary reads (e.g.
+                   services → "ClickHouse clusters", instances → "Nodes").
+                   Shown only for enabled components; drives the menu
+                   preview, scope tabs, and section headings live. -->
+              <template v-if="visibleAliasFields.length > 0">
+                <div class="setup-section-head">
+                  <h4>Menu labels</h4>
+                  <span class="sub">rename the nouns shown in the menu &amp; tabs (optional)</span>
+                </div>
+                <div class="alias-grid">
+                  <label v-for="f in visibleAliasFields" :key="f.slot" class="alias-grid-field">
+                    <span>{{ f.label }}</span>
+                    <input
+                      type="text"
+                      class="alias-input sm"
+                      :value="selectedTpl.slots?.[f.slot] ?? ''"
+                      :placeholder="f.def"
+                      spellcheck="false"
+                      @input="setSlot(f.slot, ($event.target as HTMLInputElement).value)"
+                    />
+                  </label>
+                </div>
+              </template>
+            </div>
           </div>
         </section>
 
@@ -1118,7 +1694,11 @@ const namingTest = computed<NamingTestResult>(() => {
              Live tester at the bottom evaluates the current draft
              against a sample name so the operator can see the result
              before saving. -->
-        <section id="topology-cluster" class="sw-card components-card">
+        <section
+          v-if="selectedTpl.components?.topology"
+          id="topology-cluster"
+          class="sw-card components-card"
+        >
           <div class="card-head">
             <h4>Topology cluster setup</h4>
             <span class="sub">parse service name → display label + cluster dimension (k8s/mesh namespace, tenant, fleet, …)</span>
@@ -1278,41 +1858,84 @@ const namingTest = computed<NamingTestResult>(() => {
                 <td><input type="number" step="any" v-model.number="c.scale" placeholder="1" /></td>
                 <td><input type="number" min="0" max="6" v-model.number="c.precision" placeholder="auto" /></td>
                 <td>
-                  <button class="sw-btn danger" type="button" @click="deleteMetricColumn(i)">✕</button>
+                  <button class="sw-btn is-icon danger" type="button" title="Remove column" @click="deleteMetricColumn(i)">✕</button>
                 </td>
               </tr>
             </tbody>
           </table>
-        </section>
-
-        <!-- Overview-tile settings: the per-layer compact tile on the
-             Overview's top strip. Only these two settings live here;
-             they reference metric keys from the service-list columns. -->
-        <section v-if="overviewModel" class="sw-card overview-card">
-          <div class="card-head">
-            <h4>Overview tile</h4>
-            <span class="sub">per-layer compact tile on the Overview's top strip</span>
-          </div>
-          <div class="metrics-keys">
-            <label>
-              <span>Headline (throughput)</span>
-              <select v-model="overviewModel.throughput">
-                <option :value="undefined">(orderBy)</option>
-                <option v-for="c in metricsColumns" :key="c.metric" :value="c.metric">{{ c.metric }}</option>
-              </select>
-            </label>
-            <label>
-              <span>Trend line (spark)</span>
-              <select v-model="overviewModel.spark">
-                <option :value="undefined">(throughput)</option>
-                <option v-for="c in metricsColumns" :key="c.metric" :value="c.metric">{{ c.metric }}</option>
-              </select>
-            </label>
+          <!-- Preview: the per-layer landing KPI tile (headline + trend)
+               at the head — its config picks WHICH service-list column
+               feeds the tile, no new metrics — then the service-list
+               sample table. Mock values, no MQE fired. -->
+          <div v-if="metricsColumns.length > 0" class="metrics-preview">
+            <div class="metrics-preview-head">
+              Preview <span class="sub">how this layer’s landing KPI tile + service list render (sample data)</span>
+            </div>
+            <div v-if="overviewModel" class="kpi-block">
+              <div class="kpi-config">
+                <div class="kpi-config-title">Landing KPI tile <span class="sub">which column is the headline + trend</span></div>
+                <label>
+                  <span>Headline (throughput)</span>
+                  <select v-model="overviewModel.throughput">
+                    <option :value="undefined">(default sort)</option>
+                    <option v-for="c in metricsColumns" :key="c.metric" :value="c.metric">{{ c.label || c.metric }}</option>
+                  </select>
+                </label>
+                <label>
+                  <span>Trend line (spark)</span>
+                  <select v-model="overviewModel.spark">
+                    <option :value="undefined">(headline)</option>
+                    <option v-for="c in metricsColumns" :key="c.metric" :value="c.metric">{{ c.label || c.metric }}</option>
+                  </select>
+                </label>
+              </div>
+              <!-- Faithful copy of the real layer header (LayerShell):
+                   icon tile + identity + the kpi-strip. Reuses the same
+                   class names + the Sparkline component so the preview
+                   matches the live page. -->
+              <header class="sw-card preview-layer-head">
+                <div class="layer-id-row">
+                  <div class="icon-tile" :style="{ background: selectedTpl.color || 'var(--sw-fg-3)' }">{{ previewInitials }}</div>
+                  <div class="identity-text">
+                    <div class="title-row"><h1>{{ selectedTpl.alias || selectedTpl.key }}</h1></div>
+                    <div class="sub">{{ SAMPLE_SERVICES.length }} {{ (selectedTpl.slots?.services || 'services').toLowerCase() }}</div>
+                  </div>
+                  <div class="kpi-strip">
+                    <div class="kpi">
+                      <div class="kpi-label">
+                        {{ kpiHeadlineCol?.label || kpiHeadlineCol?.metric || '—' }}<span v-if="kpiHeadlineCol?.unit" class="unit">({{ kpiHeadlineCol.unit }})</span>
+                      </div>
+                      <div class="kpi-value">{{ kpiHeadlineValue }}</div>
+                      <Sparkline class="kpi-spark" :values="kpiSparkValues" :width="84" :height="18" color="var(--sw-accent)" :stroke="1.25" />
+                    </div>
+                  </div>
+                </div>
+              </header>
+            </div>
+            <div class="metrics-preview-scroll">
+              <table class="sw-table preview-table">
+                <thead>
+                  <tr>
+                    <th>{{ scopeLabel('service') }}</th>
+                    <th v-for="c in metricsColumns" :key="c.metric" class="num">
+                      {{ c.label || c.metric }}
+                      <span v-if="effectiveOrderBy === c.metric" class="sort-ind" title="default sort">↓</span>
+                    </th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr v-for="(svc, r) in SAMPLE_SERVICES" :key="svc">
+                    <td class="svc">{{ svc }}</td>
+                    <td v-for="(c, ci) in metricsColumns" :key="c.metric" class="num">{{ previewCell(c, r + ci) }}</td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
           </div>
         </section>
 
         <!-- Scope tabs -->
-        <nav class="scope-tabs sw-card">
+        <nav id="scope-editor" class="scope-tabs sw-card">
           <button
             v-for="s in visibleScopes"
             :key="s"
@@ -1321,7 +1944,7 @@ const namingTest = computed<NamingTestResult>(() => {
             type="button"
             @click="activeScope = s"
           >
-            {{ SCOPE_LABELS[s] }}
+            {{ scopeLabel(s) }}
             <span class="count">{{ widgetsFor(s).length }}</span>
           </button>
         </nav>
@@ -1690,7 +2313,7 @@ const namingTest = computed<NamingTestResult>(() => {
           class="sw-card editor-card topo-cfg-card"
         >
           <div class="card-head">
-            <h4>{{ SCOPE_LABELS[activeScope] }} tab</h4>
+            <h4>{{ scopeLabel(activeScope) }} tab</h4>
             <span class="sub">
               {{ activeScope === 'trace'
                 ? 'Pick the trace backend this layer reads from.'
@@ -1720,7 +2343,7 @@ const namingTest = computed<NamingTestResult>(() => {
               </div>
             </div>
             <p v-else class="topo-cfg-help">
-              The <b>{{ SCOPE_LABELS[activeScope] }}</b> tab is a built-in view that uses
+              The <b>{{ scopeLabel(activeScope) }}</b> tab is a built-in view that uses
               SkyWalking-native query-protocol APIs directly. Operators configure filters
               and time range at runtime from the page itself; nothing to wire up here.
             </p>
@@ -1729,7 +2352,7 @@ const namingTest = computed<NamingTestResult>(() => {
 
         <section v-else class="sw-card editor-card">
           <div class="card-head">
-            <h4>{{ SCOPE_LABELS[activeScope] }} widgets</h4>
+            <h4>{{ scopeLabel(activeScope) }} widgets</h4>
             <span class="sub">
               click a widget to edit · drag corner to resize · drag header to reorder
             </span>
@@ -1847,7 +2470,7 @@ const namingTest = computed<NamingTestResult>(() => {
             <aside v-if="selectedWidget" class="drawer">
               <div class="drawer-head">
                 <h4>Edit widget</h4>
-                <span class="sub">{{ SCOPE_LABELS[activeScope] }} · #{{ (selectedIdx ?? 0) + 1 }}</span>
+                <span class="sub">{{ scopeLabel(activeScope) }} · #{{ (selectedIdx ?? 0) + 1 }}</span>
                 <button class="sw-btn ghost close" type="button" title="Close" @click="selectedIdx = null">✕</button>
               </div>
               <div class="drawer-body">
@@ -1984,14 +2607,33 @@ const namingTest = computed<NamingTestResult>(() => {
       </main>
     </div>
 
-    <TemplateDiffModal
-      v-if="selectedKey"
-      :open="diffModalOpen"
-      :name="`horizon.layer.${selectedKey}`"
-      :confirm-key="selectedKey"
-      @close="diffModalOpen = false"
-      @reset="onDiffReset"
-    />
+    <!-- Push confirm: shows the remote → local diff before publishing. -->
+    <Modal :open="pushDiffOpen" title="Publish local → OAP?" width="min(1100px, 94vw)" @close="pushDiffOpen = false">
+      <p class="push-lede">
+        This replaces the live (remote) version with your local draft — live for everyone. Review the
+        diff (left = remote, right = your local):
+      </p>
+      <div class="push-diff">
+        <MonacoDiff :original="pushRemotePretty" :modified="pushLocalPretty" language="json" />
+      </div>
+      <template #footer>
+        <button class="sw-btn" type="button" @click="pushDiffOpen = false">Cancel</button>
+        <button class="sw-btn is-primary" type="button" :disabled="isSaving" @click="pushToOap">
+          {{ isSaving ? 'Pushing…' : 'Confirm push' }}
+        </button>
+      </template>
+    </Modal>
+
+    <!-- Disable / delete / reactivate confirm — styled (not native). -->
+    <Modal :open="deleteOpen" :title="confirmTitle" width="min(520px, 92vw)" @close="deleteOpen = false">
+      <p class="confirm-msg">{{ confirmMessage }}</p>
+      <template #footer>
+        <button class="sw-btn" type="button" @click="deleteOpen = false">Cancel</button>
+        <button class="sw-btn" :class="{ danger: confirmIsDanger, 'is-primary': !confirmIsDanger }" type="button" :disabled="isSaving" @click="runConfirm">
+          {{ confirmLabel }}
+        </button>
+      </template>
+    </Modal>
   </div>
 </template>
 
@@ -2050,7 +2692,7 @@ const namingTest = computed<NamingTestResult>(() => {
   transition: grid-template-columns 160ms ease;
 }
 .grid.list-collapsed {
-  grid-template-columns: 36px 1fr;
+  grid-template-columns: 1fr;
 }
 .layer-list {
   padding: 8px;
@@ -2061,9 +2703,6 @@ const namingTest = computed<NamingTestResult>(() => {
   position: sticky;
   top: 16px;
 }
-.layer-list.collapsed {
-  padding: 6px 4px;
-}
 .list-toggle {
   flex: 0 0 auto;
   width: 22px;
@@ -2091,21 +2730,83 @@ const namingTest = computed<NamingTestResult>(() => {
 .list-toggle .caret.open {
   transform: rotate(90deg);
 }
-.collapsed-row {
-  justify-content: center;
-  padding: 6px 4px;
+/* Collapsed-rail layer switcher — a compact dropdown bar at the top of
+   the editor pane so layer switching costs no left-zone width. */
+.layer-switch-bar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
 }
-.collapsed-row .dot {
-  width: 10px;
-  height: 10px;
+.layer-switch-bar .list-toggle {
+  margin-right: 0;
 }
-.layer-list.collapsed .list-head {
-  border-bottom: 1px solid var(--sw-line);
-  padding: 4px 0 6px;
-  justify-content: center;
+.layer-switch-bar .caret {
+  display: inline-block;
+  font-size: 13px;
+  line-height: 1;
 }
-.layer-list.collapsed .list-toggle {
-  margin-right: 0;
+/* Filterable layer dropdown — same search + rows as the pinned rail.
+   The sole layer switcher (no separate expand toggle). */
+.layer-dd { position: relative; display: flex; }
+.layer-dd-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  height: 30px;
+  padding: 0 10px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 6px;
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 12px;
+  font-weight: 600;
+  min-width: 200px;
+  cursor: pointer;
+}
+.layer-dd-btn:hover { background: var(--sw-bg-3); }
+.layer-dd-name {
+  max-width: 220px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.dd-caret {
+  margin-left: auto;
+  transform: rotate(90deg);
+  transition: transform 0.15s;
+}
+.dd-caret.open { transform: rotate(-90deg); }
+.layer-dd-backdrop {
+  position: fixed;
+  inset: 0;
+  z-index: 40;
+}
+.layer-dd-pop {
+  position: absolute;
+  top: calc(100% + 4px);
+  left: 0;
+  z-index: 41;
+  /* Wide enough to read alias + key + badge on one line per row. */
+  width: min(80vw, 760px);
+  max-height: 360px;
+  display: flex;
+  flex-direction: column;
+  padding: 8px 0;
+  box-shadow: 0 12px 32px -8px rgba(0, 0, 0, 0.5);
+}
+.layer-dd-pop .list-search { padding: 0 10px 8px; }
+.layer-dd-list {
+  overflow-y: auto;
+  padding: 0 6px;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.layer-switch-bar .sub {
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+  margin-left: auto;
 }
 .list-head {
   display: flex;
@@ -2150,14 +2851,18 @@ const namingTest = computed<NamingTestResult>(() => {
   gap: 8px;
   padding: 0 10px 8px;
 }
-/* Page-level display/sync controls, directly under the sync banner. */
-.top-actions {
+/* Diverged / Local filters, co-located with the search inside the layer
+   picker (rail + dropdown), same as the overview dashboards admin —
+   keeps filter + selector + count read as one connected control instead
+   of floating in a separate page-level row. */
+.dd-filters {
   display: flex;
-  align-items: center;
-  gap: 10px;
-  margin: 8px 0 4px;
+  gap: 14px;
+  padding: 0 10px 8px;
+  margin-bottom: 6px;
+  border-bottom: 1px solid var(--sw-line);
 }
-.top-actions .diverged-filter { margin-right: auto; }
+.layer-dd-pop .dd-filters { padding: 0 10px 8px; }
 .diverged-filter {
   display: inline-flex;
   align-items: center;
@@ -2238,16 +2943,25 @@ const namingTest = computed<NamingTestResult>(() => {
   font-weight: 600;
   color: var(--sw-fg-0);
 }
-.identity-row .meta {
+/* Single-line identity strip: label + name + key + badges all inline. */
+.identity-title {
   display: flex;
   align-items: center;
-  gap: 6px;
-  flex-wrap: wrap;
-  margin-top: 4px;
-  font-size: 10.5px;
+  gap: 8px;
+  flex-wrap: nowrap;
+  min-width: 0;
+}
+.identity-label {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--sw-fg-3);
+  font-weight: 600;
 }
-.identity-row .meta code {
+.identity-title h2 { white-space: nowrap; }
+.identity-title code {
   font-family: var(--sw-mono);
+  font-size: 10.5px;
   background: var(--sw-bg-2);
   padding: 1px 6px;
   border-radius: 3px;
@@ -2306,11 +3020,59 @@ const namingTest = computed<NamingTestResult>(() => {
   gap: 8px;
 }
 .actions .sw-btn { font-size: 11.5px; }
+.src-tag {
+  font-size: 9.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  padding: 2px 7px;
+}
+/* "Reset to ▾" merged dropdown (Bundled / Remote sources). */
+.reset-dd { position: relative; display: inline-flex; }
+.reset-dd .caret { display: inline-block; transform: rotate(90deg); font-size: 11px; }
+.reset-dd .caret.open { transform: rotate(-90deg); }
+.reset-dd-backdrop { position: fixed; inset: 0; z-index: 40; }
+.reset-dd-pop {
+  position: absolute;
+  top: calc(100% + 4px);
+  right: 0;
+  z-index: 41;
+  min-width: 130px;
+  padding: 4px;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  box-shadow: 0 10px 28px -8px rgba(0, 0, 0, 0.5);
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.reset-dd-item {
+  text-align: left;
+  padding: 6px 10px;
+  background: transparent;
+  border: none;
+  border-radius: 4px;
+  color: var(--sw-fg-1);
+  font: inherit;
+  font-size: 12px;
+  cursor: pointer;
+}
+.reset-dd-item:hover:not(:disabled) { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.reset-dd-item:disabled { opacity: 0.4; cursor: not-allowed; }
+.push-lede { margin: 0 0 10px; font-size: 12px; color: var(--sw-fg-2); line-height: 1.5; }
+.confirm-msg { margin: 0; font-size: 13px; line-height: 1.55; color: var(--sw-fg-1); }
+.push-diff { height: 50vh; min-height: 320px; border: 1px solid var(--sw-line); border-radius: 6px; overflow: hidden; }
 .actions .sw-btn[disabled] { opacity: 0.4; pointer-events: none; }
 .save-msg {
   font-size: 11px;
   color: var(--sw-ok);
 }
+.identity-delete { display: flex; align-items: center; gap: 8px; }
+.del-note { font-size: 10.5px; color: var(--sw-fg-3); font-style: italic; }
 
 .scope-tabs {
   display: flex;
@@ -2347,6 +3109,140 @@ const namingTest = computed<NamingTestResult>(() => {
 }
 .scope-tab.on .count { color: var(--sw-accent-2); }
 
+/* Layer setup card — menu preview (left) + alias/components (right). */
+.setup-card { padding: 0; }
+.setup-grid {
+  display: grid;
+  grid-template-columns: 240px 1fr;
+  align-items: stretch;
+}
+.menu-preview {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  padding: 12px 12px 14px;
+  border-right: 1px solid var(--sw-line);
+  background: var(--sw-bg-1);
+  border-radius: var(--sw-radius, 8px) 0 0 var(--sw-radius, 8px);
+}
+.menu-preview-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 8px 10px;
+  margin-bottom: 4px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.menu-preview-title {
+  font-size: 12.5px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.menu-preview-empty {
+  margin: 4px 8px;
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+  line-height: 1.5;
+}
+/* Amber "local" chip — surfaces an unpublished local browser draft in the
+ * picker (the sync badge only reflects bundled-vs-remote). */
+.local-badge {
+  flex: 0 0 auto;
+  font-size: 9px;
+  font-weight: 700;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  color: #1a1a1a;
+  background: var(--sw-warn, #f59e0b);
+  padding: 1px 6px;
+  border-radius: 8px;
+}
+/* Dim mono chip for the raw layer key, shown next to the alias so the
+ * operator sees both the display name and the OAP layer identity. */
+.key-tag {
+  flex: 0 0 auto;
+  font-family: var(--sw-mono);
+  font-size: 9.5px;
+  letter-spacing: 0.02em;
+  color: var(--sw-fg-3);
+  background: var(--sw-bg-2);
+  padding: 1px 5px;
+  border-radius: 3px;
+}
+.menu-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 7px 10px;
+  border-radius: 5px;
+  background: transparent;
+  border: none;
+  color: var(--sw-fg-1);
+  font: inherit;
+  font-size: 12px;
+  cursor: pointer;
+  text-align: left;
+}
+.menu-item:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.menu-item.on {
+  background: var(--sw-accent-soft);
+  color: var(--sw-accent-2);
+  font-weight: 600;
+  box-shadow: inset 2px 0 0 var(--sw-accent);
+}
+.menu-item-label { flex: 1; }
+.menu-item-arrow { color: var(--sw-fg-3); font-size: 13px; }
+.menu-item.on .menu-item-arrow { color: var(--sw-accent-2); }
+.setup-right {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  padding: 12px 14px 14px;
+  min-width: 0;
+}
+.alias-field {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.alias-field > span:first-child {
+  font-size: 10.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--sw-fg-3);
+}
+.alias-input {
+  height: 30px;
+  padding: 0 10px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 5px;
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 13px;
+  max-width: 320px;
+}
+.alias-input:focus { outline: none; border-color: var(--sw-accent); }
+.alias-hint { font-size: 10.5px; color: var(--sw-fg-3); line-height: 1.4; }
+.setup-section-head {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  padding-top: 4px;
+  border-top: 1px dashed var(--sw-line);
+}
+.setup-section-head h4 {
+  margin: 0;
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+}
+.setup-section-head .sub { font-size: 10.5px; color: var(--sw-fg-3); }
+.setup-right .comp-grid { padding: 0; }
+
 .components-card { padding: 0; }
 .components-card .card-head {
   display: flex;
@@ -2618,6 +3514,191 @@ const namingTest = computed<NamingTestResult>(() => {
   color: #f87171;
 }
 
+/* Sample-data preview of the service list. */
+.metrics-preview {
+  border-top: 1px solid var(--sw-line);
+  padding: 12px 10px 6px;
+}
+.metrics-preview-head {
+  font-size: 10px;
+  font-weight: 700;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--sw-fg-3);
+  padding: 0 4px 8px;
+}
+.metrics-preview-head .sub {
+  margin-left: 8px;
+  font-weight: 500;
+  text-transform: none;
+  letter-spacing: 0;
+  color: var(--sw-fg-3);
+}
+.metrics-preview-scroll { overflow-x: auto; }
+.preview-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 11.5px;
+}
+.preview-table th {
+  text-align: left;
+  font-size: 10px;
+  font-weight: 600;
+  color: var(--sw-fg-2);
+  padding: 6px 10px;
+  border-bottom: 1px solid var(--sw-line);
+  white-space: nowrap;
+}
+.preview-table th.num,
+.preview-table td.num {
+  text-align: right;
+  font-variant-numeric: tabular-nums;
+}
+.preview-table td {
+  padding: 6px 10px;
+  border-bottom: 1px solid var(--sw-line);
+  color: var(--sw-fg-1);
+}
+.preview-table td.svc {
+  color: var(--sw-fg-0);
+  font-weight: 500;
+}
+.preview-table tbody tr:last-child td { border-bottom: none; }
+.sort-ind {
+  margin-left: 3px;
+  color: var(--sw-accent-2);
+  font-size: 10px;
+}
+
+/* Landing KPI tile preview: config head + a faithful copy of the real
+ * layer header. The header classes below mirror LayerShell so the
+ * preview matches the live page (kept in sync intentionally). */
+.kpi-block {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  padding: 4px 4px 14px;
+}
+.kpi-config {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  gap: 10px;
+}
+.kpi-config-title {
+  flex: 1 0 100%;
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--sw-fg-1);
+}
+.kpi-config-title .sub {
+  margin-left: 6px;
+  font-weight: 400;
+  font-size: 10px;
+  color: var(--sw-fg-3);
+}
+.kpi-config label {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  color: var(--sw-fg-3);
+}
+.kpi-config select {
+  height: 28px;
+  padding: 0 8px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  color: var(--sw-fg-0);
+  font: inherit;
+  font-size: 11.5px;
+}
+.kpi-config select:focus { outline: none; border-color: var(--sw-accent); }
+/* ↓ mirrors LayerShell.vue .layer-head / .layer-id-row / .kpi-strip. */
+.preview-layer-head { padding: 14px; }
+.preview-layer-head .layer-id-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  min-width: 0;
+}
+.preview-layer-head .icon-tile {
+  width: 40px;
+  height: 40px;
+  border-radius: 10px;
+  display: grid;
+  place-items: center;
+  color: #fff;
+  font-weight: 700;
+  font-size: 14px;
+  letter-spacing: -0.02em;
+  flex: 0 0 40px;
+  background-blend-mode: multiply;
+  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
+}
+.preview-layer-head .identity-text { min-width: 0; }
+.preview-layer-head .title-row h1 {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+  letter-spacing: -0.02em;
+}
+.preview-layer-head .sub {
+  margin-top: 4px;
+  font-size: 11.5px;
+  color: var(--sw-fg-3);
+}
+.preview-layer-head .kpi-strip {
+  display: flex;
+  gap: 22px;
+  flex-wrap: wrap;
+  align-items: flex-end;
+  margin-left: auto;
+}
+.preview-layer-head .kpi { text-align: right; min-width: 80px; }
+.preview-layer-head .kpi-label {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--sw-fg-3);
+  margin-bottom: 2px;
+}
+.preview-layer-head .kpi-label .unit {
+  text-transform: none;
+  letter-spacing: 0;
+  margin-left: 2px;
+  font-size: 9.5px;
+}
+.preview-layer-head .kpi-value {
+  font-size: 18px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+  letter-spacing: -0.02em;
+  color: var(--sw-fg-1);
+}
+.preview-layer-head .kpi-spark { display: block; margin-top: 4px; margin-left: auto; }
+
+/* Menu-label (slot alias) editor grid. */
+.alias-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+  gap: 8px;
+}
+.alias-grid-field {
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  color: var(--sw-fg-3);
+}
+.alias-input.sm { height: 28px; font-size: 12px; max-width: none; }
+
 .editor-card { padding: 0; }
 /* Topology / endpoint-dep config preview — read-only JSON view. */
 .topo-cfg-body { padding: 12px 14px 16px; }
diff --git a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
index d2ecb21..5a4d55a 100644
--- a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
+++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue
@@ -36,7 +36,8 @@
   decisions, not config tweaks.
 -->
 <script setup lang="ts">
-import { computed, ref, watch, watchEffect } from 'vue';
+import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, watchEffect } from 'vue';
+import { useRouter } from 'vue-router';
 import { useQuery } from '@tanstack/vue-query';
 import type {
   OverviewDashboard,
@@ -44,13 +45,23 @@ import type {
   OverviewWidget,
 } from '@skywalking-horizon-ui/api-client';
 import { bff } from '@/api/client';
+import type { OverviewTemplateSummary } from '@/api/scopes/overview';
+import { useLocalTemplateEdits, overviewEditName } from '@/controls/localTemplateEdits';
+import { usePreviewOverride } from '@/controls/previewOverride';
+import { useTemplateSources } from '@/features/admin/_shared/useTemplateSources';
 import { useLayers } from '@/shell/useLayers';
 import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue';
-import SyncAllButton from '@/features/admin/_shared/SyncAllButton.vue';
 import { refreshConfigBundle } from '@/controls/configBundle';
+import { stableStringify } from '@/utils/stableJson';
 import TemplateStatusBadge from '@/features/admin/_shared/TemplateStatusBadge.vue';
-import TemplateDiffModal from '@/features/admin/_shared/TemplateDiffModal.vue';
 import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync';
+// Real overview widget primitives — the preview renders these with mock
+// data so it matches the live page pixel-for-pixel (no bespoke copy).
+import KpiTileWidget from '@/render/widgets/KpiTileWidget.vue';
+import MetricWidget from '@/render/widgets/MetricWidget.vue';
+import MetricCompositeWidget from '@/render/widgets/MetricCompositeWidget.vue';
+import Modal from '@/features/operate/_shared/Modal.vue';
+import MonacoDiff from '@/features/operate/_shared/MonacoDiff.vue';
 
 // OAP UI-template sync status for the Overview kind. Drives the
 // page-level banner + read-only mode + per-row badge lookup.
@@ -61,9 +72,115 @@ const listQuery = useQuery({
   queryFn: () => bff.overview.adminList(),
   staleTime: 60_000,
 });
-const dashboards = computed(() => listQuery.data.value?.dashboards ?? []);
+
+// Template sources + local-draft store. Declared here (not lower with the
+// editor-source helpers) because the picker computeds below — read
+// synchronously by the auto-select watchEffect during setup — depend on
+// them; declaring them later would hit a temporal-dead-zone ReferenceError.
+const router = useRouter();
+const localEdits = useLocalTemplateEdits();
+const previewOverride = usePreviewOverride();
+const sources = useTemplateSources('overview');
+
+const OV_PREFIX = 'horizon.overview.';
+function summaryFrom(
+  id: string,
+  content: OverviewDashboard | null | undefined,
+): OverviewTemplateSummary | null {
+  if (!content) return null;
+  return {
+    id,
+    title: content.title || id,
+    ...(content.description ? { description: content.description } : {}),
+    widgetCount: content.widgets?.length ?? 0,
+    editable: true,
+  };
+}
+
+// On-disk dashboards (bundled + overlaid remote), from the BFF.
+const serverDashboards = computed(() => listQuery.data.value?.dashboards ?? []);
+
+// Remote-only: dashboards that live on OAP with no on-disk base — created
+// in this admin and pushed. The server list (disk) can't see them, so
+// surface them from the OAP sync rows.
+const remoteOnlyDashboards = computed<OverviewTemplateSummary[]>(() => {
+  const serverIds = new Set(serverDashboards.value.map((d) => d.id));
+  const out: OverviewTemplateSummary[] = [];
+  for (const name of sources.remoteNames()) {
+    if (!name.startsWith(OV_PREFIX)) continue;
+    const id = name.slice(OV_PREFIX.length);
+    if (serverIds.has(id)) continue;
+    const s = summaryFrom(id, sources.remote<OverviewDashboard>(name));
+    if (s) out.push(s);
+  }
+  return out;
+});
+
+// Local-only drafts: dashboards created in THIS browser that aren't on the
+// server or OAP yet. They surface in the picker like any dashboard —
+// flagged `local` — so create mirrors edit: draft locally, preview, then
+// "Check diff & push" publishes them. Once pushed they become remote-only
+// (then server-backed once OAP serves them) and drop out of this list.
+const localOnlyDrafts = computed<OverviewTemplateSummary[]>(() => {
+  const known = new Set([
+    ...serverDashboards.value.map((d) => d.id),
+    ...remoteOnlyDashboards.value.map((d) => d.id),
+  ]);
+  const out: OverviewTemplateSummary[] = [];
+  for (const name of localEdits.names()) {
+    if (!name.startsWith(OV_PREFIX)) continue;
+    const id = name.slice(OV_PREFIX.length);
+    if (known.has(id)) continue;
+    const s = summaryFrom(id, localEdits.get<OverviewDashboard>(name));
+    if (s) out.push(s);
+  }
+  return out;
+});
+const dashboards = computed(() => [
+  ...serverDashboards.value,
+  ...remoteOnlyDashboards.value,
+  ...localOnlyDrafts.value,
+]);
 
 const selectedId = ref<string>('');
+const selectedDash = computed(() => dashboards.value.find((d) => d.id === selectedId.value) ?? null);
+// True when the selected dashboard exists on the server (disk/remote) — a
+// local-only draft has no server detail to fetch, so the detail query must
+// stay disabled for it (it would 404).
+const isServerBacked = computed(() =>
+  serverDashboards.value.some((d) => d.id === selectedId.value),
+);
+
+// Top dropdown picker (replaces the left list — same pattern as the layer
+// dashboards switcher). Filterable; "+ New dashboard" lives inside it.
+const listDropdownOpen = ref(false);
+const listSearch = ref('');
+const divergedOnly = ref(false);
+const localOnly = ref(false);
+function isDivergedRow(id: string): boolean {
+  const s = sync.badgeFor(`horizon.overview.${id}`);
+  return s === 'diverged' || s === 'bundled-fallback';
+}
+const divergedCount = computed(() => dashboards.value.filter((d) => isDivergedRow(d.id)).length);
+const localDraftCount = computed(() => dashboards.value.filter((d) => hasLocalDraftFor(d.id)).length);
+const filteredDashboards = computed(() => {
+  const q = listSearch.value.trim().toLowerCase();
+  return dashboards.value.filter((d) => {
+    if (divergedOnly.value && !isDivergedRow(d.id)) return false;
+    if (localOnly.value && !hasLocalDraftFor(d.id)) return false;
+    if (!q) return true;
+    return d.title.toLowerCase().includes(q) || d.id.toLowerCase().includes(q);
+  });
+});
+function pickDashboard(id: string): void {
+  selectedId.value = id;
+  listDropdownOpen.value = false;
+}
+/** Whether a dashboard id has an unpublished local browser draft — drives
+ *  the "local" badge in the picker. */
+function hasLocalDraftFor(id: string): boolean {
+  return localEdits.has(overviewEditName(id));
+}
 /* Auto-select rule: if nothing is selected (cold start), pick the
  * first dashboard. If the currently-selected id disappears from the
  * list (just deleted by the operator), fall back to the new first.
@@ -103,7 +220,7 @@ function cancelNewDash(): void {
   newDashOpen.value = false;
   newDashError.value = null;
 }
-async function createDash(): Promise<void> {
+function createDash(): void {
   const id = newDashId.value.trim();
   const title = newDashTitle.value.trim();
   if (!id) {
@@ -118,25 +235,28 @@ async function createDash(): Promise<void> {
     newDashError.value = 'title is required';
     return;
   }
-  if (dashboards.value.some((d) => d.id === id)) {
-    newDashError.value = `dashboard "${id}" already exists`;
+  // The id IS the template name (horizon.overview.<id>) — must be unique
+  // across server, remote-only, and local drafts. Case-insensitive since
+  // OAP keys on the name string.
+  const idLc = id.toLowerCase();
+  if (dashboards.value.some((d) => d.id.toLowerCase() === idLc)) {
+    newDashError.value = `a dashboard with id "${id}" already exists`;
     return;
   }
-  try {
-    const description = newDashDescription.value.trim();
-    await bff.overview.adminCreate({
-      id,
-      title,
-      ...(description ? { description } : {}),
-      widgets: [],
-    });
-    await listQuery.refetch();
-    selectedId.value = id;
-    newDashOpen.value = false;
-    setFlash(`created · ${id}`);
-  } catch (err) {
-    newDashError.value = err instanceof Error ? err.message : 'create failed';
-  }
+  // Create mirrors edit: write a LOCAL browser draft, don't touch the
+  // server. The dashboard is immediately selectable + previewable; "Check
+  // diff & push" publishes it to OAP (create-or-update, server-side).
+  const description = newDashDescription.value.trim();
+  const dash: OverviewDashboard = {
+    id,
+    title,
+    ...(description ? { description } : {}),
+    widgets: [],
+  };
+  localEdits.set(overviewEditName(id), dash);
+  selectedId.value = id;
+  newDashOpen.value = false;
+  setFlash(`created local draft · ${id} — edit, preview, then “Check diff & push”.`);
 }
 
 // ── Preview pane ──────────────────────────────────────────────────
@@ -148,19 +268,96 @@ async function createDash(): Promise<void> {
  * 0..100 for percent / progress-bar). Not pixel-fidelity vs the real
  * widget components — that would require running the orchestrator
  * against mock OAP data — but enough to validate placement + scope. */
-/* Detail-pane view mode — `config` (the per-widget editor) or
- * `preview` (the layout + mock-data sanity board). Tabs in the
- * detail head switch between them. Defaults to config so an
- * operator who lands on the admin sees the editor immediately. */
-const viewMode = ref<'config' | 'preview'>('config');
+// Canvas selection: the editor is one page now — a mock-data widget grid
+// (canvas, left) + a drawer (right) that edits the clicked widget. Mirrors
+// the layer dashboards editor.
+// Sentinel id for the dashboard-meta "widget" (title + description). Real
+// widget ids never collide with it.
+const META_SEL = '__meta__';
+const selectedWidgetId = ref<string | null>(null);
+function selectWidget(id: string): void {
+  selectedWidgetId.value = id;
+}
+// Drop the selection when the dashboard changes.
+watch(selectedId, () => {
+  selectedWidgetId.value = null;
+});
+// Esc closes the editor drawer (deselects) — unless a modal owns Esc.
+function onEditorKey(e: KeyboardEvent): void {
+  if (e.key === 'Escape' && selectedWidgetId.value && !pushDiffOpen.value && !newDashOpen.value) {
+    selectedWidgetId.value = null;
+  }
+}
+onMounted(() => window.addEventListener('keydown', onEditorKey));
+onBeforeUnmount(() => window.removeEventListener('keydown', onEditorKey));
+
+// ── Canvas drag-reorder (HTML5 DnD over the flat widgets array) ─────
+const dragId = ref<string | null>(null);
+function onWidgetDragStart(e: DragEvent, id: string): void {
+  dragId.value = id;
+  if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
+}
+function onWidgetDrop(targetId: string): void {
+  const d = draft.value;
+  if (!d || !dragId.value || dragId.value === targetId) {
+    dragId.value = null;
+    return;
+  }
+  const ws = [...d.widgets];
+  const from = ws.findIndex((w) => w.id === dragId.value);
+  const to = ws.findIndex((w) => w.id === targetId);
+  dragId.value = null;
+  if (from < 0 || to < 0) return;
+  const [moved] = ws.splice(from, 1);
+  ws.splice(to, 0, moved!);
+  draft.value = { ...d, widgets: ws };
+}
+
+// ── Canvas corner-resize (drag updates span / rowSpan) ──────────────
+const resize = reactive({ active: false, id: '', startX: 0, startY: 0, startSpan: 4, startRowSpan: 1, cellW: 1, cellH: 96, cols: 12 });
+function onResizeStart(e: MouseEvent, w: OverviewWidget, cols: number): void {
+  e.preventDefault();
+  e.stopPropagation();
+  const grid = (e.target as HTMLElement).closest('.ot__pv-grid') as HTMLElement | null;
+  if (!grid) return;
+  const gap = 6;
+  const cellW = (grid.getBoundingClientRect().width - gap * (cols - 1)) / cols;
+  resize.active = true;
+  resize.id = w.id;
+  resize.startX = e.clientX;
+  resize.startY = e.clientY;
+  resize.startSpan = w.span ?? 4;
+  resize.startRowSpan = w.rowSpan ?? 1;
+  resize.cellW = cellW + gap;
+  resize.cellH = 96 + gap;
+  resize.cols = cols;
+  selectWidget(w.id);
+  window.addEventListener('mousemove', onResizeMove);
+  window.addEventListener('mouseup', onResizeEnd);
+}
+function onResizeMove(e: MouseEvent): void {
+  if (!resize.active || !draft.value) return;
+  const span = Math.max(1, Math.min(resize.cols, resize.startSpan + Math.round((e.clientX - resize.startX) / resize.cellW)));
+  const rowSpan = Math.max(1, Math.min(8, resize.startRowSpan + Math.round((e.clientY - resize.startY) / resize.cellH)));
+  const w = draft.value.widgets.find((x) => x.id === resize.id);
+  if (w && (w.span !== span || w.rowSpan !== rowSpan)) {
+    w.span = span;
+    w.rowSpan = rowSpan;
+  }
+}
+function onResizeEnd(): void {
+  resize.active = false;
+  window.removeEventListener('mousemove', onResizeMove);
+  window.removeEventListener('mouseup', onResizeEnd);
+}
 
 interface PreviewSection {
   cols: number;
-  widgets: typeof draft.value extends infer T
-    ? T extends { widgets: infer W }
-      ? W
-      : never
-    : never;
+  /** The section-break widget that opened this section (its id + title),
+   *  or null for the implicit first section. Lets the canvas render the
+   *  section-break as a selectable header. */
+  sb: OverviewWidget | null;
+  widgets: OverviewWidget[];
 }
 const previewSections = computed<PreviewSection[]>(() => {
   if (!draft.value) return [];
@@ -168,15 +365,15 @@ const previewSections = computed<PreviewSection[]>(() => {
   let cur: PreviewSection | null = null;
   for (const w of draft.value.widgets) {
     if (w.type === 'section-break') {
-      cur = { cols: w.cols ?? 12, widgets: [] as never };
+      cur = { cols: w.cols ?? 12, sb: w, widgets: [] };
       out.push(cur);
       continue;
     }
     if (!cur) {
-      cur = { cols: 12, widgets: [] as never };
+      cur = { cols: 12, sb: null, widgets: [] };
       out.push(cur);
     }
-    (cur.widgets as unknown as OverviewWidget[]).push(w);
+    cur.widgets.push(w);
   }
   return out;
 });
@@ -225,41 +422,79 @@ function mockAlarms(seed: string, n: number): Array<{
   return out;
 }
 
-/** Split a kpi-tile / metric-composite widget's rows into the two
- *  visual partitions the real renderer uses: number tiles (count
- *  grid) vs progress-bar rows. Same rule the runtime uses —
- *  `style === 'progress-bar'` OR `unit === '%'` go to bars. */
-function kpiCountRows(w: OverviewWidget): OverviewKpi[] {
-  return (w.kpis ?? []).filter((k) => k.style !== 'progress-bar' && k.unit !== '%');
+/** Mock value per KPI label for the real widget components — percent /
+ *  progress-bar rows get a 0-100 value, everything else a plain number.
+ *  Keyed by widget id + label so it's stable across re-renders. */
+function mockKpiValues(w: OverviewWidget): Record<string, number | null> {
+  const out: Record<string, number | null> = {};
+  for (const k of w.kpis ?? []) {
+    out[k.label] =
+      k.unit === '%' || k.style === 'progress-bar'
+        ? mockPercent(w.id + k.label)
+        : mockNumber(w.id + k.label, k.max ?? 999);
+  }
+  return out;
 }
-function kpiBarRows(w: OverviewWidget): OverviewKpi[] {
-  return (w.kpis ?? []).filter((k) => k.style === 'progress-bar' || k.unit === '%');
+/** Grid placement for one preview cell — span/rowSpan within the
+ *  section's column count. */
+function previewCellStyle(w: OverviewWidget, cols: number): Record<string, string> {
+  // Same clamps as the live renderer (OverviewDashboardView.widgetStyle):
+  // span ∈ [1, cols], rowSpan ∈ [1, 8].
+  return {
+    gridColumn: `span ${Math.min(cols, Math.max(1, w.span ?? cols))}`,
+    gridRow: `span ${Math.min(8, Math.max(1, w.rowSpan ?? 1))}`,
+  };
 }
 
-function previewSectionTitle(idx: number): string {
-  if (!draft.value) return '';
-  /* Walk the original widget array up to the idx-th section-break
-   * to recover its title (the preview groups widgets under each
-   * break but only stores cols + widget rows in the section). */
-  let n = 0;
-  for (const w of draft.value.widgets) {
-    if (w.type !== 'section-break') continue;
-    if (n === idx) return w.title;
-    n += 1;
+// ── Delete current dashboard ───────────────────────────────────────
+// A local-only draft (never pushed) is truly removed from the browser —
+// a real hard delete, it was never on OAP. Anything on OAP (bundled or
+// remote-only) is soft-disabled, because OAP has no hard DELETE; a
+// disabled template drops out of the bundle, so it vanishes from the UI.
+// Either way it's irreversible from the UI (no re-enable entrance).
+// Styled confirm (replaces window.confirm — native box breaks the dark
+// design). `askDeleteDash` opens it with the right copy; `confirmDeleteDash`
+// runs the actual delete/disable.
+const deleteOpen = ref(false);
+const deleteTitle = ref('');
+const deleteMessage = ref('');
+const deleteConfirmLabel = ref('Delete');
+function askDeleteDash(): void {
+  const id = selectedId.value;
+  if (!id || !editName.value) return;
+  const onOap = remoteAvailable.value || bundledExists.value;
+  if (!onOap) {
+    deleteTitle.value = 'Delete local draft?';
+    deleteMessage.value = `Delete the local draft “${id}”? It was never published, so it's removed from this browser only. This can't be undone.`;
+    deleteConfirmLabel.value = 'Delete draft';
+  } else if (bundledExists.value) {
+    deleteTitle.value = 'Disable built-in dashboard?';
+    deleteMessage.value = `Disable the built-in dashboard “${id}”? OAP has no hard delete, so it's soft-disabled — hidden from everyone. This can't be undone from the UI.`;
+    deleteConfirmLabel.value = 'Disable';
+  } else {
+    deleteTitle.value = 'Delete dashboard?';
+    deleteMessage.value = `Delete the dashboard “${id}”? OAP has no hard delete, so it's soft-disabled — hidden from everyone. This can't be undone from the UI.`;
+    deleteConfirmLabel.value = 'Delete';
   }
-  return '';
+  deleteOpen.value = true;
 }
-
-// ── Delete current dashboard ───────────────────────────────────────
-async function deleteCurrentDash(): Promise<void> {
+async function confirmDeleteDash(): Promise<void> {
   const id = selectedId.value;
-  if (!id) return;
-  if (!window.confirm(`Delete dashboard "${id}"? This removes the JSON file from disk.`)) {
+  const name = editName.value;
+  deleteOpen.value = false;
+  if (!id || !name) return;
+  const onOap = remoteAvailable.value || bundledExists.value;
+  if (!onOap) {
+    localEdits.remove(name);
+    previewOverride.clear(name);
+    setFlash(`deleted local draft · ${id}`);
     return;
   }
   try {
-    await bff.overview.adminDelete(id);
-    await listQuery.refetch();
+    await bff.templateSync.disable(name);
+    localEdits.remove(name);
+    previewOverride.clear(name);
+    await Promise.all([listQuery.refetch(), sources.refetch(), refreshConfigBundle()]);
     setFlash(`deleted · ${id}`);
   } catch (err) {
     setFlash(err instanceof Error ? `error: ${err.message}` : 'delete failed');
@@ -269,7 +504,9 @@ async function deleteCurrentDash(): Promise<void> {
 const detailQuery = useQuery({
   queryKey: computed(() => ['admin/overview-templates', selectedId.value]),
   queryFn: () => bff.overview.adminGet(selectedId.value),
-  enabled: computed(() => selectedId.value.length > 0),
+  // Don't fetch detail for a local-only draft — there's no server copy
+  // (it would 404); the editor loads it straight from the local draft.
+  enabled: computed(() => selectedId.value.length > 0 && isServerBacked.value),
   staleTime: 60_000,
 });
 
@@ -285,17 +522,73 @@ const layerOptions = computed<string[]>(() => {
   return Array.from(live).sort();
 });
 
+/* ── Editor sources ───────────────────────────────────────────────
+ * Load from LOCAL (browser draft), BUNDLED (shipped), or REMOTE (OAP).
+ * `editorSource` tracks which is loaded; `loadedSnapshot` is the baseline
+ * for `isDirty`. Save always writes LOCAL; publish LOCAL → OAP after.
+ * ─────────────────────────────────────────────────────────────────── */
+const editName = computed(() => (selectedId.value ? overviewEditName(selectedId.value) : ''));
+const hasLocalDraft = computed<boolean>(() => !!editName.value && localEdits.has(editName.value));
+const remoteAvailable = computed<boolean>(() => !!editName.value && sources.hasRemote(editName.value));
+// Ships a bundled default → not deletable (delete would fall back to the
+// bundle). Drives both the disabled delete button and the guard above.
+const bundledExists = computed<boolean>(() => !!editName.value && sources.hasBundled(editName.value));
+
 const draft = ref<OverviewDashboard | null>(null);
-watch(
-  () => detailQuery.data.value,
-  (resp) => {
-    if (!resp) {
-      draft.value = null;
-      return;
-    }
-    draft.value = JSON.parse(JSON.stringify(resp.dashboard)) as OverviewDashboard;
-  },
-);
+const editorSource = ref<'local' | 'bundled' | 'remote'>('bundled');
+const loadedSnapshot = ref<string>('');
+
+function bundledContent(): OverviewDashboard | null {
+  return (
+    sources.bundled<OverviewDashboard>(editName.value) ??
+    detailQuery.data.value?.dashboard ??
+    null
+  );
+}
+function sourceContent(src: 'local' | 'bundled' | 'remote'): OverviewDashboard | null {
+  if (src === 'local') return localEdits.get<OverviewDashboard>(editName.value) ?? null;
+  if (src === 'remote') return sources.remote<OverviewDashboard>(editName.value);
+  return bundledContent();
+}
+function loadFrom(src: 'local' | 'bundled' | 'remote'): void {
+  const c = sourceContent(src);
+  draft.value = c ? (JSON.parse(JSON.stringify(c)) as OverviewDashboard) : null;
+  loadedSnapshot.value = draft.value ? JSON.stringify(draft.value) : '';
+  editorSource.value = src;
+}
+
+/** Write the editor content to the local draft (cleared if it equals
+ *  remote). Drives hasLocalDraft / localDiffersFromRemote reactively. */
+function persistLocal(content: OverviewDashboard): void {
+  const remote = sources.remote<OverviewDashboard>(editName.value);
+  if (remote && stableStringify(content) === stableStringify(remote)) {
+    localEdits.remove(editName.value);
+  } else {
+    localEdits.set(editName.value, JSON.parse(JSON.stringify(content)));
+  }
+  loadedSnapshot.value = JSON.stringify(content);
+}
+
+// "Reset to ▾" dropdown — reload from a source AND adopt it as the local
+// draft (so the choice persists + Push/diff status updates).
+const resetDropdownOpen = ref(false);
+function resetTo(src: 'bundled' | 'remote'): void {
+  loadFrom(src);
+  if (draft.value) persistLocal(draft.value);
+  resetDropdownOpen.value = false;
+}
+
+// "Preview ▾" dropdown — open the real overview page in a new tab
+// rendering the chosen source (writes it to the preview-override store).
+const previewDropdownOpen = ref(false);
+function previewLive(src: 'local' | 'bundled' | 'remote'): void {
+  const content = sourceContent(src);
+  if (!content || !selectedId.value) return;
+  previewOverride.set(editName.value, content);
+  const href = router.resolve({ path: `/overview/${selectedId.value}`, query: { mode: 'preview', source: src } }).href;
+  previewDropdownOpen.value = false;
+  window.open(href, '_blank', 'noopener');
+}
 
 const flash = ref<string | null>(null);
 const saving = ref(false);
@@ -306,51 +599,76 @@ function setFlash(msg: string): void {
   }, 4000);
 }
 
-const isDirty = computed<boolean>(() => {
-  const cur = draft.value;
-  const orig = detailQuery.data.value?.dashboard;
-  if (!cur || !orig) return false;
-  return JSON.stringify(cur) !== JSON.stringify(orig);
-});
-
-// Diverged-row controls: shown only when OAP's copy differs from
-// bundled for the currently-selected dashboard. The "show diff &
-// reset" modal opens a Monaco side-by-side and the destructive
-// confirm UI (operator types the dashboard id to arm Reset).
-const selectedStatus = computed(() =>
-  selectedId.value ? sync.badgeFor(`horizon.overview.${selectedId.value}`) : null,
+const isDirty = computed<boolean>(() =>
+  draft.value ? JSON.stringify(draft.value) !== loadedSnapshot.value : false,
 );
-const isDiverged = computed(() => selectedStatus.value === 'diverged');
-const diffModalOpen = ref(false);
-function openDiffModal(): void { diffModalOpen.value = true; }
-async function onDiffReset(): Promise<void> {
-  await detailQuery.refetch();
-  setFlash('OAP reset to bundled · reload the overview to see widget changes');
+
+// Which source to seed the editor from for the current selection. Local
+// draft wins; then honour an explicit "remote" choice; then bundled if it
+// exists; else remote (a remote-only dashboard has no bundled base, so
+// bundled would load nothing). Falls back to bundled when nothing exists.
+function defaultEditorSource(): 'local' | 'bundled' | 'remote' {
+  if (hasLocalDraft.value) return 'local';
+  if (editorSource.value === 'remote' && remoteAvailable.value) return 'remote';
+  if (bundledContent()) return 'bundled';
+  if (remoteAvailable.value) return 'remote';
+  return 'bundled';
 }
 
-async function onSave(): Promise<void> {
-  if (!draft.value || !selectedId.value) return;
+// Seed the editor when the selected overview (or a background refetch)
+// changes — but never clobber unsaved keystrokes.
+watch(
+  [selectedId, () => detailQuery.data.value, hasLocalDraft, remoteAvailable],
+  () => {
+    if (isDirty.value) return;
+    loadFrom(defaultEditorSource());
+  },
+  { immediate: true },
+);
+
+/** Save the editor to the browser local draft. Never writes OAP. */
+function onSave(): void {
+  if (!draft.value || !editName.value) return;
+  persistLocal(draft.value);
+  editorSource.value = 'local';
+  setFlash('Saved to your browser (local). Publish with “Check diff & push”.');
+}
+
+// Push = publish local → remote. Available only when a local draft exists
+// AND differs from remote. The confirm modal shows the remote→local diff.
+const pushDiffOpen = ref(false);
+// Key-stable so a one-field edit doesn't read as "everything changed".
+function prettyJson(o: unknown): string {
+  return o ? stableStringify(o, 2) : '';
+}
+const localDiffersFromRemote = computed<boolean>(() => {
+  const local = localEdits.get<OverviewDashboard>(editName.value);
+  if (!local) return false;
+  return stableStringify(local) !== stableStringify(sources.remote<OverviewDashboard>(editName.value) ?? null);
+});
+const pushLocalPretty = computed(() => prettyJson(localEdits.get(editName.value)));
+const pushRemotePretty = computed(() => prettyJson(sources.remote(editName.value)));
+
+/** Publish the local draft to OAP, clear it, reload remote. */
+async function pushToOap(): Promise<void> {
+  const local = localEdits.get<OverviewDashboard>(editName.value);
+  if (!local || saving.value) return;
   saving.value = true;
   try {
-    // Save writes the LOCAL bundled template (not OAP). The edit renders
-    // locally and shows as diverged until published to OAP via "Sync all
-    // to OAP". Works even when OAP is unreachable.
-    await bff.templateSync.saveLocal(`horizon.overview.${selectedId.value}`, draft.value);
-    await detailQuery.refetch();
-    await refreshConfigBundle(); // refresh diverged badges + sidebar warning
-    setFlash('Saved locally — not yet live for others. Use “Sync all to OAP” to publish.');
+    await bff.templateSync.save(editName.value, local);
+    localEdits.remove(editName.value);
+    previewOverride.clear(editName.value); // drop the now-stale preview snapshot
+    await Promise.all([sources.refetch(), detailQuery.refetch(), refreshConfigBundle()]);
+    loadFrom('remote');
+    pushDiffOpen.value = false;
+    setFlash('Published your local draft to OAP — now live for everyone.');
   } catch (err) {
-    setFlash(err instanceof Error ? `error: ${err.message}` : 'save failed');
+    setFlash(err instanceof Error ? `error: ${err.message}` : 'push failed');
   } finally {
     saving.value = false;
   }
 }
 
-function onReset(): void {
-  const orig = detailQuery.data.value?.dashboard;
-  if (orig) draft.value = JSON.parse(JSON.stringify(orig)) as OverviewDashboard;
-}
-
 // ── KPI row helpers (kpi-tile only) ────────────────────────────────
 function addKpi(w: OverviewWidget): void {
   const next = [...(w.kpis ?? []), { label: 'new KPI', mqe: '' } as OverviewKpi];
@@ -387,8 +705,10 @@ function moveWidget(idx: number, dir: -1 | 1): void {
 }
 function removeWidget(idx: number): void {
   if (!draft.value) return;
+  const removed = draft.value.widgets[idx];
   const next = draft.value.widgets.filter((_, i) => i !== idx);
   draft.value = { ...draft.value, widgets: next };
+  if (removed && removed.id === selectedWidgetId.value) selectedWidgetId.value = null;
 }
 
 // ── Add-widget composer ────────────────────────────────────────────
@@ -458,6 +778,7 @@ function createWidget(): void {
   };
   draft.value = { ...draft.value, widgets: [...draft.value.widgets, widget] };
   composerOpen.value = false;
+  selectedWidgetId.value = widget.id; // open the drawer to finish editing it
 }
 
 // ── Widget meta ────────────────────────────────────────────────────
@@ -501,78 +822,94 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
 
     <SyncStatusBanner :banner="sync.banner.value" />
 
-    <div class="ot__toolbar">
-      <SyncAllButton kind="overview" />
-    </div>
-
     <div v-if="listQuery.isPending.value" class="ot__empty">loading…</div>
 
-    <div v-else class="ot__split">
-      <ul class="ot__list">
-        <li
-          v-for="d in dashboards"
-          :key="d.id"
-          class="ot__list-item"
-          :class="{ active: d.id === selectedId, readonly: !d.editable }"
-          @click="selectedId = d.id"
-        >
-          <div class="ot__list-title">{{ d.title }}</div>
-          <div v-if="d.description" class="ot__list-desc">{{ d.description }}</div>
-          <div class="ot__list-meta">
-            <code>{{ d.id }}</code>
-            <span>{{ d.widgetCount }} widget{{ d.widgetCount === 1 ? '' : 's' }}</span>
-            <span v-if="!d.editable" class="ot__readonly-tag">no source file</span>
-            <TemplateStatusBadge :status="sync.badgeFor(`horizon.overview.${d.id}`)" />
-          </div>
-        </li>
-        <li v-if="dashboards.length === 0" class="ot__list-empty">No overview templates loaded.</li>
-        <li class="ot__list-add">
-          <button
-            v-if="!newDashOpen"
-            type="button"
-            class="ot__add-trigger ot__add-trigger--list"
-            :disabled="sync.readOnly.value"
-            :title="sync.readOnly.value ? 'OAP unreachable — cannot create' : ''"
-            @click="openNewDash"
-          >+ New dashboard</button>
-          <div v-else class="ot__newdash">
-            <label class="ot__field">
-              <span>Id</span>
-              <input
-                v-model="newDashId"
-                type="text"
-                class="ot__in"
-                placeholder="my-overview"
-                @keyup.enter="createDash"
-              />
-            </label>
-            <label class="ot__field">
-              <span>Title</span>
-              <input
-                v-model="newDashTitle"
-                type="text"
-                class="ot__in"
-                placeholder="My overview"
-                @keyup.enter="createDash"
-              />
-            </label>
-            <label class="ot__field ot__field--wide">
-              <span>Description (optional)</span>
-              <textarea
-                v-model="newDashDescription"
-                class="ot__in ot__in--ta"
-                rows="2"
-                placeholder="Short, one-paragraph description shown under the dashboard title."
-              />
-            </label>
-            <div v-if="newDashError" class="ot__newdash-err">{{ newDashError }}</div>
-            <div class="ot__newdash-foot">
-              <button type="button" class="ot__btn" @click="cancelNewDash">cancel</button>
-              <button type="button" class="ot__btn ot__btn--primary" @click="createDash">create</button>
+    <div v-else class="ot__main">
+      <!-- Top dashboard picker — filterable dropdown (same pattern as the
+           layer dashboards switcher); "+ New" lives inside it. -->
+      <div class="ot__picker-bar">
+        <div class="ot__dd">
+          <button type="button" class="ot__dd-btn" @click="listDropdownOpen = !listDropdownOpen">
+            <span class="ot__dd-title">{{ selectedDash?.title || 'Select dashboard' }}</span>
+            <code v-if="selectedDash" class="ot__dd-id">{{ selectedDash.id }}</code>
+            <span v-if="selectedId && hasLocalDraftFor(selectedId)" class="ot__local-badge" title="Unpublished local draft">local</span>
+            <TemplateStatusBadge v-if="selectedId" :status="sync.badgeFor(`horizon.overview.${selectedId}`)" />
+            <span class="caret" :class="{ open: listDropdownOpen }">›</span>
+          </button>
+          <template v-if="listDropdownOpen">
+            <div class="ot__dd-backdrop" @click="listDropdownOpen = false" />
+            <div class="ot__dd-pop">
+              <!-- Browse mode: search + filters + list. Hidden while the
+                   New-dashboard composer is open so the form stands alone. -->
+              <template v-if="!newDashOpen">
+              <div class="ot__dd-search">
+                <input v-model="listSearch" type="text" placeholder="Search dashboards…" autocomplete="off" spellcheck="false" />
+              </div>
+              <div class="ot__dd-filters">
+                <label class="ot__dd-filter" :class="{ on: divergedOnly }">
+                  <input v-model="divergedOnly" type="checkbox" :disabled="divergedCount === 0" />
+                  Diverged<span v-if="divergedCount" class="ot__dd-fc">{{ divergedCount }}</span>
+                </label>
+                <label class="ot__dd-filter" :class="{ on: localOnly }">
+                  <input v-model="localOnly" type="checkbox" :disabled="localDraftCount === 0" />
+                  Local<span v-if="localDraftCount" class="ot__dd-fc local">{{ localDraftCount }}</span>
+                </label>
+              </div>
+              <div class="ot__dd-list">
+                <button
+                  v-for="d in filteredDashboards"
+                  :key="d.id"
+                  type="button"
+                  class="ot__dd-item"
+                  :class="{ active: d.id === selectedId }"
+                  @click="pickDashboard(d.id)"
+                >
+                  <span class="ot__dd-item-title">{{ d.title }}</span>
+                  <code>{{ d.id }}</code>
+                  <span class="ot__dd-item-count">{{ d.widgetCount }}w</span>
+                  <span v-if="!d.editable" class="ot__readonly-tag">no source</span>
+                  <span v-if="hasLocalDraftFor(d.id)" class="ot__local-badge" title="Unpublished local draft">local</span>
+                  <TemplateStatusBadge :status="sync.badgeFor(`horizon.overview.${d.id}`)" />
+                </button>
+                <p v-if="filteredDashboards.length === 0" class="ot__dd-empty">No dashboards match.</p>
+              </div>
+              <div class="ot__dd-new">
+                <button
+                  type="button"
+                  class="ot__btn"
+                  :disabled="sync.readOnly.value"
+                  :title="sync.readOnly.value ? 'OAP unreachable — cannot create' : ''"
+                  @click="openNewDash"
+                >+ New dashboard</button>
+              </div>
+              </template>
+
+              <!-- Create mode: self-contained form (replaces the list). -->
+              <div v-else class="ot__newdash">
+                <div class="ot__newdash-head">New dashboard</div>
+                <label class="ot__field ot__field--wide">
+                  <span>Id (used as the name — must be unique)</span>
+                  <input v-model="newDashId" type="text" class="ot__in ot__in--mono" placeholder="my-overview" @keyup.enter="createDash" />
+                </label>
+                <label class="ot__field ot__field--wide">
+                  <span>Title</span>
+                  <input v-model="newDashTitle" type="text" class="ot__in" placeholder="My overview" @keyup.enter="createDash" />
+                </label>
+                <label class="ot__field ot__field--wide">
+                  <span>Description (optional)</span>
+                  <textarea v-model="newDashDescription" class="ot__in ot__in--ta" rows="2" placeholder="Short, one-paragraph description shown under the dashboard title." />
+                </label>
+                <div v-if="newDashError" class="ot__newdash-err">{{ newDashError }}</div>
+                <div class="ot__newdash-foot">
+                  <button type="button" class="ot__btn" @click="cancelNewDash">cancel</button>
+                  <button type="button" class="ot__btn ot__btn--primary" @click="createDash">create</button>
+                </div>
+              </div>
             </div>
-          </div>
-        </li>
-      </ul>
+          </template>
+        </div>
+        <span class="ot__picker-count">{{ dashboards.length }} dashboard{{ dashboards.length === 1 ? '' : 's' }}</span>
+      </div>
 
       <section class="ot__detail">
         <div v-if="detailQuery.isPending.value && !draft" class="ot__empty">loading…</div>
@@ -582,40 +919,87 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
             <span class="ot__count mono">
               {{ draft.widgets.length }} widget{{ draft.widgets.length === 1 ? '' : 's' }}
             </span>
-            <!-- Tab strip — Config (editor) / Preview (mock board). -->
-            <div class="ot__tabs" role="tablist">
+            <span v-if="flash" class="ot__flash">{{ flash }}</span>
+            <span v-else-if="isDirty" class="ot__dirty">unsaved changes</span>
+            <span v-else class="ot__clean">saved</span>
+            <!-- Delete. A local-only draft is removed from the browser; a
+                 dashboard on OAP (bundled or remote-only) is soft-disabled
+                 (OAP has no hard delete). Irreversible from the UI. -->
+            <button
+              type="button"
+              class="ot__head-btn ot__head-btn--danger"
+              :disabled="sync.readOnly.value && (remoteAvailable || bundledExists)"
+              :title="(sync.readOnly.value && (remoteAvailable || bundledExists))
+                ? 'OAP unreachable — cannot delete'
+                : bundledExists
+                  ? `Disable built-in dashboard ${draft.id} (OAP has no hard delete — hidden, irreversible from the UI)`
+                  : remoteAvailable
+                    ? `Delete dashboard ${draft.id} (soft-disabled on OAP — irreversible from the UI)`
+                    : `Delete local draft ${draft.id} (never published)`"
+              @click="askDeleteDash"
+            >{{ bundledExists ? 'disable' : 'delete' }}</button>
+            <!-- Source / save / publish actions, right-aligned (same row as
+                 the title + tabs, mirroring the layer dashboards editor). -->
+            <div class="ot__head-actions">
+              <span class="ot__src" :title="`Editing from: ${editorSource}`">{{ editorSource }}</span>
+              <div class="reset-dd">
+                <button type="button" class="ot__btn" @click="resetDropdownOpen = !resetDropdownOpen">
+                  reset to <span class="caret" :class="{ open: resetDropdownOpen }">›</span>
+                </button>
+                <template v-if="resetDropdownOpen">
+                  <div class="reset-dd-backdrop" @click="resetDropdownOpen = false" />
+                  <div class="reset-dd-pop">
+                    <button class="reset-dd-item" type="button" title="Discard current edits and reload the bundled default." @click="resetTo('bundled')">Bundled</button>
+                    <button class="reset-dd-item" type="button" :disabled="!remoteAvailable" :title="remoteAvailable ? 'Discard current edits and reload OAP\'s live version.' : 'OAP has no copy yet.'" @click="resetTo('remote')">Remote</button>
+                  </div>
+                </template>
+              </div>
+              <div class="reset-dd">
+                <button type="button" class="ot__btn" @click="previewDropdownOpen = !previewDropdownOpen">
+                  preview <span class="caret" :class="{ open: previewDropdownOpen }">›</span>
+                </button>
+                <template v-if="previewDropdownOpen">
+                  <div class="reset-dd-backdrop" @click="previewDropdownOpen = false" />
+                  <div class="reset-dd-pop">
+                    <button class="reset-dd-item" type="button" :disabled="!hasLocalDraft" title="Preview your unpublished local draft." @click="previewLive('local')">Local</button>
+                    <button class="reset-dd-item" type="button" title="Preview the bundled (shipped) default." @click="previewLive('bundled')">Bundled</button>
+                    <button class="reset-dd-item" type="button" :disabled="!remoteAvailable" title="Preview OAP's live version." @click="previewLive('remote')">Remote</button>
+                  </div>
+                </template>
+              </div>
               <button
+                v-if="!sync.readOnly.value"
                 type="button"
-                role="tab"
-                class="ot__tab"
-                :class="{ active: viewMode === 'config' }"
-                :aria-selected="viewMode === 'config'"
-                @click="viewMode = 'config'"
-              >Config</button>
+                class="ot__btn"
+                :disabled="!localDiffersFromRemote || saving"
+                :title="localDiffersFromRemote ? 'Review the local → remote diff, then publish to OAP.' : 'No local changes to publish — local matches remote.'"
+                @click="pushDiffOpen = true"
+              >
+                check diff &amp; push
+              </button>
               <button
                 type="button"
-                role="tab"
-                class="ot__tab"
-                :class="{ active: viewMode === 'preview' }"
-                :aria-selected="viewMode === 'preview'"
-                @click="viewMode = 'preview'"
-              >Preview</button>
+                class="ot__btn ot__btn--primary"
+                :disabled="!isDirty || saving"
+                title="Save the editor to your browser (local). Publish later with “Check diff & push”."
+                @click="onSave"
+              >
+                {{ saving ? 'saving…' : 'save (local)' }}
+              </button>
             </div>
-            <button
-              type="button"
-              class="ot__head-btn ot__head-btn--danger"
-              :disabled="sync.readOnly.value"
-              :title="sync.readOnly.value ? 'OAP unreachable — cannot delete' : `Delete dashboard ${draft.id}`"
-              @click="deleteCurrentDash"
-            >delete</button>
           </header>
 
-          <div v-if="viewMode === 'config'" class="ot__widgets">
-            <!-- Dashboard-level meta: title + description. These show
-                 on every page that references the dashboard (sidebar
-                 list, sub-heading on the page itself, admin list).
-                 Description is optional but recommended. -->
-            <section class="ot__meta">
+          <!-- One-page editor: mock-data widget grid (canvas, left) +
+               drawer (right) that edits the clicked widget. -->
+          <div class="ot__editor-split" :class="{ 'has-sel': !!selectedWidgetId }">
+
+          <aside v-if="selectedWidgetId" class="ot__drawer-pane ot__widgets">
+            <div class="ot__drawer-head">
+              <span class="ot__drawer-title">{{ selectedWidgetId === META_SEL ? 'Dashboard meta' : 'Edit widget' }}</span>
+              <button type="button" class="ot__drawer-close" title="Close (Esc)" @click="selectedWidgetId = null">✕</button>
+            </div>
+            <!-- Dashboard-level meta: title + description. -->
+            <section v-if="selectedWidgetId === META_SEL" class="ot__meta">
               <header class="ot__meta-head">
                 <span class="ot__meta-kicker">Dashboard meta</span>
                 <span class="ot__meta-hint">Shown in the sidebar and as the page sub-heading.</span>
@@ -637,6 +1021,7 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
 
             <article
               v-for="(w, wi) in draft.widgets"
+              v-show="w.id === selectedWidgetId"
               :key="w.id"
               class="ot__widget"
               :class="`ot__widget--${w.type}`"
@@ -757,183 +1142,181 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
                   <div v-if="(w.kpis ?? []).length === 0" class="ot__kpis-empty">
                     No KPI rows. Add one to surface a metric on the tile.
                   </div>
-                  <table v-else class="ot__kpi-table">
-                    <thead>
-                      <tr>
-                        <th></th>
-                        <th>Label</th>
-                        <th>Source</th>
-                        <th>MQE</th>
-                        <th>Unit</th>
-                        <th>Aggr</th>
-                        <th>Style</th>
-                        <th>Max</th>
-                        <th></th>
-                      </tr>
-                    </thead>
-                    <tbody>
-                      <tr v-for="(k, i) in w.kpis ?? []" :key="i">
-                        <td>
+                  <!-- One card per KPI. The drawer is narrow (sidebar-only),
+                       so fields stack vertically instead of a wide table
+                       row that truncates every column. -->
+                  <ul v-else class="ot__kpi-cards">
+                    <li v-for="(k, i) in w.kpis ?? []" :key="i" class="ot__kpi-card">
+                      <div class="ot__kpi-card-head">
+                        <span class="ot__kpi-card-idx">#{{ i + 1 }}</span>
+                        <div class="ot__kpi-card-ord">
                           <button
                             type="button"
                             class="ot__arrow"
+                            title="Move up"
                             :disabled="i === 0"
                             @click="moveKpi(w, i, -1)"
-                          >‹</button>
+                          >↑</button>
                           <button
                             type="button"
                             class="ot__arrow"
+                            title="Move down"
                             :disabled="i === (w.kpis ?? []).length - 1"
                             @click="moveKpi(w, i, 1)"
-                          >›</button>
-                        </td>
-                        <td><input v-model="k.label" type="text" class="ot__in" /></td>
-                        <td>
-                          <select v-model="k.source" class="ot__in ot__in--narrow">
-                            <option :value="undefined">mqe</option>
-                            <option value="mqe">mqe</option>
-                            <option value="service-count">service-count</option>
-                          </select>
-                        </td>
-                        <td>
-                          <input
-                            v-if="(k.source ?? 'mqe') === 'mqe'"
-                            v-model="k.mqe"
-                            type="text"
-                            class="ot__in ot__in--mono"
-                          />
-                          <span v-else class="ot__none">— (listServices)</span>
-                        </td>
-                        <td><input v-model="k.unit" type="text" class="ot__in ot__in--xnarrow" /></td>
-                        <td>
-                          <select v-model="k.aggregation" class="ot__in ot__in--xnarrow">
+                          >↓</button>
+                        </div>
+                        <button type="button" class="ot__del" title="Remove row" @click="removeKpi(w, i)">×</button>
+                      </div>
+                      <label class="ot__field ot__field--full">
+                        <span>Label</span>
+                        <input v-model="k.label" type="text" class="ot__in" />
+                      </label>
+                      <label class="ot__field ot__field--full">
+                        <span>Source</span>
+                        <select v-model="k.source" class="ot__in">
+                          <option :value="undefined">mqe</option>
+                          <option value="mqe">mqe</option>
+                          <option value="service-count">service-count</option>
+                        </select>
+                      </label>
+                      <label v-if="(k.source ?? 'mqe') === 'mqe'" class="ot__field ot__field--full">
+                        <span>MQE</span>
+                        <input v-model="k.mqe" type="text" class="ot__in ot__in--mono" />
+                      </label>
+                      <p v-else class="ot__none">Value comes from the service count (listServices) — no MQE.</p>
+                      <div class="ot__kpi-card-grid">
+                        <label class="ot__field">
+                          <span>Unit</span>
+                          <input v-model="k.unit" type="text" class="ot__in" />
+                        </label>
+                        <label class="ot__field">
+                          <span>Aggr</span>
+                          <select v-model="k.aggregation" class="ot__in">
                             <option :value="undefined">—</option>
                             <option value="avg">avg</option>
                             <option value="sum">sum</option>
                           </select>
-                        </td>
-                        <td>
-                          <select
-                            v-model="k.style"
-                            class="ot__in ot__in--narrow"
-                            @change="onKpiStyleChange(k)"
-                          >
+                        </label>
+                        <label class="ot__field">
+                          <span>Style</span>
+                          <select v-model="k.style" class="ot__in" @change="onKpiStyleChange(k)">
                             <option :value="undefined">number</option>
                             <option value="number">number</option>
                             <option value="progress-bar">progress-bar</option>
                           </select>
-                        </td>
-                        <td>
+                        </label>
+                        <label v-if="k.style === 'progress-bar'" class="ot__field">
+                          <span>Max</span>
                           <input
-                            v-if="k.style === 'progress-bar'"
                             v-model.number="k.max"
                             type="number"
                             min="0"
                             step="any"
-                            class="ot__in ot__in--xnum"
+                            class="ot__in"
                             placeholder="100"
                           />
-                          <span v-else class="ot__none">—</span>
-                        </td>
-                        <td>
-                          <button type="button" class="ot__del" @click="removeKpi(w, i)">×</button>
-                        </td>
-                      </tr>
-                    </tbody>
-                  </table>
+                        </label>
+                      </div>
+                    </li>
+                  </ul>
                 </div>
               </template>
             </article>
 
-            <!-- Add-widget composer. Operator picks type + size FIRST
-                 (per the spec); on Create the widget is appended with
-                 sensible defaults and the inline editor handles the
-                 rest (title / layer / MQE / KPI rows / etc.). -->
-            <div class="ot__add-widget">
-              <button
-                v-if="!composerOpen"
-                type="button"
-                class="ot__add-trigger"
-                @click="openComposer"
-              >
-                + Add widget
-              </button>
-              <article v-else class="ot__widget ot__composer">
-                <header class="ot__widget-head">
-                  <span class="ot__widget-kind">New widget</span>
-                </header>
-                <div class="ot__row">
-                  <label class="ot__field">
-                    <span>Type</span>
-                    <select v-model="composerType" class="ot__in ot__in--narrow">
-                      <option v-for="opt in WIDGET_TYPE_OPTIONS" :key="opt.type" :value="opt.type">
-                        {{ opt.label }}
-                      </option>
-                    </select>
-                  </label>
-                  <label v-if="composerType !== 'section-break'" class="ot__field">
-                    <span>Width (span)</span>
-                    <input v-model.number="composerSpan" type="number" min="1" max="12" class="ot__in ot__in--num" />
-                  </label>
-                  <label v-if="composerType !== 'section-break'" class="ot__field">
-                    <span>Height (rows)</span>
-                    <input v-model.number="composerRowSpan" type="number" min="1" max="12" class="ot__in ot__in--num" />
-                  </label>
-                </div>
-                <div class="ot__composer-foot">
-                  <span class="ot__composer-hint">
-                    A default title + content scaffold is generated; you can edit everything below after creating.
-                  </span>
-                  <button type="button" class="ot__btn" @click="cancelComposer">cancel</button>
-                  <button type="button" class="ot__btn ot__btn--primary" @click="createWidget">
-                    create
-                  </button>
-                </div>
-              </article>
-            </div>
-          </div>
-
-          <!-- Layout + mock-data preview. Sits on its own tab so it
-               gets the full pane width, no scroll-past-editor needed. -->
-          <section v-if="viewMode === 'preview'" class="ot__preview">
-            <header class="ot__preview-head">
-              <h3>Preview</h3>
-              <span class="ot__preview-hint">layout + mock data · not live OAP values</span>
+          </aside>
+
+          <!-- Canvas: mock-data widget grid (left). Click a widget to edit
+               it in the drawer; only the live "Preview ▾" page uses real
+               OAP data. -->
+          <div class="ot__canvas-pane ot__preview">
+            <div class="ot__canvas-hint">layout + mock data · click a widget to edit · the live page uses real data</div>
+            <!-- Page heading preview — the dashboard title + description as
+                 they render on the live overview page. Click to edit the
+                 dashboard meta in the drawer, like any widget. -->
+            <header
+              class="ot__canvas-title"
+              :class="{ 'ot__cw--sel': selectedWidgetId === META_SEL }"
+              @click="selectWidget(META_SEL)"
+            >
+              <h2>{{ draft.title || draft.id }}</h2>
+              <p v-if="draft.description" class="ot__canvas-desc">{{ draft.description }}</p>
             </header>
             <div
               v-for="(sec, si) in previewSections"
               :key="si"
               class="ot__pv-section"
             >
-              <div v-if="previewSectionTitle(si)" class="ot__pv-section-head">
-                {{ previewSectionTitle(si) }}
+              <!-- Section break (a.k.a. line-break / text header) — a real
+                   selectable widget; click to edit its title + columns. -->
+              <div
+                v-if="sec.sb"
+                class="ot__pv-section-head ot__pv-sb"
+                :class="{ 'ot__cw--sel': selectedWidgetId === sec.sb.id, 'ot__cw--drag': dragId === sec.sb.id }"
+                draggable="true"
+                @click="selectWidget(sec.sb.id)"
+                @dragstart="onWidgetDragStart($event, sec.sb.id)"
+                @dragend="dragId = null"
+                @dragover.prevent
+                @drop.prevent="onWidgetDrop(sec.sb.id)"
+              >
+                <span class="ot__pv-sb-tag">section</span>
+                <span class="ot__pv-sb-title">{{ sec.sb.title || '(untitled section)' }}</span>
+                <span class="ot__pv-sb-cols">{{ sec.cols }} cols</span>
               </div>
               <div
                 class="ot__pv-grid"
                 :style="{ gridTemplateColumns: `repeat(${sec.cols}, minmax(0, 1fr))` }"
               >
-                <article
+                <!-- Each widget is wrapped in a selectable cell. The inner
+                     preview (the real overview component, mock data) is
+                     pointer-events:none so a click selects the widget for
+                     editing instead of following KpiTileWidget's link. -->
+                <div
                   v-for="w in sec.widgets"
                   :key="w.id"
-                  class="ot__pv-card"
-                  :style="{
-                    gridColumn: `span ${Math.min(w.span ?? 12, sec.cols)}`,
-                    gridRow: `span ${w.rowSpan ?? 1}`,
-                  }"
+                  class="ot__cw"
+                  :class="{ 'ot__cw--sel': selectedWidgetId === w.id, 'ot__cw--drag': dragId === w.id }"
+                  :style="previewCellStyle(w, sec.cols)"
+                  draggable="true"
+                  @click="selectWidget(w.id)"
+                  @dragstart="onWidgetDragStart($event, w.id)"
+                  @dragend="dragId = null"
+                  @dragover.prevent
+                  @drop.prevent="onWidgetDrop(w.id)"
                 >
-                  <div class="ot__pv-card-head">
-                    <span class="ot__pv-kind">{{ widgetKindLabel(w.type) }}</span>
-                    <span v-if="w.layer" class="ot__pv-layer">{{ w.layer }}</span>
-                  </div>
-                  <div class="ot__pv-title">{{ w.title }}</div>
-                  <!-- Per-type mock body. -->
-                  <template v-if="w.type === 'metric'">
-                    <div class="ot__pv-bignum">
-                      {{ mockNumber(w.id) }}<span v-if="w.unit" class="ot__pv-unit">{{ w.unit }}</span>
+                  <MetricWidget
+                    v-if="w.type === 'metric'"
+                    :title="w.title"
+                    :tip="w.tip"
+                    :value="mockNumber(w.id)"
+                    :unit="w.unit"
+                  />
+                  <KpiTileWidget
+                    v-else-if="w.type === 'kpi-tile'"
+                    :title="w.title"
+                    :tip="w.tip"
+                    :layer="w.layer"
+                    :show-count="w.showCount ?? false"
+                    :count="mockNumber(w.id, 30)"
+                    :kpis="w.kpis ?? []"
+                    :kpi-values="mockKpiValues(w)"
+                  />
+                  <MetricCompositeWidget
+                    v-else-if="w.type === 'metric-composite'"
+                    :title="w.title"
+                    :tip="w.tip"
+                    :layer="w.layer"
+                    :kpis="w.kpis ?? []"
+                    :kpi-values="mockKpiValues(w)"
+                  />
+                  <article v-else class="ot__pv-card">
+                    <div class="ot__pv-card-head">
+                      <span class="ot__pv-kind">{{ widgetKindLabel(w.type) }}</span>
+                      <span v-if="w.layer" class="ot__pv-layer">{{ w.layer }}</span>
                     </div>
-                    <code v-if="w.mqe" class="ot__pv-mqe">{{ w.mqe }}</code>
-                  </template>
-                  <template v-else-if="w.type === 'alarms'">
+                    <div class="ot__pv-title">{{ w.title }}</div>
+                    <!-- Per-type mock body. -->
+                    <template v-if="w.type === 'alarms'">
                     <!-- Mock alarm rows — same shape as AlarmsWidget so
                          the operator sees the rail layout, not just a
                          count. Severity / message / scope all mocked. -->
@@ -973,103 +1356,135 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
                     </svg>
                     <div class="ot__pv-sub">topology · {{ w.layer ?? '—' }}</div>
                   </template>
-                  <template v-else-if="w.type === 'kpi-tile' || w.type === 'metric-composite'">
-                    <!-- Split layout: number-style → count tiles grid;
-                         progress-bar style → bars grid. Mirrors how the
-                         real MetricCompositeWidget partitions its KPIs. -->
-                    <div v-if="(w.kpis ?? []).length === 0" class="ot__pv-empty">
-                      no KPI rows configured
-                    </div>
-                    <template v-else>
-                      <div
-                        v-if="kpiCountRows(w).length > 0"
-                        class="ot__pv-num-grid"
-                      >
-                        <div
-                          v-for="k in kpiCountRows(w)"
-                          :key="k.label"
-                          class="ot__pv-num-tile"
-                        >
-                          <span class="ot__pv-num-val">
-                            {{ mockNumber(w.id + k.label) }}<span v-if="k.unit" class="ot__pv-num-unit">{{ k.unit }}</span>
-                          </span>
-                          <span class="ot__pv-num-label">{{ k.label }}</span>
-                        </div>
-                      </div>
-                      <div
-                        v-if="kpiBarRows(w).length > 0"
-                        class="ot__pv-bar-grid"
-                      >
-                        <div
-                          v-for="k in kpiBarRows(w)"
-                          :key="k.label"
-                          class="ot__pv-bar-row"
-                        >
-                          <div class="ot__pv-bar-head">
-                            <span class="ot__pv-bar-label">{{ k.label }}</span>
-                            <span class="ot__pv-bar-val">
-                              {{ mockPercent(w.id + k.label) }}{{ k.unit ?? '%' }}
-                            </span>
-                          </div>
-                          <div class="ot__pv-bar">
-                            <span
-                              class="ot__pv-bar-fill"
-                              :style="{ width: mockPercent(w.id + k.label) + '%' }"
-                            />
-                          </div>
-                        </div>
-                      </div>
-                    </template>
-                  </template>
-                </article>
+                  </article>
+                  <!-- Corner resize handle (drag to change span / rowSpan). -->
+                  <span
+                    class="ot__cw-resize"
+                    title="Drag to resize"
+                    @mousedown="onResizeStart($event, w, sec.cols)"
+                  />
+                </div>
               </div>
             </div>
             <div v-if="previewSections.length === 0" class="ot__pv-empty">
-              No widgets yet — add one above to see the preview.
+              No widgets yet — add one below.
+            </div>
+            <!-- Add-widget composer, on the canvas (the drawer only shows
+                 when a widget is selected). -->
+            <div class="ot__add-widget">
+              <button
+                v-if="!composerOpen"
+                type="button"
+                class="ot__add-trigger"
+                @click="openComposer"
+              >
+                + Add widget
+              </button>
+              <article v-else class="ot__widget ot__composer">
+                <header class="ot__widget-head">
+                  <span class="ot__widget-kind">New widget</span>
+                </header>
+                <div class="ot__row">
+                  <label class="ot__field">
+                    <span>Type</span>
+                    <select v-model="composerType" class="ot__in ot__in--narrow">
+                      <option v-for="opt in WIDGET_TYPE_OPTIONS" :key="opt.type" :value="opt.type">
+                        {{ opt.label }}
+                      </option>
+                    </select>
+                  </label>
+                  <label v-if="composerType !== 'section-break'" class="ot__field">
+                    <span>Width (span)</span>
+                    <input v-model.number="composerSpan" type="number" min="1" max="12" class="ot__in ot__in--num" />
+                  </label>
+                  <label v-if="composerType !== 'section-break'" class="ot__field">
+                    <span>Height (rows)</span>
+                    <input v-model.number="composerRowSpan" type="number" min="1" max="12" class="ot__in ot__in--num" />
+                  </label>
+                </div>
+                <div class="ot__composer-foot">
+                  <span class="ot__composer-hint">
+                    A default title + content scaffold is generated; you can edit everything after creating.
+                  </span>
+                  <button type="button" class="ot__btn" @click="cancelComposer">cancel</button>
+                  <button type="button" class="ot__btn ot__btn--primary" @click="createWidget">create</button>
+                </div>
+              </article>
             </div>
-          </section>
-
-          <div class="ot__actions">
-            <span v-if="flash" class="ot__flash">{{ flash }}</span>
-            <span v-else-if="isDirty" class="ot__dirty">unsaved changes</span>
-            <span v-else class="ot__clean">saved</span>
-            <button type="button" class="ot__btn" :disabled="!isDirty || saving" @click="onReset">
-              reset
-            </button>
-            <button
-              v-if="isDiverged && !sync.readOnly.value"
-              type="button"
-              class="ot__btn"
-              title="Show side-by-side diff vs OAP, and reset OAP back to bundled (with confirmation)."
-              @click="openDiffModal"
-            >
-              show diff &amp; reset
-            </button>
-            <button
-              type="button"
-              class="ot__btn ot__btn--primary"
-              :disabled="!isDirty || saving"
-              title="Save this edit to the local bundled template. Publish it to OAP for everyone with “Sync all to OAP”."
-              @click="onSave"
-            >
-              {{ saving ? 'saving…' : 'save locally' }}
-            </button>
+          </div>
           </div>
         </template>
       </section>
     </div>
 
-    <TemplateDiffModal
-      v-if="selectedId"
-      :open="diffModalOpen"
-      :name="`horizon.overview.${selectedId}`"
-      :confirm-key="selectedId"
-      @close="diffModalOpen = false"
-      @reset="onDiffReset"
-    />
+    <!-- Push confirm: shows the remote → local diff before publishing. -->
+    <Modal :open="pushDiffOpen" title="Publish local → OAP?" width="min(1100px, 94vw)" @close="pushDiffOpen = false">
+      <p class="ot__push-lede">
+        This replaces the live (remote) version with your local draft — live for everyone. Review the
+        diff (left = remote, right = your local):
+      </p>
+      <div class="ot__push-diff">
+        <MonacoDiff :original="pushRemotePretty" :modified="pushLocalPretty" language="json" />
+      </div>
+      <template #footer>
+        <button class="sw-btn" type="button" @click="pushDiffOpen = false">Cancel</button>
+        <button class="sw-btn is-primary" type="button" :disabled="saving" @click="pushToOap">
+          {{ saving ? 'Pushing…' : 'Confirm push' }}
+        </button>
+      </template>
+    </Modal>
+
+    <!-- Delete / disable confirm — styled (not the native confirm box). -->
+    <Modal :open="deleteOpen" :title="deleteTitle" width="min(520px, 92vw)" @close="deleteOpen = false">
+      <p class="ot__confirm-msg">{{ deleteMessage }}</p>
+      <template #footer>
+        <button class="sw-btn" type="button" @click="deleteOpen = false">Cancel</button>
+        <button class="sw-btn is-danger" type="button" @click="confirmDeleteDash">{{ deleteConfirmLabel }}</button>
+      </template>
+    </Modal>
   </div>
 </template>
 
+<style scoped>
+.ot__confirm-msg { margin: 0; font-size: 13px; line-height: 1.55; color: var(--sw-fg-1); }
+.sw-btn.is-danger { border-color: rgba(239, 68, 68, 0.4); color: #f87171; }
+.sw-btn.is-danger:hover { background: var(--sw-err-soft, rgba(239, 68, 68, 0.12)); }
+.reset-dd { position: relative; display: inline-flex; }
+.reset-dd .caret { display: inline-block; transform: rotate(90deg); font-size: 11px; }
+.reset-dd .caret.open { transform: rotate(-90deg); }
+.reset-dd-backdrop { position: fixed; inset: 0; z-index: 40; }
+.reset-dd-pop {
+  position: absolute;
+  top: calc(100% + 4px);
+  left: 0;
+  z-index: 41;
+  min-width: 130px;
+  padding: 4px;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  box-shadow: 0 10px 28px -8px rgba(0, 0, 0, 0.5);
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.reset-dd-item {
+  text-align: left;
+  padding: 6px 10px;
+  background: transparent;
+  border: none;
+  border-radius: 4px;
+  color: var(--sw-fg-1);
+  font: inherit;
+  font-size: 12px;
+  cursor: pointer;
+}
+.reset-dd-item:hover:not(:disabled) { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.reset-dd-item:disabled { opacity: 0.4; cursor: not-allowed; }
+.ot__push-lede { margin: 0 0 10px; font-size: 12px; color: var(--sw-fg-2); line-height: 1.5; }
+.ot__push-diff { height: 50vh; min-height: 320px; border: 1px solid var(--sw-line); border-radius: 6px; overflow: hidden; }
+</style>
+
 <style scoped>
 .ot { padding: 20px 20px 60px; max-width: 1400px; margin: 0 auto; }
 .ot__head { margin-bottom: 18px; }
@@ -1086,6 +1501,57 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
 .ot__empty { padding: 32px; text-align: center; color: var(--sw-fg-3); font-size: 12px; }
 .ot__toolbar { display: flex; justify-content: flex-end; margin: 8px 0; }
 
+.ot__main { display: flex; flex-direction: column; gap: 12px; }
+.ot__picker-bar { display: flex; align-items: center; gap: 10px; }
+.ot__picker-count { font-size: 10.5px; color: var(--sw-fg-3); }
+.ot__dd { position: relative; display: flex; }
+.ot__dd-btn {
+  display: inline-flex; align-items: center; gap: 8px;
+  height: 32px; padding: 0 12px;
+  background: var(--sw-bg-2); border: 1px solid var(--sw-line-2); border-radius: 6px;
+  color: var(--sw-fg-0); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; min-width: 240px;
+}
+.ot__dd-btn:hover { background: var(--sw-bg-3); }
+.ot__dd-btn .ot__dd-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px; }
+.ot__dd-btn .ot__dd-id { font-family: var(--sw-mono); font-size: 10px; color: var(--sw-fg-3); background: var(--sw-bg-1); padding: 1px 5px; border-radius: 3px; }
+.ot__dd-btn .caret { margin-left: auto; transform: rotate(90deg); transition: transform 0.15s; font-size: 12px; }
+.ot__dd-btn .caret.open { transform: rotate(-90deg); }
+.ot__dd-backdrop { position: fixed; inset: 0; z-index: 40; }
+.ot__dd-pop {
+  position: absolute; top: calc(100% + 4px); left: 0; z-index: 41;
+  width: min(80vw, 560px); max-height: 60vh; display: flex; flex-direction: column;
+  background: var(--sw-bg-1); border: 1px solid var(--sw-line); border-radius: 8px;
+  box-shadow: 0 12px 32px -8px rgba(0,0,0,0.5); padding: 6px;
+}
+.ot__dd-filters { display: flex; gap: 12px; padding: 2px 4px 8px; border-bottom: 1px solid var(--sw-line); margin-bottom: 4px; }
+.ot__dd-filter { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--sw-fg-2); cursor: pointer; }
+.ot__dd-filter.on { color: var(--sw-fg-0); }
+.ot__dd-filter input:disabled { cursor: not-allowed; }
+.ot__dd-fc { padding: 0 5px; border-radius: 8px; background: var(--sw-warn, var(--sw-accent)); color: #1a1a1a; font-size: 9.5px; font-weight: 700; }
+.ot__dd-search { padding: 2px 2px 6px; }
+.ot__dd-search input {
+  width: 100%; box-sizing: border-box; height: 30px; padding: 0 8px;
+  background: var(--sw-bg-2); border: 1px solid var(--sw-line-2); border-radius: 5px; color: var(--sw-fg-0); font: inherit; font-size: 12px;
+}
+.ot__dd-search input:focus { outline: none; border-color: var(--sw-accent); }
+.ot__dd-list { overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
+.ot__dd-item {
+  display: flex; align-items: center; gap: 8px; width: 100%;
+  padding: 7px 9px; border: none; background: transparent; border-radius: 5px;
+  color: var(--sw-fg-1); font: inherit; font-size: 12px; text-align: left; cursor: pointer;
+}
+.ot__dd-item:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+.ot__dd-item.active { background: var(--sw-accent-soft); color: var(--sw-accent-2); box-shadow: inset 2px 0 0 var(--sw-accent); }
+.ot__dd-item-title { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.ot__dd-item code { font-family: var(--sw-mono); font-size: 10px; color: var(--sw-fg-3); }
+.ot__dd-item-count { font-family: var(--sw-mono); font-size: 10px; color: var(--sw-fg-3); }
+.ot__dd-empty { padding: 10px; font-size: 11px; color: var(--sw-fg-3); }
+.ot__local-badge {
+  flex: 0 0 auto; font-size: 9px; font-weight: 700; letter-spacing: 0.06em;
+  text-transform: uppercase; color: #1a1a1a; background: var(--sw-warn, #f59e0b);
+  padding: 1px 6px; border-radius: 8px;
+}
+.ot__dd-new { border-top: 1px solid var(--sw-line); margin-top: 4px; padding: 8px 2px 2px; }
 .ot__split { display: grid; grid-template-columns: 280px 1fr; gap: 16px; align-items: start; }
 .ot__list {
   list-style: none; margin: 0; padding: 0;
@@ -1103,9 +1569,16 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
 .ot__list-empty { padding: 24px; text-align: center; font-size: 12px; color: var(--sw-fg-3); }
 .ot__list-add { padding: 8px; border-top: 1px solid var(--sw-line); }
 .ot__add-trigger--list { font-size: 11.5px; padding: 6px; }
-.ot__newdash { display: flex; flex-direction: column; gap: 6px; padding: 6px; }
+.ot__newdash { display: flex; flex-direction: column; gap: 10px; padding: 10px 12px; }
+.ot__newdash-head {
+  font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
+  color: var(--sw-fg-2); padding-bottom: 8px; border-bottom: 1px solid var(--sw-line);
+}
 .ot__newdash-err { font-size: 11px; color: var(--sw-err); }
-.ot__newdash-foot { display: flex; gap: 6px; justify-content: flex-end; }
+.ot__newdash-foot {
+  display: flex; gap: 8px; justify-content: flex-end;
+  padding-top: 8px; border-top: 1px solid var(--sw-line);
+}
 .ot__head-btn {
   background: transparent;
   border: 1px solid var(--sw-line-2);
@@ -1183,7 +1656,14 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
   border-bottom: 1px dashed var(--sw-line);
   margin-bottom: 6px;
 }
-.ot__pv-grid { display: grid; gap: 6px; }
+/* Mirror the live renderer's section grid (OverviewDashboardView): fixed
+   72px auto-rows so `rowSpan` is real height and `span` columns sit
+   side-by-side, instead of collapsing to content height (which stacks a
+   span-9 topology above a span-3 alarms rather than beside it). */
+.ot__pv-grid { display: grid; grid-auto-rows: 72px; gap: 8px; }
+/* Inner widget fills its spanned cell, like the live page (grid items
+   stretch by default; the inner component must take the full height). */
+.ot__cw > *:not(.ot__cw-resize) { height: 100%; box-sizing: border-box; }
 .ot__pv-card {
   background: var(--sw-bg-1);
   border: 1px solid var(--sw-line);
@@ -1317,10 +1797,89 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
 }
 
 .ot__detail { background: var(--sw-bg-1); border: 1px solid var(--sw-line); border-radius: 8px; padding: 16px; }
-.ot__detail-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
+/* One-page editor: canvas (left, mock-data grid) + drawer (right, editor).
+   DOM order is drawer-then-canvas; `order` flips them visually. */
+.ot__editor-split { display: flex; gap: 14px; align-items: flex-start; }
+.ot__canvas-pane { order: 1; flex: 1 1 0; min-width: 0; }
+.ot__drawer-pane {
+  order: 2;
+  flex: 0 0 380px;
+  max-width: 380px;
+  position: sticky;
+  top: 12px;
+  max-height: calc(100vh - 90px);
+  overflow-y: auto;
+  border-left: 1px solid var(--sw-line);
+  padding-left: 14px;
+}
+.ot__canvas-hint { font-size: 10.5px; color: var(--sw-fg-3); margin-bottom: 8px; }
+.ot__canvas-title {
+  margin-bottom: 12px; padding: 4px 6px 10px; cursor: pointer; border-radius: 6px;
+  /* Same selectable hint as the canvas cells. */
+  outline: 1px dashed var(--sw-line-2); outline-offset: 2px;
+}
+.ot__canvas-title:hover { background: var(--sw-bg-2); outline-color: var(--sw-fg-3); }
+.ot__canvas-title h2 { margin: 0; font-size: 17px; font-weight: 600; color: var(--sw-fg-0); }
+.ot__canvas-desc { margin: 4px 0 0; font-size: 12px; color: var(--sw-fg-2); line-height: 1.5; max-width: 760px; }
+.ot__drawer-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--sw-line); }
+.ot__drawer-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--sw-fg-2); font-weight: 600; }
+.ot__drawer-close { margin-left: auto; background: transparent; border: none; color: var(--sw-fg-3); font-size: 14px; cursor: pointer; width: 22px; height: 22px; border-radius: 4px; }
+.ot__drawer-close:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+/* Canvas cell: selectable wrapper. The inner preview is pointer-events:none
+   so clicking selects the widget (instead of following its link). */
+.ot__cw {
+  position: relative;
+  min-width: 0;
+  cursor: grab;
+  border-radius: 8px;
+  /* Dashed hint that the tile is selectable; firms up on hover, becomes a
+     solid accent outline when selected. */
+  outline: 1px dashed var(--sw-line-2);
+  outline-offset: 2px;
+}
+.ot__cw:hover { outline-color: var(--sw-fg-3); }
+.ot__cw:active { cursor: grabbing; }
+.ot__cw > * { pointer-events: none; }
+.ot__cw--sel { outline: 2px solid var(--sw-accent) !important; outline-offset: 1px; border-radius: 8px; }
+.ot__cw--drag { opacity: 0.45; }
+/* Corner resize handle — overrides the cell's pointer-events:none so it
+   can capture its own mousedown. */
+.ot__cw-resize {
+  pointer-events: auto;
+  position: absolute;
+  right: 2px;
+  bottom: 2px;
+  width: 14px;
+  height: 14px;
+  cursor: nwse-resize;
+  border-right: 2px solid var(--sw-fg-3);
+  border-bottom: 2px solid var(--sw-fg-3);
+  border-bottom-right-radius: 3px;
+  opacity: 0.5;
+}
+.ot__cw:hover .ot__cw-resize { opacity: 0.9; }
+/* Section-break rendered as a selectable header in the canvas. */
+.ot__pv-sb {
+  display: flex; align-items: center; gap: 8px; cursor: pointer;
+  border-radius: 5px; padding: 5px 8px; margin-bottom: 6px;
+  /* Same selectable hint as the canvas cells. */
+  outline: 1px dashed var(--sw-line-2); outline-offset: 2px;
+}
+.ot__pv-sb:hover { background: var(--sw-bg-2); outline-color: var(--sw-fg-3); }
+.ot__pv-sb-tag {
+  font-size: 8.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
+  color: var(--sw-accent); border: 1px solid var(--sw-accent-line, var(--sw-accent));
+  border-radius: 3px; padding: 0 4px;
+}
+.ot__pv-sb-title { flex: 1; font-weight: 600; color: var(--sw-fg-0); text-transform: none; letter-spacing: 0; }
+.ot__pv-sb-cols { font-size: 9.5px; color: var(--sw-fg-3); font-family: var(--sw-mono); }
+.ot__detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
+.ot__head-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
+.ot__detail-subhead { display: flex; align-items: center; justify-content: flex-end; gap: 10px; margin-bottom: 12px; }
+.ot__del-note { font-size: 10.5px; color: var(--sw-fg-3); font-style: italic; }
 .ot__detail-head h2 { margin: 0; font-size: 13px; font-weight: 600; }
 .ot__detail-head h2 code { font-family: var(--sw-mono); color: var(--sw-fg-0); }
-.ot__count { font-size: 11px; color: var(--sw-fg-3); margin-left: auto; }
+.ot__count { font-size: 11px; color: var(--sw-fg-3); }
 
 /* Per-widget card */
 .ot__widgets { display: flex; flex-direction: column; gap: 10px; }
@@ -1451,17 +2010,28 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
   padding: 2px 10px; border-radius: 4px; cursor: pointer;
 }
 .ot__add-btn:hover { color: var(--sw-fg-0); border-style: solid; }
-.ot__kpi-table { width: 100%; border-collapse: collapse; }
-.ot__kpi-table thead th {
-  text-align: left; font-size: 9px;
-  text-transform: uppercase; letter-spacing: 0.06em;
-  color: var(--sw-fg-3); font-weight: 600;
-  padding: 4px 6px;
-  border-bottom: 1px solid var(--sw-line);
+/* KPI rows as stacked cards (the drawer is sidebar-narrow — a wide table
+   row truncates every column). One bordered card per row; full-width
+   Label/Source/MQE, then a 2-col Unit/Aggr/Style/Max grid. */
+.ot__kpi-cards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
+.ot__kpi-card {
+  background: var(--sw-bg-2); border: 1px solid var(--sw-line);
+  border-radius: 5px; padding: 8px 10px;
+  display: flex; flex-direction: column; gap: 8px;
+}
+.ot__kpi-card-head { display: flex; align-items: center; gap: 6px; }
+.ot__kpi-card-idx {
+  font-size: 10px; font-weight: 700; font-family: var(--sw-mono);
+  color: var(--sw-fg-3);
 }
-.ot__kpi-table tbody td { padding: 4px; vertical-align: middle; }
-.ot__kpi-table input.ot__in,
-.ot__kpi-table select.ot__in { width: 100%; min-width: 0; }
+.ot__kpi-card-ord { display: inline-flex; gap: 2px; margin-left: auto; }
+.ot__kpi-card-grid {
+  display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
+}
+.ot__kpi-card .ot__field { min-width: 0; }
+.ot__kpi-card .ot__field--full { grid-column: 1 / -1; }
+.ot__kpi-card .ot__in { width: 100%; box-sizing: border-box; }
+.ot__kpi-card .ot__none { margin: 0; }
 .ot__arrow {
   background: transparent; border: 0;
   color: var(--sw-fg-2); font: inherit; font-size: 12px;
@@ -1508,10 +2078,19 @@ function widgetKindLabel(type: OverviewWidget['type']): string {
   font-style: italic;
 }
 
-.ot__actions { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
-.ot__flash { font-size: 11px; color: var(--sw-ok); margin-right: auto; }
-.ot__dirty { font-size: 11px; color: var(--sw-warn); margin-right: auto; }
-.ot__clean { font-size: 11px; color: var(--sw-fg-3); margin-right: auto; }
+.ot__flash { font-size: 11px; color: var(--sw-ok); }
+.ot__dirty { font-size: 11px; color: var(--sw-warn); }
+.ot__src {
+  font-size: 9.5px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  color: var(--sw-fg-3);
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line-2);
+  border-radius: 4px;
+  padding: 2px 7px;
+}
+.ot__clean { font-size: 11px; color: var(--sw-fg-3); }
 .ot__btn {
   background: var(--sw-bg-1); border: 1px solid var(--sw-line-2);
   color: var(--sw-fg-0); font: inherit; font-size: 12px;
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index c1adf7c..372a132 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -27,8 +27,11 @@
 -->
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
 import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
 import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import { bffClient } from '@/api/client';
+import { useAuthStore } from '@/state/auth';
 import Icon from '@/components/icons/Icon.vue';
 import Sparkline from '@/components/charts/Sparkline.vue';
 import LayerServiceSelector from './LayerServiceSelector.vue';
@@ -36,6 +39,7 @@ import { metricMeta } from '@/utils/metricCatalog';
 import { colorForMetric } from '@/utils/metricColor';
 import { useLayerLanding } from '@/layer/useLayerLanding';
 import { useLayers, firstLayerTab } from '@/shell/useLayers';
+import { layerContentToDef, type LayerTemplateContent } from '@/shell/layerFromTemplate';
 import { useSelectedService } from '@/layer/useSelectedService';
 import { useLayerSelectionStore } from '@/state/layerSelection';
 import { useSetupStore } from '@/state/setup';
@@ -78,21 +82,55 @@ watch(
   { immediate: true },
 );
 const { layers, isLoading: layersLoading } = useLayers();
-const layer = computed<LayerDef | null>(() => {
-  const found = layers.value.find((l) => l.key === layerKey.value);
-  return found ?? null;
+// Case-insensitive: URL layer keys are lowercase, registry keys UPPER_SNAKE.
+// In preview mode `useLayers` overlays/injects the previewed layer here, so
+// this already reflects the draft's components (tab visibility).
+const menuLayer = computed<LayerDef | null>(
+  () => layers.value.find((l) => l.key.toUpperCase() === layerKey.value.toUpperCase()) ?? null,
+);
+
+// Preview mode (`?mode=preview`) — render a configured-but-inactive
+// layer (OAP reports no services, so it's absent from the live menu)
+// instead of the "Layer not found" gate. Admin-only: the fallback is
+// synthesized from the layer template (admin data), so operators /
+// viewers still hit the normal gate. Empty metrics are expected — there
+// are no services to query.
+const auth = useAuthStore();
+const isAdmin = computed<boolean>(() => !!auth.user?.roles?.includes('admin'));
+// Single canonical form: `?mode=preview`. The admin "Open live page"
+// link generates exactly this.
+const previewAllowed = computed<boolean>(() => route.query.mode === 'preview' && isAdmin.value);
+const wantPreviewFallback = computed<boolean>(
+  () => previewAllowed.value && !menuLayer.value && !!layerKey.value,
+);
+const tplQuery = useQuery({
+  queryKey: ['layer-preview-templates'],
+  queryFn: () => bffClient.layerTemplates.list(),
+  enabled: wantPreviewFallback,
+  staleTime: 60_000,
 });
+const previewLoading = computed<boolean>(() => wantPreviewFallback.value && tplQuery.isLoading.value);
+const previewLayer = computed<LayerDef | null>(() => {
+  if (!wantPreviewFallback.value) return null;
+  const t = tplQuery.data.value?.templates.find((x) => x.key.toUpperCase() === layerKey.value.toUpperCase());
+  return t ? layerContentToDef(t as unknown as LayerTemplateContent) : null;
+});
+const layer = computed<LayerDef | null>(() => menuLayer.value ?? previewLayer.value);
+
 // Distinguishes "still loading" from "truly absent" so the "Layer
 // not found" card doesn't flash during a login → layer-URL redirect
 // (the menu fetch is in-flight and `layers.value` is briefly []).
 // We treat the layer as missing only after the menu request has
-// settled AND the layer key still isn't in the result.
-const layerMissing = computed<boolean>(
-  () => !layersLoading.value && layers.value.length > 0 && layer.value === null,
-);
+// settled AND — in preview mode — the template fetch has settled too.
 const menuStillLoading = computed<boolean>(
-  () => !layer.value && (layersLoading.value || layers.value.length === 0),
+  () => !menuLayer.value && (layersLoading.value || layers.value.length === 0),
 );
+const layerMissing = computed<boolean>(() => {
+  if (layer.value) return false;
+  if (menuStillLoading.value) return false;
+  if (previewLoading.value) return false;
+  return true;
+});
 
 // Auto-redirect when the URL targets a sub-route the layer doesn't
 // support — e.g. `/layer/mesh_dp/service` on a layer with
@@ -211,6 +249,17 @@ const selectedName = computed(() => {
 // say) can flip the same meta flag without touching this file.
 const viewOwnsServiceSelector = computed(() => Boolean(route.meta?.ownsServiceSelector));
 
+// Zipkin trace mode is a self-contained, cross-service explorer (its
+// own service/duration/annotation filters), so the layer identity +
+// service KPI header adds nothing and crowds it out — hide the shell
+// header there. Mirrors LayerTracesEntry's zipkin decision: the
+// `/zipkin-trace` route always, plus the `/trace` route on a pure-
+// zipkin-source layer (mesh / k8s).
+const isZipkinTrace = computed<boolean>(() => {
+  if (/\/zipkin-trace(\/|$|\?)/.test(route.path)) return true;
+  return scopeSegment.value === 'trace' && layer.value?.traces?.source === 'zipkin';
+});
+
 // Keep the URL-backed service selection honest for every page that
 // uses the shell picker. A stale `?service=` can survive navigation or
 // manual URL entry; the switch label used to fall back visually to the
@@ -373,7 +422,7 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
 
 <template>
   <div class="layer-shell">
-    <header v-if="layer" class="sw-card layer-head">
+    <header v-if="layer && !isZipkinTrace" class="sw-card layer-head">
       <!-- Row 1: layer identity (left) + LAYER aggregate KPI strip
            (right). The aggregates use sum/avg per the JSON columns'
            aggregation field — sum cpm across services, avg p99 etc. -->
@@ -491,7 +540,7 @@ const serviceKpis = computed<HeaderKpi[]>(() => {
     <!-- Loading state for the menu fetch — appears in the login →
          layer-URL redirect window where the menu is still in flight
          and we can't yet tell whether the layer exists. -->
-    <div v-if="menuStillLoading" class="missing">
+    <div v-if="menuStillLoading || previewLoading" class="missing">
       <div class="sw-card missing-card">
         <Icon name="event" :size="18" />
         <div>
diff --git a/apps/ui/src/shell/AppShell.vue b/apps/ui/src/shell/AppShell.vue
index 6a9bf18..b5f941a 100644
--- a/apps/ui/src/shell/AppShell.vue
+++ b/apps/ui/src/shell/AppShell.vue
@@ -21,6 +21,7 @@ import AppSidebar from './AppSidebar.vue';
 import AppTopbar from './AppTopbar.vue';
 import DebugEventPanel from './DebugEventPanel.vue';
 import GlobalConnectivityBanner from './GlobalConnectivityBanner.vue';
+import PreviewModeBanner from './PreviewModeBanner.vue';
 import TracePopout from '@/layer/traces/TracePopout.vue';
 import ZipkinTracePopout from '@/layer/traces/ZipkinTracePopout.vue';
 import TemplateConflictPrompt from './TemplateConflictPrompt.vue';
@@ -28,6 +29,7 @@ import { ensureConfigBundle, useConfigBundle } from '@/controls/configBundle';
 import { useClickTracking } from '@/controls/useClickTracking';
 import { useLayers } from '@/shell/useLayers';
 import { useDebugPanel } from '@/controls/debugPanel';
+import { useSidebar } from '@/controls/sidebar';
 import { useThemeStore } from '@/state/theme';
 import { useTimeDefaultsStore } from '@/state/timeDefaults';
 import { useTimeRangeStore } from '@/controls/timeRange';
@@ -100,10 +102,12 @@ const initReady = computed<boolean>(
 // transient overlay (operator dismisses it explicitly) — reserving
 // 260px permanently would waste real estate.
 const { enabled: debugPanelEnabled } = useDebugPanel();
+// Drives the `.sw` grid column width — see `.sw.side-collapsed` below.
+const { collapsed: sidebarCollapsed } = useSidebar();
 </script>
 
 <template>
-  <div class="sw">
+  <div class="sw" :class="{ 'side-collapsed': sidebarCollapsed }">
     <AppSidebar />
     <AppTopbar />
     <main class="sw-main" :class="{ 'has-debug-panel': debugPanelEnabled }">
@@ -111,6 +115,8 @@ const { enabled: debugPanelEnabled } = useDebugPanel();
            (`:12800`) poll reports unreachable. Admin-port (`:17128`)
            failures render per-page via AdminFeatureWarning, not here. -->
       <GlobalConnectivityBanner />
+      <!-- Notice shown only while a page is in ?mode=preview. -->
+      <PreviewModeBanner />
       <!-- Shell-level init placeholder. Visible until the layer
            registry + config bundle have both loaded. Per-page code
            runs against fully-populated state from the first paint. -->
@@ -149,6 +155,13 @@ const { enabled: debugPanelEnabled } = useDebugPanel();
  * vertical real estate. */
 .sw-main.has-debug-panel { padding-bottom: 26px; }
 
+/* Folded sidebar — collapse the grid's side column to a thin rail that
+ * holds just the expand affordance. The sidebar's own scoped styles
+ * hide the nav/labels at this width (see AppSidebar `.sw-side.collapsed`).
+ * Animates in step with the sidebar's own collapse transition. */
+.sw.side-collapsed { grid-template-columns: 48px 1fr; }
+.sw { transition: grid-template-columns 160ms ease; }
+
 .sw-init {
   padding: 48px 20px;
   display: flex;
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 5f862fe..8a97d59 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -33,10 +33,13 @@ import { useLayers, firstLayerTab } from '@/shell/useLayers';
 import { useLandingOrder } from '@/shell/useLandingOrder';
 import { useOverviewDashboards } from '@/render/overview/useOverviewDashboards';
 import { useDebugPanel } from '@/controls/debugPanel';
+import { useSidebar } from '@/controls/sidebar';
 import { useAlarmCount } from '@/shell/useAlarmCount';
 import { useConfigBundle } from '@/controls/configBundle';
 
 const { enabled: debugPanelEnabled, toggle: toggleDebugPanel } = useDebugPanel();
+// Fold the whole menu to a thin rail; the shell grid follows suit.
+const { collapsed: sidebarCollapsed, toggle: toggleSidebar } = useSidebar();
 /* Shares the same composable as the topbar badge — one query feeds
  * both, so the two surfaces never disagree and the cache is warm
  * regardless of which renders first. */
@@ -132,14 +135,20 @@ const operateLayers = computed(() =>
 const sidebarEntries = computed<SidebarEntry[]>(() => bucket(publicLayers.value));
 
 const route = useRoute();
+// Case-insensitive: layer keys are UPPER_SNAKE in the menu/links but a
+// URL may arrive lowercased (overview KPI tiles link lowercased, hand-typed
+// URLs, etc.). Without normalising, the active row wouldn't get the accent
+// colour and the auto-scroll couldn't find it.
+const routePathLc = computed(() => route.path.toLowerCase());
 function isActive(path: string): boolean {
-  return route.path === path || route.path.startsWith(path + '/');
+  const p = path.toLowerCase();
+  return routePathLc.value === p || routePathLc.value.startsWith(p + '/');
 }
 /** Use this — not {@link isActive} — for sibling routes where one is
  *  a prefix of another (e.g. `/operate/live-debug` vs
  *  `/operate/live-debug/history`); prefix-match would light both up. */
 function isActiveExact(path: string): boolean {
-  return route.path === path;
+  return routePathLc.value === path.toLowerCase();
 }
 
 // Only the layer the route currently points at is auto-expanded. We do
@@ -147,29 +156,39 @@ function isActiveExact(path: string): boolean {
 // expanding a section is an explicit user action (a click), and a
 // default-open accordion misleads operators into thinking that layer is
 // "selected" when they've navigated nowhere.
+const navRef = ref<HTMLElement | null>(null);
+/** Bring the route's active nav row into view (it may sit below the fold
+ *  on a long sidebar). Waits a tick so the route-driven expand has
+ *  rendered the L2 children that contain the active row. */
+async function scrollActiveIntoView(): Promise<void> {
+  await nextTick();
+  // Both the layer row and its active child carry `.is-active`; scroll to
+  // the LAST (deepest) one — the active tab — so it lands in view.
+  // Exclude `.sw-nav-toggle` (the bottom "Debug events" button is
+  // `.is-active` whenever the panel is on — default on localhost — and
+  // being last in the DOM it would otherwise scroll the sidebar to the
+  // very end on every navigation regardless of the clicked item).
+  const actives = navRef.value?.querySelectorAll('.is-active:not(.sw-nav-toggle)');
+  actives?.[actives.length - 1]?.scrollIntoView({ block: 'nearest' });
+}
+
+// Expand the layer the route points at AND scroll its active item into
+// view — on entering a layer page (deep-link, reload, or in-app nav),
+// not just the first paint. URL keys are lowercase; layer keys are
+// UPPER_SNAKE — match case-insensitively.
 watch(
   [() => route.path, orderedLayers],
   ([path, rows]) => {
     const m = path.match(/^\/layer\/([^/]+)/);
     if (m) {
-      const key = m[1];
-      const L = rows.find((l) => l.key === key);
-      if (L) expandedLayer.value = key;
+      const L = rows.find((l) => l.key.toUpperCase() === m[1]!.toUpperCase());
+      if (L) expandedLayer.value = L.key;
     }
+    void scrollActiveIntoView();
   },
   { immediate: true },
 );
-
-// On first paint, bring the route's selected nav item into view — on a
-// long sidebar the active layer/tab can land below the fold, so landing
-// deep-linked (or after a reload) would otherwise show no visible
-// selection. Wait a tick so the route-driven expand above has rendered
-// the L2 children that may contain the active row.
-const navRef = ref<HTMLElement | null>(null);
-onMounted(async () => {
-  await nextTick();
-  navRef.value?.querySelector('.is-active')?.scrollIntoView({ block: 'nearest' });
-});
+onMounted(scrollActiveIntoView);
 
 interface NavRow {
   icon: IconName;
@@ -347,13 +366,37 @@ watch(
 </script>
 
 <template>
-  <aside class="sw-side">
-    <RouterLink to="/" class="sw-brand" aria-label="SkyWalking Horizon">
-      <span class="brand-logo" v-html="isLightAppearance ? logoSwBlue : logoSw" />
-      <small>Horizon</small>
-    </RouterLink>
+  <aside class="sw-side" :class="{ collapsed: sidebarCollapsed }">
+    <!-- Expanded: original wordmark header (unchanged) + collapse toggle.
+         Folded: the wordmark moves to the topbar (see AppTopbar); the rail
+         keeps just the expand chevron in the same 44px header row. -->
+    <div v-if="!sidebarCollapsed" class="sw-brand-row">
+      <RouterLink to="/" class="sw-brand" aria-label="SkyWalking Horizon">
+        <span class="brand-logo" v-html="isLightAppearance ? logoSwBlue : logoSw" />
+        <small>Horizon</small>
+      </RouterLink>
+      <button
+        type="button"
+        class="side-toggle"
+        title="Collapse menu"
+        aria-label="Collapse menu"
+        @click="toggleSidebar"
+      >
+        <Icon name="caret" :size="12" />
+      </button>
+    </div>
+    <button
+      v-else
+      type="button"
+      class="side-expand"
+      title="Show menu"
+      aria-label="Show menu"
+      @click="toggleSidebar"
+    >
+      <Icon name="caret" :size="12" />
+    </button>
 
-    <nav ref="navRef" class="sw-nav">
+    <nav v-show="!sidebarCollapsed" ref="navRef" class="sw-nav">
       <!-- Overviews are gated by `overview:read`. -->
       <template v-if="auth.hasVerb('overview:read')">
         <div class="sw-nav-section sw-nav-section--icon">
@@ -842,7 +885,7 @@ watch(
       </template>
     </nav>
 
-    <div class="sw-side-foot">
+    <div v-show="!sidebarCollapsed" class="sw-side-foot">
       <div class="sw-avatar">
         {{ auth.user?.username ? auth.user.username.slice(0, 2).toUpperCase() : '?' }}
       </div>
@@ -868,11 +911,58 @@ watch(
 </template>
 
 <style scoped>
+/* Brand + collapse toggle share the 44px top row (aligns with the
+ * topbar). Shown only while expanded; when folded the row is replaced by
+ * the expand chevron and the wordmark moves to the topbar. */
+.sw-brand-row {
+  display: flex;
+  align-items: center;
+  height: 44px;
+  border-bottom: 1px solid var(--sw-line);
+}
 .sw-brand,
 .sw-brand:hover {
   text-decoration: none;
   color: inherit;
+  /* Border + height now live on .sw-brand-row; override the global
+   * .sw-brand so we don't double the border or the height. */
+  flex: 1;
+  min-width: 0;
+  height: auto;
+  border-bottom: none;
+  overflow: hidden;
+}
+.side-toggle {
+  flex: 0 0 auto;
+  width: 26px;
+  height: 26px;
+  margin: 0 9px;
+  display: inline-grid;
+  place-items: center;
+  background: transparent;
+  border: none;
+  border-radius: 5px;
+  color: var(--sw-fg-3);
+  cursor: pointer;
+}
+.side-toggle:hover { background: var(--sw-bg-2); color: var(--sw-fg-1); }
+/* caret base glyph points down — rotate to a left chevron (collapse). */
+.side-toggle :deep(svg) { transition: transform 0.15s; transform: rotate(90deg); }
+/* Folded rail: the expand chevron occupies the 44px header row (aligns
+ * with the topbar, where the wordmark now lives). */
+.side-expand {
+  width: 100%;
+  height: 44px;
+  display: grid;
+  place-items: center;
+  background: transparent;
+  border: none;
+  border-bottom: 1px solid var(--sw-line);
+  color: var(--sw-fg-3);
+  cursor: pointer;
 }
+.side-expand:hover { background: var(--sw-bg-2); color: var(--sw-fg-1); }
+.side-expand :deep(svg) { transform: rotate(-90deg); }
 .brand-logo {
   display: inline-flex;
   align-items: center;
diff --git a/apps/ui/src/shell/AppTopbar.vue b/apps/ui/src/shell/AppTopbar.vue
index 5b02983..02f0396 100644
--- a/apps/ui/src/shell/AppTopbar.vue
+++ b/apps/ui/src/shell/AppTopbar.vue
@@ -25,6 +25,13 @@ import { useTimeRangeStore, TIME_PRESETS, STEP_LIMITS, isValidRange, type TimeSt
 import { useThemeStore, AVAILABLE_THEMES, type ThemeId } from '@/state/theme';
 import { useAuthStore } from '@/state/auth';
 import { useTimeDefaultsStore } from '@/state/timeDefaults';
+import { useSidebar } from '@/controls/sidebar';
+import logoSw from '@/assets/icons/logo-sw.svg?raw';
+
+// When the sidebar is folded its wordmark is hidden; surface the brand
+// (logo + name) here in the topbar's left zone instead.
+const { collapsed: sidebarCollapsed } = useSidebar();
+const logoSwBlue = logoSw.replace(/fill="#fff"/g, 'fill="#1368B3"');
 
 // Per-user "Save as my default" / "Reset to org default" on the time
 // picker. The org default is what the admin set on
@@ -49,6 +56,9 @@ function resetTimeDefaultToOrg(): void {
 // Per CLAUDE.md the theme is a runtime concern, not a feature flag;
 // this is the only surface where end users touch it.
 const themeStore = useThemeStore();
+const isLightAppearance = computed<boolean>(
+  () => AVAILABLE_THEMES.find((t) => t.id === themeStore.active)?.appearance === 'light',
+);
 const themeMenuOpen = ref(false);
 const themeChipEl = ref<HTMLElement | null>(null);
 function toggleThemeMenu(): void { themeMenuOpen.value = !themeMenuOpen.value; }
@@ -391,10 +401,14 @@ function formatRangeStamp(ms: number, step: TimeStep): string {
 
 <template>
   <header class="sw-top">
-    <!-- Left zone intentionally empty for now. The framework-event
-         feed moved to a dedicated bottom-fixed panel (gated by the
-         Admin → "Debug events" sidebar toggle) — see
-         `controls/debugPanel.ts` + `shell/DebugEventPanel.vue`. -->
+    <!-- When the sidebar is folded, its wordmark is hidden — show the
+         brand (logo + name) here so the product identity stays visible.
+         Otherwise the left zone stays empty (the framework-event feed
+         moved to the bottom-fixed DebugEventPanel). -->
+    <RouterLink v-if="sidebarCollapsed" to="/" class="top-brand" aria-label="SkyWalking Horizon">
+      <span class="top-brand-logo" v-html="isLightAppearance ? logoSwBlue : logoSw" />
+      <small>Horizon</small>
+    </RouterLink>
     <div class="sw-top-spacer" />
     <div class="sw-top-actions">
       <component
@@ -625,6 +639,23 @@ function formatRangeStamp(ms: number, step: TimeStep): string {
 </template>
 
 <style scoped>
+/* Brand shown in the topbar's left zone only while the sidebar is
+   folded — same wordmark + "Horizon" label as the sidebar header. */
+.top-brand {
+  display: inline-flex;
+  align-items: center;
+  text-decoration: none;
+  color: inherit;
+}
+.top-brand-logo { display: inline-flex; align-items: center; }
+.top-brand-logo :deep(svg) { height: 16px; width: auto; display: block; }
+.top-brand small {
+  font-weight: 500;
+  color: var(--sw-fg-2);
+  margin-left: 4px;
+  letter-spacing: 0.02em;
+}
+
 /* Disabled state for global time-range / refresh chips when the
    current page owns its own time range. Greys out without removing
    the chip so the operator still sees the affordance + tooltip. */
diff --git a/apps/ui/src/shell/PreviewModeBanner.vue b/apps/ui/src/shell/PreviewModeBanner.vue
new file mode 100644
index 0000000..4a65c1c
--- /dev/null
+++ b/apps/ui/src/shell/PreviewModeBanner.vue
@@ -0,0 +1,110 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<!--
+  Shown only while a page is in `?mode=preview` — tells the operator that
+  the dashboard they're looking at is rendered from an UNPUBLISHED source
+  (their local draft / bundled default / remote), not the normal live view.
+  Dismissible (× button or Esc); re-appears when the previewed target
+  changes so each dashboard surfaces the notice once.
+-->
+<script setup lang="ts">
+import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
+import { useRoute } from 'vue-router';
+import Icon from '@/components/icons/Icon.vue';
+import { usePreviewMode, getPreviewSource } from '@/controls/previewMode';
+import { useLayers } from '@/shell/useLayers';
+import { useConfigBundle } from '@/controls/configBundle';
+
+const route = useRoute();
+const previewMode = usePreviewMode();
+const { layers } = useLayers();
+const { bundle } = useConfigBundle();
+
+/** Friendly name of the dashboard being previewed (layer name / overview
+ *  title), from the current route. */
+const target = computed<string | null>(() => {
+  const lm = route.path.match(/^\/layer\/([^/]+)/);
+  if (lm) {
+    const key = lm[1]!;
+    return layers.value.find((l) => l.key.toLowerCase() === key.toLowerCase())?.name || key.toUpperCase();
+  }
+  const om = route.path.match(/^\/overview\/([^/]+)/);
+  if (om) {
+    const id = om[1]!;
+    return (bundle.value?.overviews ?? []).find((o) => o.id === id)?.title || id;
+  }
+  return null;
+});
+const source = computed<string>(() => getPreviewSource() ?? 'local');
+
+const dismissed = ref(false);
+const show = computed(() => previewMode.value && !dismissed.value && !!target.value);
+// Surface the notice once per previewed target — re-show when it changes.
+watch([target, source], () => (dismissed.value = false));
+
+function close(): void {
+  dismissed.value = true;
+}
+function onKey(e: KeyboardEvent): void {
+  if (e.key === 'Escape' && show.value) close();
+}
+onMounted(() => window.addEventListener('keydown', onKey));
+onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
+</script>
+
+<template>
+  <div v-if="show" class="preview-banner" role="status">
+    <Icon name="event" :size="13" />
+    <span class="preview-banner-text">
+      <b>Preview mode</b> — <b>{{ target }}</b> is loaded from your <b>{{ source }}</b> version, not the
+      published one. Only you see this; everyone else gets the live (remote) dashboard.
+    </span>
+    <button class="preview-banner-close" type="button" title="Close (Esc)" aria-label="Close" @click="close">✕</button>
+  </div>
+</template>
+
+<style scoped>
+.preview-banner {
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  margin: 10px 14px 0;
+  padding: 7px 12px;
+  border: 1px solid var(--sw-accent-line, var(--sw-accent));
+  background: var(--sw-accent-soft);
+  border-radius: 7px;
+  font-size: 12px;
+  color: var(--sw-fg-1);
+}
+.preview-banner :deep(svg) { color: var(--sw-accent); flex: 0 0 auto; }
+.preview-banner-text { flex: 1; line-height: 1.45; }
+.preview-banner-text b { color: var(--sw-fg-0); }
+.preview-banner-close {
+  flex: 0 0 auto;
+  width: 22px;
+  height: 22px;
+  display: grid;
+  place-items: center;
+  background: transparent;
+  border: none;
+  border-radius: 5px;
+  color: var(--sw-fg-3);
+  font-size: 12px;
+  cursor: pointer;
+}
+.preview-banner-close:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
+</style>
diff --git a/apps/ui/src/shell/TemplateConflictPrompt.vue b/apps/ui/src/shell/TemplateConflictPrompt.vue
index b766498..24af840 100644
--- a/apps/ui/src/shell/TemplateConflictPrompt.vue
+++ b/apps/ui/src/shell/TemplateConflictPrompt.vue
@@ -15,109 +15,112 @@
   limitations under the License.
 -->
 <!--
-  Per-session conflict prompt. When the operator lands with templates
-  that diverge between the local bundled copy and the OAP-stored remote
-  copy, ask once which version to render: their LOCAL (unpublished) edits
-  or the REMOTE (live) version. The choice is global and per login
-  session (reset on login). Until chosen, the runtime defaults to remote.
+  Per-session reminder for EDITORS who have unpublished local template
+  drafts in THIS browser. Live pages always render remote; a draft is only
+  previewed via the editor's "Preview live" entrance (?mode=preview). This
+  prompt just nudges the operator to the relevant edit page so they can
+  publish or discard. No "use local vs remote" choice — remote is always
+  the live source. Shown once per session (dismissible), only to users who
+  can edit the kind of draft they hold.
 -->
 <script setup lang="ts">
 import { computed, ref } from 'vue';
+import { useRouter } from 'vue-router';
 import Modal from '@/features/operate/_shared/Modal.vue';
-import { useConfigBundle, setTemplateRenderMode, refreshConfigBundle } from '@/controls/configBundle';
-import { useTemplatePreference } from '@/controls/templatePreference';
 import { useLayers } from '@/shell/useLayers';
-import { bffClient } from '@/api/client';
+import { useConfigBundle } from '@/controls/configBundle';
+import { useLocalTemplateEdits } from '@/controls/localTemplateEdits';
+import { usePreviewMode } from '@/controls/previewMode';
+import { useAuthStore } from '@/state/auth';
 
-const { bundle } = useConfigBundle();
-const pref = useTemplatePreference();
+const router = useRouter();
+const previewMode = usePreviewMode();
 const { layers } = useLayers();
+const { bundle } = useConfigBundle();
+const auth = useAuthStore();
+const { edits } = useLocalTemplateEdits();
 
-/** Diverged templates named from the operator's MENU perspective —
- *  the layer's sidebar label / overview title, not the template file. */
-const divergedItems = computed<string[]>(() => {
-  const badges = (bundle.value?.syncStatus?.badges ?? []).filter((b) => b.status === 'diverged');
+const canEditLayers = computed<boolean>(() => auth.hasVerb('dashboard:read'));
+const canEditOverviews = computed<boolean>(() => auth.hasVerb('overview:write'));
+
+interface DraftItem {
+  kind: 'layer' | 'overview';
+  key: string;
+  label: string;
+}
+/** Unpublished local drafts in this browser, restricted to kinds the
+ *  user can edit, named from the menu perspective. */
+const draftItems = computed<DraftItem[]>(() => {
   const overviews = bundle.value?.overviews ?? [];
-  return badges.map((b) => {
-    if (b.kind === 'layer') {
-      const L = layers.value.find((l) => l.key.toUpperCase() === b.key.toUpperCase());
-      return L?.name ? `Layer · ${L.name}` : `Layer · ${b.key}`;
+  const out: DraftItem[] = [];
+  for (const name of Object.keys(edits.value)) {
+    const [, kind, ...rest] = name.split('.');
+    const key = rest.join('.');
+    if (kind === 'layer') {
+      if (!canEditLayers.value) continue;
+      const L = layers.value.find((l) => l.key.toUpperCase() === key.toUpperCase());
+      out.push({ kind, key, label: L?.name ? `Layer · ${L.name}` : `Layer · ${key}` });
+    } else if (kind === 'overview') {
+      if (!canEditOverviews.value) continue;
+      const ov = overviews.find((o) => o.id === key);
+      out.push({ kind, key, label: ov?.title ? `Overview · ${ov.title}` : `Overview · ${key}` });
     }
-    if (b.kind === 'overview') {
-      const ov = overviews.find((o) => o.id === b.key);
-      return ov?.title ? `Overview · ${ov.title}` : `Overview · ${b.key}`;
-    }
-    return b.key;
-  });
+  }
+  return out;
 });
-const divergedCount = computed(() => divergedItems.value.length);
-const open = computed(() => pref.mode === null && divergedCount.value > 0);
 
-// Second step shown when the operator picks "use live" — overriding the
-// local edits with the remote version is destructive, so it's confirmed.
-const confirmingRemote = ref(false);
-const busy = ref(false);
+const hasLayerDrafts = computed(() => draftItems.value.some((d) => d.kind === 'layer'));
+const hasOverviewDrafts = computed(() => draftItems.value.some((d) => d.kind === 'overview'));
 
-async function useLocal(): Promise<void> {
-  await setTemplateRenderMode('local');
-}
+// Dismissed once per browser session so it doesn't nag on every route.
+const SESSION_KEY = 'horizon:localDraftPrompt:dismissed';
+const dismissed = ref<boolean>(
+  typeof sessionStorage !== 'undefined' && sessionStorage.getItem(SESSION_KEY) === '1',
+);
+// Not while previewing — a preview tab shows the dedicated preview banner
+// instead of the editor's unpublished-edits reminder.
+const open = computed(() => !previewMode.value && !dismissed.value && draftItems.value.length > 0);
 
-// Override local with remote: discard the local edits (revert bundled to
-// the live version), then render remote.
-async function useLive(): Promise<void> {
-  if (busy.value) return;
-  busy.value = true;
+function dismiss(): void {
+  dismissed.value = true;
   try {
-    await bffClient.templateSync.revertLocal();
-    await refreshConfigBundle();
-    pref.set('remote');
-  } finally {
-    busy.value = false;
-    confirmingRemote.value = false;
+    sessionStorage.setItem(SESSION_KEY, '1');
+  } catch {
+    /* private mode — in-memory dismissal still holds for this session */
   }
 }
+function goLayers(): void {
+  const first = draftItems.value.find((d) => d.kind === 'layer');
+  dismiss();
+  void router.push({ path: '/admin/layer-dashboards', query: first ? { layer: first.key } : {} });
+}
+function goOverviews(): void {
+  dismiss();
+  void router.push({ path: '/admin/overview-templates' });
+}
 </script>
 
 <template>
-  <Modal
-    :open="open"
-    :dismissable="false"
-    :title="confirmingRemote ? 'Use the live version?' : 'Template versions differ from OAP'"
-  >
-    <div v-if="!confirmingRemote" class="tcp">
+  <Modal :open="open" :dismissable="false" title="You have unpublished local edits">
+    <div class="tcp">
       <p class="tcp__lede">
-        <b>{{ divergedCount }}</b> dashboard{{ divergedCount === 1 ? '' : 's' }} differ between the
-        version OAP currently serves (<b>remote / live</b>) and your <b>local</b> copy — OAP may have
-        newer changes, or you have unpublished local edits. Which should this session render?
+        <b>{{ draftItems.length }}</b> dashboard{{ draftItems.length === 1 ? '' : 's' }} ha{{ draftItems.length === 1 ? 's' : 've' }}
+        edits saved only in <b>this browser</b> — they are not live for anyone. Live pages render the
+        remote (OAP) version; preview your drafts from the editor’s <b>Preview live</b> button, then
+        <b>Push local → OAP</b> to publish.
       </p>
       <ul class="tcp__list">
-        <li v-for="(name, i) in divergedItems" :key="i">{{ name }}</li>
-      </ul>
-      <ul class="tcp__opts">
-        <li><b>Local</b> — preview your unpublished edits. Nothing is sent to OAP; publish later with “Sync all to OAP”.</li>
-        <li><b>Remote</b> — show the live version everyone else sees. Your local edits stay on disk, unpublished.</li>
+        <li v-for="(d, i) in draftItems" :key="i">{{ d.label }}</li>
       </ul>
     </div>
-    <div v-else class="tcp">
-      <p class="tcp__lede tcp__warn">
-        OAP holds a different version. Using live will <b>overwrite your
-        {{ divergedCount }} local change{{ divergedCount === 1 ? '' : 's' }}</b> with the remote
-        version — your local edits are <b>discarded and cannot be recovered</b>. Publish them with
-        “Sync all to OAP” instead if you want to keep them. Override local with live?
-      </p>
-    </div>
-
     <template #footer>
-      <template v-if="!confirmingRemote">
-        <button class="sw-btn" type="button" @click="confirmingRemote = true">Use live (remote)</button>
-        <button class="sw-btn primary" type="button" @click="useLocal">Keep my local edits</button>
-      </template>
-      <template v-else>
-        <button class="sw-btn" type="button" :disabled="busy" @click="confirmingRemote = false">Back</button>
-        <button class="sw-btn danger" type="button" :disabled="busy" @click="useLive">
-          {{ busy ? 'Overriding…' : 'Overwrite local with live' }}
-        </button>
-      </template>
+      <button class="sw-btn" type="button" @click="dismiss">Dismiss</button>
+      <button v-if="hasOverviewDrafts" class="sw-btn" type="button" @click="goOverviews">
+        Overview templates →
+      </button>
+      <button v-if="hasLayerDrafts" class="sw-btn is-primary" type="button" @click="goLayers">
+        Layer dashboards →
+      </button>
     </template>
   </Modal>
 </template>
@@ -125,10 +128,8 @@ async function useLive(): Promise<void> {
 <style scoped>
 .tcp { padding: 4px 2px; }
 .tcp__lede { margin: 0 0 10px; font-size: 12.5px; color: var(--sw-fg-1); line-height: 1.55; }
-.tcp__warn { color: var(--sw-fg-1); }
-.tcp__warn b { color: var(--sw-err); }
 .tcp__list {
-  margin: 0 0 12px;
+  margin: 0;
   padding: 8px 10px 8px 24px;
   max-height: 30vh;
   overflow: auto;
@@ -139,6 +140,4 @@ async function useLive(): Promise<void> {
   color: var(--sw-fg-1);
   line-height: 1.6;
 }
-.tcp__opts { margin: 0; padding-left: 18px; font-size: 11.5px; color: var(--sw-fg-2); line-height: 1.6; }
-.tcp__opts b { color: var(--sw-fg-0); }
 </style>
diff --git a/apps/ui/src/shell/layerFromTemplate.ts b/apps/ui/src/shell/layerFromTemplate.ts
new file mode 100644
index 0000000..53f0d6a
--- /dev/null
+++ b/apps/ui/src/shell/layerFromTemplate.ts
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Map a layer TEMPLATE (the admin/editor JSON shape — `components`,
+ * `slots`, etc.) to a menu `LayerDef`. Used to render preview pages and to
+ * overlay the sidebar menu from a browser-local draft so the per-layer tab
+ * set reflects the draft's enabled components — without pushing anything to
+ * OAP. The `components` flags drive the cap-based tab visibility.
+ */
+
+import type { LayerCaps, LayerDef } from '@skywalking-horizon-ui/api-client';
+
+/** Loose view of a layer template's menu-relevant fields. */
+export interface LayerTemplateContent {
+  key: string;
+  alias?: string;
+  color?: string;
+  group?: string;
+  visibility?: 'public' | 'operate';
+  documentLink?: string;
+  components?: Record<string, boolean | undefined>;
+  slots?: LayerDef['slots'];
+  metrics?: LayerDef['metrics'];
+  overview?: LayerDef['overview'];
+  naming?: LayerDef['naming'];
+  traces?: LayerDef['traces'];
+}
+
+/** `components.*` → `caps.*` (the tab-visibility flags the sidebar reads). */
+export function componentsToCaps(components: Record<string, boolean | undefined> | undefined): LayerCaps {
+  const c = components ?? {};
+  return {
+    dashboards: !!c.service,
+    instances: !!c.instances,
+    endpoints: !!c.endpoints,
+    serviceMap: !!c.topology,
+    endpointDependency: !!c.endpointDependency,
+    traces: !!c.traces,
+    logs: !!c.logs,
+    traceProfiling: !!c.traceProfiling,
+    ebpfProfiling: !!c.ebpfProfiling,
+    asyncProfiling: !!c.asyncProfiling,
+  };
+}
+
+/** Build a full LayerDef from template content — for previewing a layer
+ *  OAP doesn't currently list (no live data). */
+export function layerContentToDef(t: LayerTemplateContent): LayerDef {
+  return {
+    key: t.key,
+    name: t.alias || t.key,
+    color: t.color || 'var(--sw-fg-3)',
+    serviceCount: 0,
+    active: false,
+    level: null,
+    group: t.group,
+    visibility: t.visibility,
+    normal: null,
+    documentLink: t.documentLink,
+    slots: t.slots ?? {},
+    caps: componentsToCaps(t.components),
+    header: t.metrics,
+    metrics: t.metrics,
+    overview: t.overview,
+    naming: t.naming,
+    traces: t.traces,
+  };
+}
+
+/** Overlay an existing menu LayerDef with a draft's menu-relevant fields
+ *  (caps + slots) so the sidebar reflects the draft in preview mode. Keeps
+ *  the live `serviceCount` / `color` / `name` from the menu entry. */
+export function overlayLayerDef(base: LayerDef, t: LayerTemplateContent): LayerDef {
+  return {
+    ...base,
+    slots: { ...base.slots, ...(t.slots ?? {}) },
+    caps: componentsToCaps(t.components),
+  };
+}
diff --git a/apps/ui/src/shell/router/index.ts b/apps/ui/src/shell/router/index.ts
index edfab5f..a873cc9 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -17,6 +17,7 @@
 import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
 import { useAuthStore } from '@/state/auth';
 import { pushEvent } from '@/controls/eventLog';
+import { setPreviewMode, usePreviewMode, getPreviewSource } from '@/controls/previewMode';
 
 const placeholder = () => import('@/shell/PlaceholderView.vue');
 
@@ -315,7 +316,27 @@ router.beforeEach(async (to) => {
 // buffer is intentionally NOT cleared on navigation — operators want
 // to see the cross-page history (last 200 events) so they can trace
 // "I clicked here, then services loaded, then I clicked there".
+// Preview mode (`?mode=preview&source=…`) is sticky within layer/overview
+// pages: the sidebar tab links don't carry query params, so without this
+// the overlay would drop the moment you navigate via the menu. We keep the
+// flag on and re-apply the query to the URL so it both propagates AND stays
+// visible. Leaving the layer/overview area turns preview off.
+const previewMode = usePreviewMode();
+const PREVIEWABLE = /^\/(layer|overview)\//;
 router.afterEach((to, from) => {
+  if (to.query.mode === 'preview') {
+    setPreviewMode(true, typeof to.query.source === 'string' ? to.query.source : undefined);
+  } else if (previewMode.value && PREVIEWABLE.test(to.path)) {
+    // Menu navigation dropped the params — re-add them (no history entry).
+    const src = getPreviewSource();
+    void router.replace({
+      path: to.path,
+      query: { ...to.query, mode: 'preview', ...(src ? { source: src } : {}) },
+    });
+    return;
+  } else {
+    setPreviewMode(false);
+  }
   if (to.path === from.path) return;
   pushEvent('route', 'info', `Navigated to ${to.path}`);
 });
diff --git a/apps/ui/src/shell/useLayers.ts b/apps/ui/src/shell/useLayers.ts
index 0887d1a..e1d3efe 100644
--- a/apps/ui/src/shell/useLayers.ts
+++ b/apps/ui/src/shell/useLayers.ts
@@ -19,6 +19,10 @@ import { computed } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import type { LayerDef } from '@skywalking-horizon-ui/api-client';
 import { bffClient } from '@/api/client';
+import { usePreviewMode, getPreviewSource } from '@/controls/previewMode';
+import { usePreviewOverride } from '@/controls/previewOverride';
+import { useLocalTemplateEdits, layerEditName } from '@/controls/localTemplateEdits';
+import { layerContentToDef, overlayLayerDef, type LayerTemplateContent } from '@/shell/layerFromTemplate';
 
 /**
  * Live OAP-driven layer + menu state. Fed by `GET /api/menu`. Refetches on
@@ -37,7 +41,51 @@ export function useLayers() {
     refetchOnWindowFocus: true,
   });
 
-  const layers = computed<LayerDef[]>(() => q.data.value?.layers ?? []);
+  const previewMode = usePreviewMode();
+  const previewOverride = usePreviewOverride();
+  const localEdits = useLocalTemplateEdits();
+
+  /** Preview content for a layer key. `source=local` reads the live local
+   *  draft; `bundled`/`remote` read the override snapshot. */
+  function previewContent(key: string): LayerTemplateContent | null {
+    const name = layerEditName(key);
+    const src = getPreviewSource();
+    if (src === 'local') return localEdits.get<LayerTemplateContent>(name) ?? null;
+    if (src === 'bundled' || src === 'remote') return previewOverride.get<LayerTemplateContent>(name) ?? null;
+    return (
+      previewOverride.get<LayerTemplateContent>(name) ??
+      localEdits.get<LayerTemplateContent>(name) ??
+      null
+    );
+  }
+
+  // In preview mode the per-layer tab set must reflect the previewed
+  // template (its `components`/`slots`), so the sidebar overlays caps/slots
+  // from the draft — and injects a previewed layer OAP doesn't list (no
+  // live data) so the operator can navigate its tabs. Nothing is pushed to
+  // OAP; this is browser-side only.
+  const layers = computed<LayerDef[]>(() => {
+    const base = q.data.value?.layers ?? [];
+    if (!previewMode.value) return base;
+    const seen = new Set(base.map((L) => L.key.toUpperCase()));
+    const overlaid = base.map((L) => {
+      const content = previewContent(L.key);
+      return content ? overlayLayerDef(L, content) : L;
+    });
+    // Inject previewed layers absent from the live menu.
+    const injected: LayerDef[] = [];
+    for (const name of [...previewOverride.names(), ...localEdits.names()]) {
+      if (!name.startsWith('horizon.layer.')) continue;
+      const key = name.slice('horizon.layer.'.length);
+      if (seen.has(key.toUpperCase())) continue;
+      const content = previewContent(key);
+      if (content) {
+        injected.push(layerContentToDef(content));
+        seen.add(key.toUpperCase());
+      }
+    }
+    return [...overlaid, ...injected];
+  });
   /**
    * Layers known to OAP (returned by `listLayers`). May include stale
    * registry entries — receivers that ever ingested but currently have no
diff --git a/apps/ui/src/utils/stableJson.ts b/apps/ui/src/utils/stableJson.ts
new file mode 100644
index 0000000..7b9d853
--- /dev/null
+++ b/apps/ui/src/utils/stableJson.ts
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * JSON.stringify with object keys deep-sorted (array order preserved). Use
+ * it to compare / diff two objects that may carry the same data in a
+ * different key order — e.g. a locally-edited template (authored key order)
+ * vs OAP's copy (stored alphabetically). Without this, a one-field edit
+ * looks like "everything changed" in a line diff.
+ */
+function sortKeys(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(sortKeys);
+  if (value && typeof value === 'object') {
+    const obj = value as Record<string, unknown>;
+    const out: Record<string, unknown> = {};
+    for (const k of Object.keys(obj).sort()) out[k] = sortKeys(obj[k]);
+    return out;
+  }
+  return value;
+}
+
+export function stableStringify(value: unknown, space?: number): string {
+  return JSON.stringify(sortKeys(value), null, space);
+}
diff --git a/docs/customization/layer-templates.md b/docs/customization/layer-templates.md
index 3dc7ed6..eb00a03 100644
--- a/docs/customization/layer-templates.md
+++ b/docs/customization/layer-templates.md
@@ -321,21 +321,26 @@ When set, the layer's service list groups by `cluster`. Without it, services are
 
 ## Admin Editor
 
-Layer templates are editable at runtime via **Dashboard setup → Layer dashboards** (`/admin/layer-dashboards`, verb `dashboard:write`). The editor shows layer-specific controls for service, instance, endpoint, topology, trace, log, and profiling views.
+Layer templates are editable at runtime via **Dashboard setup → Layer dashboards** (`/admin/layer-dashboards`, verb `dashboard:write`). Pick a layer from the filterable dropdown (alias + key + sync status), then edit its service / instance / endpoint / topology / trace / log / profiling views. A live menu preview sits beside the Alias / Components / Menu-labels editor; clicking a menu item jumps to that component's config.
 
-The save/publish model has two steps:
+### How edits flow: draft → preview → publish
 
-1. **Save locally.** "Save locally" writes your edit to the local bundled copy and renders it immediately for preview — it does **not** touch OAP. The template now shows as **diverged** (local differs from what OAP serves), the row carries a *Synced from OAP — N diverged* banner, and the affected layers show a yellow warning icon in the sidebar. Save works even when OAP is unreachable.
-2. **Publish.** **Sync all to OAP** pushes the diverged templates to OAP (the runtime source of truth) — only the ones that differ — behind a confirmation that lists exactly what will be written. After publishing, the template is synced and everyone sees it.
+Your work-in-progress lives **in your browser**, never on the server until you publish. The live page everyone sees stays on the published OAP version throughout.
 
-A **Diverged only** filter and a **Showing: Local / Remote** display toggle sit at the top of the page; **Show diff** opens a side-by-side bundled-vs-OAP comparison.
+1. **Save (local).** Stores your edit as a draft in this browser only. Nobody else sees it, and your own normal browsing still shows the published version. The picker tags a layer with a local draft as **local**.
+2. **Reset to ▾.** Loads the **Bundled** (shipped default) or **Remote** (OAP live) version into the editor as a fresh starting point.
+3. **Preview ▾.** Opens the real layer page in a new tab rendering your **Local** draft, the **Bundled** default, or **Remote** — using sample data, so you can check layout, enabled components, and menu labels without touching the server. Preview works even for layers OAP currently reports no services for.
+4. **Check diff & push.** Shows a side-by-side *remote → local* diff and publishes to OAP (the runtime source of truth). Enabled only when your draft actually differs from remote. After publishing, the draft is cleared and everyone sees the change.
 
-### Local vs. remote conflicts
+A top banner summarizes page state — *Synced from OAP — N diverged, Y local* — and **Diverged** / **Local** filters narrow the picker. Each row shows a status chip: **synced** (bundled == OAP), **diverged** (OAP differs from bundled — OAP wins at render), **remote-only** (on OAP, no bundled default), **disabled** (deleted — see below), or **bundled** (OAP has no copy right now).
 
-OAP is the source of truth at runtime, so by default the app renders the OAP-stored version. When your local edits diverge, a per-session prompt (listing the affected items by menu name) asks which to render:
+### Disabling / reactivating a layer
 
-- **Keep my local edits** — render your local copy for preview; publish later with Sync all.
-- **Use live** — overwrite your local copy with the remote (OAP) version. This **discards your local edits** and is confirmed first; use it when OAP holds the newer version.
+OAP has no hard delete, so the **Disable** button next to the layer title soft-disables the layer on OAP. A disabled layer is dropped from the sidebar and renders nowhere, for everyone.
+
+A disabled layer still appears in this admin page (struck-through, status **disabled**) and offers a **Reactivate** button that re-enables it from the bundled default. A layer that exists only as an unpublished local draft is simply removed from your browser. Both actions are confirmed in a dialog first.
+
+> **Note:** re-enabling depends on the OAP UI-template API clearing the disabled flag. On OAP versions that don't support this, a disabled layer must be re-enabled from the OAP side. Treat disabling a built-in layer as a heavyweight action.
 
 ## Bundled examples
 
diff --git a/docs/customization/overview-templates.md b/docs/customization/overview-templates.md
index e3c4fb3..2392210 100644
--- a/docs/customization/overview-templates.md
+++ b/docs/customization/overview-templates.md
@@ -206,30 +206,35 @@ Read-only — Horizon does not support acknowledge / close / silence operations.
 
 ## Admin Editor
 
-Overview templates are editable at runtime via **Dashboard setup → Overview templates** (`/admin/overview-templates`, verb `overview:write`). The editor:
+Overview templates are editable at runtime via **Dashboard setup → Overview templates** (`/admin/overview-templates`, verb `overview:write`). Pick a dashboard from the filterable dropdown (title + id + sync status), then lay it out on a **12-column canvas**: drag a widget to reorder, corner-drag to resize, click a widget to edit it in the right-hand drawer. Section breaks ("text widget" / line break) and the dashboard title are selectable too. The canvas shows **sample data** so you can  [...]
 
-- Lists all bundled overviews + any added ones, with widget count and editable flag.
-- For each overview, shows the widget array with per-widget controls.
-- **Type-aware editor**: per `widget.type`, only the relevant fields are exposed:
+**Per-widget fields** (the drawer shows only what the `widget.type` needs):
 
-  | Type | Fields shown |
-  |---|---|
-  | `section-break` | `title`, `cols` |
-  | `metric` | `layer`, `title`, `tip`, `mqe`, `unit`, `aggregation`, `span`, `rowSpan` |
-  | `topology` | `layer`, `title`, `tip`, `span`, `rowSpan` |
-  | `alarms` | `layer`, `title`, `tip`, `limit`, `span`, `rowSpan` |
-  | `kpi-tile` | `layer`, `title`, `tip`, `showCount`, KPI rows (add / remove), `span`, `rowSpan` |
-  | `metric-composite` | `layer`, `title`, `tip`, KPI rows (mixed MQE + service-count source), `span`, `rowSpan` |
+| Type | Fields shown |
+|---|---|
+| `section-break` | `title`, `cols` |
+| `metric` | `layer`, `title`, `tip`, `mqe`, `unit`, `aggregation`, `span`, `rowSpan` |
+| `topology` | `layer`, `title`, `tip`, `span`, `rowSpan` |
+| `alarms` | `layer`, `title`, `tip`, `limit`, `span`, `rowSpan` |
+| `kpi-tile` | `layer`, `title`, `tip`, `showCount`, KPI rows (add / remove), `span`, `rowSpan` |
+| `metric-composite` | `layer`, `title`, `tip`, KPI rows (each a stacked card: label / source / MQE / unit / aggr / style / max), `span`, `rowSpan` |
+
+### How edits flow: draft → preview → publish
+
+Same model as [layer templates](/customization/layer-templates): your edit lives **in your browser**, and the live page everyone sees stays on the published OAP version until you publish.
+
+1. **Save (local).** Stores your draft in this browser only; the dashboard is tagged **local** in the picker.
+2. **Reset to ▾** loads the **Bundled** or **Remote** version into the canvas.
+3. **Preview ▾** opens the real overview page in a new tab rendering **Local** / **Bundled** / **Remote**.
+4. **Check diff & push** shows a *remote → local* diff and publishes to OAP (create-or-update). Enabled only when your draft differs from remote.
 
-- **Add / remove widgets** with the type picker.
-- **Preview** renders the in-progress template against live OAP data.
+A **+ New dashboard** form (inside the picker) creates a dashboard the same way: it writes a **local draft** (id is the template name, must be unique) — edit and preview it, then **Check diff & push** publishes it to OAP. A pushed dashboard with no bundled default is **remote-only**; the picker, the live page, and the sidebar all show it.
 
-The save/publish model has two steps:
+A top banner summarizes state — *Synced from OAP — N diverged, Y local* — with **Diverged** / **Local** filters. Status chips per row: **synced**, **diverged** (OAP wins at render), **remote-only**, **disabled** (deleted), **bundled**.
 
-1. **Save locally.** The edit is written to the local bundled copy and renders immediately for preview. OAP is not changed yet.
-2. **Publish.** **Sync all to OAP** pushes diverged overview templates to OAP behind a confirmation that lists the affected templates.
+### Deleting a dashboard
 
-If the local copy differs from OAP, Horizon shows the template as **diverged**. Use **Show diff** to compare local and remote, **Keep my local edits** to preview, or **Use live** to discard the local copy and render the OAP version.
+OAP has no hard delete, so the **Delete** button next to the title soft-disables the dashboard on OAP (a disabled dashboard drops from the picker's live state, the sidebar, and the live page). A dashboard that exists only as an unpublished local draft is removed from your browser instead. Either action is confirmed in a dialog first; deletion is irreversible from the UI, but a new dashboard can always be created with the same id.
 
 ## Hot reload