/** * The dshloader UI SLOT engine (`ctx.dshLoader.ui` / `__dshLoader__.ui`). * * dsh's Web shell offers official slots for a few places (`settings.section`, * `conversation.chat.node`, …) but not for everything plugins need to decorate. * Today four plugins in this family each hand-roll the same DOM-injection * boilerplate against hardcoded shell selectors: * * - dsh-thought-buddy → `[data-conversation-scroll] [role="status"]` * - dsh-auxiliary → the Models page's editable model rows * - dsh-code-review → `[data-shell-overlay]`, `.dshDesktopDetailsSurface` * - dsh-better-sidebar → `#root [data-slot="conversation"]`, theme root, … * * Every one of them re-implements: a MutationObserver, requestAnimationFrame * coalescing, an idempotence guard, self-healing after a React re-render, and * teardown. This module implements that ONCE and moves the shell selectors into * a named ANCHOR TABLE the version adapter owns — so when dsh moves its DOM, * one anchor entry changes instead of four plugins. * * What it cannot do: invent an anchor that no longer exists. A vanished anchor * turns N silent breakages into ONE diagnosable one (`ui.diagnose()`), which is * the honest goal — DOM injection is centralised here, not made robust. The * durable fix stays "get the anchor promoted to an official slot". * * @module @dsh-plugin/dsh-loader/ui/slots */ import * as React from 'react'; /** Where a mount node is placed relative to its host element. */ export type InsertMode = 'prepend' | 'append' | 'before' | 'after'; /** How the active adapter locates one shell anchor. */ export interface AnchorSpec { /** Human-readable description, used by {@link UiAPI.diagnose}. */ describe: string; /** Primary locator. Return every element that should host a mount. */ find: () => Iterable; /** * Fallback locator, tried only when `find` yields nothing. Lets an anchor * survive a narrow selector going stale (the pattern thought-buddy uses: * scoped selector first, whole-document second). */ fallback?: () => Iterable; /** Extra predicate a candidate must satisfy (e.g. matching text content). */ accept?: (host: Element) => boolean; /** Placement of the mount node. Defaults to `'append'`. */ insert?: InsertMode; /** Tag name for the generated mount node. Defaults to `'span'`. */ tagName?: string; } /** One plugin-supplied mount against an anchor. */ export interface MountSpec { /** * Unique mount id, `':'`. Doubles as the `data-dshl-slot` * attribute value that makes mounting idempotent. */ id: string; /** * Populate the mount node. Called once per host; return a cleanup that * releases whatever was created. */ render: (mount: HTMLElement, host: Element) => (() => void) | void; /** Skip this host when the predicate returns false. */ when?: (host: Element) => boolean; } /** A React flavour of {@link MountSpec}. */ export interface ReactMountSpec { /** Unique mount id, `':'`. */ id: string; /** Component rendered into the mount node; receives the host element. */ component: React.ComponentType<{ host: Element; }>; /** Skip this host when the predicate returns false. */ when?: (host: Element) => boolean; } /** Diagnostics for one registered anchor. */ export interface AnchorDiagnostic { anchor: string; describe: string; /** Hosts the primary locator currently finds. */ hosts: number; /** Whether the fallback locator had to be used. */ usedFallback: boolean; /** Mount ids currently registered against this anchor. */ mounts: string[]; /** Live mount instances across all hosts. */ live: number; } /** The `ui` facade. */ export interface UiAPI { /** * Register (or replace) an anchor. The adapter seeds the built-ins at boot; * a plugin may add its own for shell areas dshloader does not know yet. * @returns a disposer removing the anchor and unmounting everything on it. */ defineAnchor(name: string, spec: AnchorSpec): () => void; /** Anchor names currently registered. */ anchors(): string[]; /** * The elements an anchor currently resolves to. Use this for anchors that are * READ rather than decorated — e.g. reading the layout frame's collapsed * attribute or grid width instead of mounting into it. */ hosts(anchor: string): Element[]; /** * Subscribe to "the shell's DOM has settled": called once per coalesced * mutation batch, on the same observer and the same rAF frame the mount sweep * uses. * * This exists for injections whose HOST IDENTIFICATION is itself business * logic and therefore does not fit the anchor model — dsh-auxiliary's model * catalog is the motivating case: it cross-references `aria-label` inputs * against a provider directory, re-reads settings on a debounce, and also * listens for `input` events, so an `AnchorSpec.find()` could never express * it. Such a caller keeps its own sweep but stops running a second * MutationObserver over the whole document. * * The listener MUST be idempotent: it is invoked again after any batch, * including batches its own writes caused. * * @returns an unsubscribe function. */ onDomSettled(listener: () => void): () => void; /** * Mount imperative content on every element an anchor resolves to, now and * whenever the shell re-renders. Idempotent per (host, mount id). * @returns a disposer unmounting every instance of this mount. */ mount(anchor: string, spec: MountSpec): () => void; /** * Mount a React component on every element an anchor resolves to. A React * root is created per host and unmounted on teardown. * @returns a disposer unmounting every instance of this mount. */ mountReact(anchor: string, spec: ReactMountSpec): () => void; /** Snapshot of anchor health — the single place to look when injection stops working. */ diagnose(): AnchorDiagnostic[]; /** Stop observing and unmount everything (called by the loader's own teardown). */ destroy(): void; } /** Options for {@link createUiAPI}. */ export interface CreateUiOptions { /** Root to observe. Defaults to `document.documentElement`. */ observeRoot?: Element; /** Diagnostic sink; defaults to `console.warn`. */ warn?: (message: string) => void; } /** * Build the `ui` facade with its own observer. One engine serves every anchor * and every mount, so the shell is observed exactly once regardless of how many * plugins inject. */ export declare function createUiAPI(options?: CreateUiOptions): UiAPI;