The Dock Pattern

One container owns which panels are open. No panel knows about any other.

The problem

An app grows floating things — a player, a chat, a settings panel, a cookie notice — and each one arrives with its own corner, its own toggle, and its own localStorage key. Nothing knows about anything else, so they overlap, they all shout at once, and after a reload the screen is whatever each panel independently decided.

The fix is not a component library. It is moving one thing — presence — out of the panels and into a container. The dock knows what exists, what is open, and where it sits. A panel knows how to draw itself. That is the whole split, and it is worth doing at three panels, not thirty.

Running, right here

Pull the bar at the bottom of the frame. Three panels, one dock, no framework — drag them, minimise them, reload the frame and they come back where you left them.

The demo is one HTML file; the dock is one script, no dependencies. Public domain — take it, rename it, change everything.

Hand it to your agent

Paste this into Claude, in your own project. It builds the dock from scratch, in your stack, with the bugs below already dodged.

Build a "dock" for my app's floating panels: one container that owns which
panels are open, so no panel has to know about any other.

THE SHAPE
- One pull tab, bottom centre of the viewport. Clicking it opens a tray.
- The tray has a header row (what this is, and an X on the right that closes
  it), then a row of panels: glyph, label, and a dot showing open / minimised.
- Clicking a tray row opens that panel in a popout window with a titlebar, a
  minimise and a close. Clicking it while open minimises it instead.
- Windows drag by their titlebar, land near the centre of the screen in the
  first spot that overlaps nothing, and remember where they were.
- ONE record in localStorage holds every panel's state and position. That
  record is the source of truth — not a flag inside each panel.
- A toast API for the things that are not panels: a line of text and a couple
  of actions, stacked above the tab.

REGISTRATION — three tiers, because panels come in three shapes
1. { tag: 'my-panel' } — a custom element the dock creates and owns. The dock
   sets an `embedded` attribute on it; the element then renders its body only
   and leaves chrome, positioning and close to the dock.
2. { mount(body, api) } — the dock hands you an empty box and { close,
   minimize }. For plain markup, or a framework portal target.
   Panels mount inside the dock's shadow root, so the host page's CSS does not
   reach them — that is what lets the dock survive on a page whose styles it
   has never met. A panel brings its own: its own shadow root, or a <style> in
   the markup it mounts. Say so in the docs; it is not obvious.
3. { external: true, onOpen, onMinimize, onClose } — a panel my app already
   renders and positions. The dock owns only its state and its tray row. My
   app calls dock.setState(id, 'open'|'min'|'closed') when its own UI changes
   it, and on first mount each panel adopts dock.getState(id) — so the dock
   decides what is up after a reload, not each panel's own default.

API
  register(item) · unregister(id)
  open(id) · minimize(id) · close(id) · toggle(force?)
  getState(id) → 'open' | 'min' | 'closed' · setState(id, state)
  toast({ text, actions: [{ label, onClick, primary }], sticky, ttl })
  fires a `dock-ready` event on document with { dock }, and sets a global
  handle, so panels can register whichever loads first.

RULES THAT MATTER — every one of these is a bug already paid for
- Read attributes in connectedCallback, NEVER in the constructor. The dock
  sets them after createElement, so a constructor never sees them.
- Guard the position clamp against a 0x0 viewport. Background tabs, prerender
  and hidden frames report that, and the arithmetic sends every window to a
  negative corner.
- Settle animations on the Animation's finished promise, never on a timer that
  has to agree with a duration in a stylesheet. Under prefers-reduced-motion,
  resolve immediately and cut.
- Write the state record before the position — they share one entry, and the
  order decides whether a restored window keeps where it was.
- Count only the dock's own windows in the tab badge. External panels have a
  tray dot but are not windows, and counting them makes the badge lie.
- Wrap the whole file in an IIFE and put the UI in a shadow root. It gets
  dropped into pages that may already declare the same names.
- Escape minimises the active window — the one last raised — and only closes
  the tray when nothing is up.
- A container that moves between DOM trees (Turbo, morphdom, a router) fires
  disconnect then connect. Build once, and defer teardown a tick so a move
  does not tear the dock down.
- The dock may connect before the code that registers panels runs, or after
  it. Support both: read the global handle if it is already there, otherwise
  listen once for the ready event. Getting this wrong is the most common way a
  panel silently never appears — it cost me the first run of my own demo.
- If a panel is registered by tag and that tag's script never loaded, the
  element upgrades to nothing and the window opens empty — it reads as a broken
  widget, not a missing file. Check customElements.get(tag) when you spawn, say
  which tag is missing, and clear the note if whenDefined(tag) later resolves.

Style everything from CSS custom properties with defaults, so a host page can
theme it by setting a handful of variables and nothing else. No dependencies.

The three tiers

The reason this works on an app that already exists: you do not have to rewrite a panel to dock it. Pick the tier that matches what you already have.

tieryou givedock ownsuse when
tag a custom element name the element, its window, its state you are writing the panel now
mount a function that fills a box the box, its window, its state plain markup, or a portal target
external three callbacks the state and the tray row only the panel already renders itself

The third tier is the one that earns its keep. A React panel with its own badge, its own drawer and its own animation keeps every bit of that — it just stops being the authority on whether it is open.

Adopting an app that already has panels

Second prompt, for the retrofit. It is a smaller job than it looks: one hook, one line per panel.

My app already has several floating panels, each holding its own open/closed
state (useState, a store, or a localStorage key each). Wire them into the
dock as EXTERNAL items without moving any of their markup.

- Write one hook/helper — useDockItem({ id, label, glyph, title, open,
  setOpen, enabled }) — that registers the panel with the dock as
  { external: true }, maps the dock's onOpen/onMinimize/onClose onto setOpen,
  and reports every change the panel makes itself back with setState.
- The dock may load after the app, or before it. Handle both: read the global
  handle if it is there, otherwise listen once for the ready event.
- On first registration the panel adopts dock.getState(id) — the dock's
  memory beats the panel's own default, so one place decides what is open
  after a reload.
- Keep the callbacks in a ref so they never close over a stale setter.
- Unregister on unmount, so a panel scoped to one route leaves the tray. Its
  remembered state stays, so returning to the route restores it.
- Add exactly one line per panel. Do not touch their rendering, their styling
  or their own toggles — those keep working and now report in.

Then delete whatever ad-hoc corner toggles the panels had, if the dock's tray
has replaced them.

What it buys you

before

Six panels, six corners, six storage keys. Two overlap on a laptop screen. The cookie bar covers the player. Nothing can ask "what is open right now?" because nothing holds the answer.

after

One tray. One record. A new panel is one register call and inherits placement, persistence, drag, minimise, keyboard and theming. Anything can ask the dock what is up — including the next feature you have not written.

The traps, in full

Where this came from

Five Lucian Labs properties run this: the shop, radio, clock, hire and labs widgets used to be five fixed corners across lucianlabs.ca, tides, mood, waveloop.app and elijahlucian.ca. They are one dock now, and the personal site's own React panels ride in it as external items — fourteen of them, one line each.

dock.js on this page is that dock with the branding taken out. It is the pattern, not the product: no Lucian tokens, no widgets, nothing to strip before you use it.