Pin Dock + Global Search

Two cross-cutting features that overlay the standard tab/section flow: a bottom dock for pinned sections, and a top search box that filters the toolbar to matching properties.

Two cross-cutting features that overlay the standard tab/section flow: a bottom dock for pinned sections, and a top search box that filters the toolbar to matching properties.

Inspector Pin Dock

A bottom region of the toolbar that holds pinned section bodies. Pinning keeps a section's controls visible across selection changes — useful when you're tweaking, say, spacing or color across multiple nodes in a row.

How it works

Each PropertySection header carries a <SectionPinButton>. Clicking it adds the section's id to pinnedIds in InspectorPinContext and the section body gets portaled into a slot inside InspectorPinDock at the bottom.

FileRole
InspectorPinContext.tsxContext: pinnedIds, togglePin, registerSlot, getSlotNode
InspectorPinDock.tsxBottom region — collapsible, height-adjustable, hosts portal targets
SectionPinButton.tsxThe pin/unpin toggle in each section header

Portal mechanics

The pinned section's body is not duplicated. PropertySection (PropertySection.tsx) checks isPinned(sectionId) and getSlotNode(sectionId):

  • If pinned, the body renders into the dock's slot via createPortal.
  • The original location in the stack panel shows a placeholder ("Pinned to dock") so users know where it went.

This keeps a single React subtree for each section — same hooks, same state, same useNode() context, just a different DOM mount point.

Persistence

InspectorPinDock.tsx:21-25 reads/writes via phStorage:

  • sidebar-inspector-pin-height — dock height in pixels
  • sidebar-inspector-pin-dock — open/closed boolean
  • (Pinned ids themselves persist via pinnedIds in InspectorPinContext's storage hook.)

The dock height also gets reflected to --sidebar-inspector-pin-height CSS var on #toolbar so the scroll body can reserve space and avoid overlap:

toolbar.style.setProperty("--sidebar-inspector-pin-height", `${h}px`);

UI states

  • No pins — dock returns null, takes no space.
  • Pinned but collapsed — single-row "Pinned" button at the bottom (one click expands).
  • Open — full panel with drag handle (top edge), collapse button, and one card per pinned section.

Drag handle uses pointer-move math — no library — clamped to [MIN_HEIGHT, 60% of toolbar height].

Gotchas

  • Slot ref callbacks must be stable. Inline ref={el => ...} retriggers ref churn → infinite setState. The dock memoizes ref callbacks per section id (InspectorPinDock.tsx:39-54).
  • Portal target must exist before the section renders. The dock renders empty <div ref={refForSlot(id)} /> first; the section's body portals into that node on its next render.
  • Pinned section UX shouldn't depend on accordion state. The dock shows bodies always-open by design.

Global Search

Typing in the search box (SettingsSearchAtom) replaces the stacked tabs with a single results list of matched properties — no tab navigation during search.

Replacement behavior

RegistrySettings.tsx:218-222:

<InspectorBody
  sections={
    isSearching
      ? [{ title: `Results for "${search}"`, children: <SearchResults search={search} /> }]
      : STATIC_SECTIONS
  }
/>

A single fake "Results" section replaces all 5 stacks while searching. Tab strip becomes a single-tab strip.

Matching

searchProperties(query) (propertyRegistry.ts) returns Map<sectionId, PropertyDef[]> — properties grouped by their home section so results stay organized. Matches against def.label + def.keywords. searchOnly properties are included only here (never in the normal stack view).

Highlight inside results

RegistrySettings.tsx:233-271 (<SearchEffects>) does two things:

  1. Auto-expand accordions: accordionCtx.openAll() so collapsed sections inside results show their bodies.

  2. Visual highlight: uses the CSS Custom Highlight API to highlight matched substrings inside #toolbarContents. No DOM mutation — CSS.highlights.set("settings-search", new Highlight(...ranges)).

    Uses a TreeWalker to find every text node, builds Ranges for each match, registers them with the global highlights map. Cleanup runs the delete on every search change.

Gotchas

  • Highlight requires browser support. The if (typeof CSS === "undefined" || !("highlights" in CSS)) return; guard short-circuits in older browsers. Search still works without it — just no visual highlight.
  • searchOnly is intentional. Some properties are too niche for the main view (e.g. weird CSS overrides). Marking them searchOnly: true keeps them discoverable without cluttering tabs.
  • Tab atom unchanged during search. When the user clears the search, the previously active tab is still the right one. Don't reset it.