From 2e08090759f96c9a234db48f8001d4222401285a Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 24 Jul 2026 07:59:29 +0000 Subject: [PATCH] feat(hub): add render-only `visibility` to dock entry base declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a whenexpr-typed `visibility` field alongside `when` on `DevframeDockEntryBase`. It hides only the entry's own dock-bar button while leaving the entry registered and fully reachable (activation, RPC lookups, frame-nav, etc.) — useful for a shared-frame `subTabs` anchor that should surface only its synthesized member tabs, not its own button. `when` remains the general relevance switch for an entry as a whole; `visibility` is a narrower render-only carve-out with the same syntax and the same type-safety in `defineDockEntry`. --- docs/guide/when-clauses.md | 18 +++++++++++++ packages/devframe/src/types/devframe.ts | 7 +++++ .../hub/src/client/__tests__/host.test.ts | 18 +++++++++++++ packages/hub/src/define.ts | 6 ++++- .../src/node/__tests__/mount-devframe.test.ts | 13 ++++++++++ packages/hub/src/node/mount-devframe.ts | 7 ++--- packages/hub/src/types/docks.ts | 26 +++++++++++++++++++ 7 files changed, 91 insertions(+), 4 deletions(-) diff --git a/docs/guide/when-clauses.md b/docs/guide/when-clauses.md index 6bf26c01..30c83448 100644 --- a/docs/guide/when-clauses.md +++ b/docs/guide/when-clauses.md @@ -40,6 +40,24 @@ ctx.docks.register({ `when: 'false'` hides an entry unconditionally. +### Render-only visibility on dock entries + +A dock entry also takes `visibility`, a second expression in the same syntax that hides only the entry's own dock-bar button, leaving the entry registered and reachable — `docks.activate()`/`switchEntry()` by id, RPC lookups, and anything else that walks the raw entry list keep working. `when` remains the general relevance switch for the entry as a whole; reach for `visibility` only when an entry must stay in the model without a button of its own. + +The canonical case is a shared-frame anchor (`subTabs`): the anchor iframe must stay registered to keep driving the postMessage nav loop, but only its synthesized member tabs should render as dock-bar buttons. + +```ts +ctx.docks.register({ + id: 'my-devtool:anchor', + title: 'My Devtool', + type: 'iframe', + icon: 'ph:squares-four-duotone', + url: '/__my-devtool/', + subTabs: { protocol: 'postmessage' }, + visibility: 'false', // hide the anchor's own button; its tabs still render +}) +``` + ## Expression syntax ### Operators diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 3b93ec41..5f57777f 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -219,6 +219,13 @@ export interface DevframeDockDefaults { * clauses). Set to `'false'` to hide the entry unconditionally. */ when?: string + /** + * Render-only visibility expression, same syntax as {@link when}. Hides the + * entry's own dock-bar button when it evaluates to `false` while leaving it + * registered and reachable (activation, RPC lookups, etc.) — unlike `when`, + * which is the general relevance switch for the entry as a whole. + */ + visibility?: string /** Badge text rendered on the dock icon (e.g. an unread count). */ badge?: string /** Id of the dock group this entry collapses under, if any. */ diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index db214abf..37dad14a 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -99,6 +99,24 @@ describe('createDevframeClientHost', () => { host.dispose() }) + it('keeps a `visibility: false` entry in the raw model, activatable by id', async () => { + const { rpc, states } = createStubRpc() + const host = await createDevframeClientHost({ rpc }) + states.get('devframe:docks')!.push([ + iframeEntry('anchor', { subTabs: { protocol: 'postmessage' }, visibility: 'false' }), + iframeEntry('visible'), + ]) + + // `visibility` is a render-only hint for the UI layer — the hub itself + // never filters `entries`/`getStateById`/`switchEntry` by it, so the + // anchor stays fully reachable. + expect(host.context.docks.entries.map(e => e.id)).toEqual(['anchor', 'visible']) + expect(host.context.docks.getStateById('anchor')).toBeDefined() + expect(await host.context.docks.switchEntry('anchor')).toBe(true) + expect(host.context.docks.selected?.id).toBe('anchor') + host.dispose() + }) + it('groups entries by category — grouped members bucket under their group, orphans by their own', async () => { const { rpc, states } = createStubRpc() const host = await createDevframeClientHost({ rpc }) diff --git a/packages/hub/src/define.ts b/packages/hub/src/define.ts index fe68081c..2ab9d320 100644 --- a/packages/hub/src/define.ts +++ b/packages/hub/src/define.ts @@ -16,8 +16,12 @@ export function defineCommand( export function defineDockEntry< const T extends DevframeDockUserEntry, const W extends string = '', + const V extends string = '', >( - entry: Omit & { when?: WhenExpression }, + entry: Omit & { + when?: WhenExpression + visibility?: WhenExpression + }, ): T { return entry as unknown as T } diff --git a/packages/hub/src/node/__tests__/mount-devframe.test.ts b/packages/hub/src/node/__tests__/mount-devframe.test.ts index 67a81e63..7e147d15 100644 --- a/packages/hub/src/node/__tests__/mount-devframe.test.ts +++ b/packages/hub/src/node/__tests__/mount-devframe.test.ts @@ -71,6 +71,19 @@ describe('mountDevframe', () => { }) }) + it('applies the definition-level dock `visibility` default to the synthesized entry, independent of `when`', async () => { + const ctx = createContext() + await mountDevframe(ctx, makeDevframe({ + dock: { when: 'clientType == embedded', visibility: 'false' }, + })) + + expect(ctx.docks.views.get('demo')).toMatchObject({ + id: 'demo', + when: 'clientType == embedded', + visibility: 'false', + }) + }) + it('lets per-mount dock overrides win over the definition dock defaults', async () => { const ctx = createContext() await mountDevframe( diff --git a/packages/hub/src/node/mount-devframe.ts b/packages/hub/src/node/mount-devframe.ts index 2569af12..f8bdf53e 100644 --- a/packages/hub/src/node/mount-devframe.ts +++ b/packages/hub/src/node/mount-devframe.ts @@ -13,9 +13,10 @@ export interface MountDevframeOptions { /** * Per-mount overrides for the auto-synthesized iframe dock entry. Use * this to customize the entry's `category`, override the icon, hide it - * via `when`, etc. Takes precedence over the definition's own - * {@link DevframeDefinition.dock} defaults. Cannot change `id`, `type`, - * or `url` — those are derived from the devframe definition. + * via `when` (or only its dock-bar button via `visibility`), etc. Takes + * precedence over the definition's own {@link DevframeDefinition.dock} + * defaults. Cannot change `id`, `type`, or `url` — those are derived from + * the devframe definition. */ dock?: Partial> } diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index ac20b9b1..020ff377 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -108,6 +108,28 @@ export interface DevframeDockEntryBase { * @see {@link import('devframe/utils/when').evaluateWhen} */ when?: string + /** + * Render-only conditional visibility expression, same syntax as {@link when}. + * When it evaluates to `false`, a viewer omits the entry from the rendered + * dock bar / list, but the entry stays registered and fully reachable — + * `docks.activate()`/`switchEntry()` by id, RPC lookups, and anything else + * that walks the raw entry list (e.g. the {@link DevframeViewIframe.subTabs} + * frame-nav adapter) keep working exactly as if it were visible. + * + * Use this instead of {@link when} when an entry must remain part of the + * model without a dock-bar button of its own — the canonical case is a + * shared-frame {@link DevframeViewIframe.subTabs anchor}: set + * `visibility: 'false'` on the anchor so only its synthesized member tabs + * render, while the anchor itself keeps driving the postMessage nav loop. + * `when`, by contrast, is the general relevance switch for the entry as a + * whole; reach for `visibility` only for this render-only carve-out. + * + * Set to `'false'` to unconditionally hide the entry's own dock-bar button. + * + * @example 'false' + * @see {@link import('devframe/utils/when').evaluateWhen} + */ + visibility?: string /** * Badge text to display on the dock icon (e.g., unread count) */ @@ -182,6 +204,10 @@ export interface DevframeViewIframe extends DevframeDockEntryBase { * (grouped/soft-navigated via this anchor's {@link frameId}), and drives the * live navigation loop. Absent a shim, the anchor simply renders as a single * plain iframe dock. + * + * Set {@link DevframeDockEntryBase.visibility} to `'false'` on the anchor to + * hide its own dock-bar button once tabs are discovered, surfacing only the + * synthesized member docks while the anchor keeps driving the nav loop. */ subTabs?: FrameSubTabsConfig /**