The dock workbench has been rendering panels, toggling them on click, and persisting their state for a while now. What it didn’t have was a way for consumers to say “show the backlog panel” from code, or a URL that means “open this page with these panels visible.” Trellis needs both.

The exclusivity problem I didn’t know I had

I started with what seemed like the straightforward part: add an activateDockPanel(key) method to LiveSite, dispatch pages-dock-toggle, done. But the design review caught something I’d missed — zone-group exclusivity (only one panel visible per zone in an exclusive dock bar) was enforced in the click handler, not in the event handler. Any code dispatching pages-dock-toggle directly — and there were already several call sites doing this — bypassed exclusivity entirely. Both panels would show simultaneously, and the dock-bar buttons would diverge from actual panel state.

The fix was to centralise everything in the pages-dock-toggle handler in site.ts: exclusivity enforcement, button data-active sync, and cascade expand/collapse. The click handler became a one-liner — dispatch the event and nothing else. Same for initDockZoneGroup and the rearrange handler.

One subtlety: the exclusive prop is on the dock-bar component, not the zone. I needed a way for the centralised handler to know whether the bar is exclusive without importing the component’s props. The answer was data-exclusive on the dock-bar DOM element — set at render time, checked by the handler via btn.closest('[data-component-type="dock-bar"]'). Clean DOM-contract communication, no coupling.

activateDockPanel

With exclusivity centralised, activateDockPanel was simple:

function activateDockPanel(key: string): boolean {
  const panelEl = target.querySelector(`[data-component-id="${CSS.escape(key)}"]`);
  if (!panelEl) return false;
  target.dispatchEvent(new CustomEvent("pages-dock-toggle", {
    bubbles: true, composed: true,
    detail: { panelId: key, visible: true },
  }));
  return true;
}

DOM lookup instead of zoneEngine.zoneMap — so it works for both dockWorkbench() compositions and manual dock-bar setups where no zone engine exists. Returns false for unknown keys without throwing.

Hash panel binding

The second feature extends DeepLink with a panel field: #/page/dashboard?panel=backlog,properties. The interesting design choice was the relationship between panel= and the existing dock= param.

dock= is total state declaration — every panel’s open/closed status. It’s what syncUrl produces internally. panel= is activation intent — “show these panels.” It’s additive: a deep link with ?panel=backlog works even if new panels are added to the workbench later. dock=nav:open,files:closed,props:closed breaks the moment someone adds a fourth panel.

When both appear for the same key, panel= wins. The merge happens inside restoreFromUrl before dispatching events, so there’s no redundant hide-then-show churn.

I also added support for panel-only deep links: #?panel=backlog (no page path). This required extending parseFromUrl to handle the #? prefix and updating serializeToUrl to produce #? when the page is empty.

The popstate bug

While wiring the hash binding, I found that restoreFromUrl was updating the dockState map on popstate but never dispatching pages-dock-toggle events. Dock panels didn’t actually show or hide on browser back/forward. This was a pre-existing bug — fixed it as a prerequisite before adding the panel merge.

What this opens up

Trellis can now wire keyboard shortcuts to site.activateDockPanel("terminal") and share deep links like #/page/workspace?panel=terminal,agent. The API is small — one method and one URL param — but it’s the minimum a workbench consumer needs for programmatic control without reaching into DOM internals.


<
Previous Post
Trust but verify — making the orchestrator a gatekeeper
>
Next Post
Closing the Snapshot Gaps