From 39a5157eb44ea525eabea0cfeb565c6a1e26e275 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 23 Jul 2026 02:24:23 +0000 Subject: [PATCH 1/2] feat(hub): reinterpret grouped dock entry category as in-group sub-category Adopt the refined dock-grouping semantics downstream viewers are moving to: when a dock entry's groupId resolves to a registered group, the group's own category is the entry's outer dock-bar bucket, and the entry's own category is reinterpreted as an in-group sub-category. Orphan entries (groupId with no registered group) keep using their own category as the outer bucket. - Document the dual role of category/groupId and the group's category on the types (DevframeDockEntryBase, DevframeViewGroup) plus the hub grouping docs. - Make the client host's groupByCategory bucket grouped members under their group's category (default fallback), orphans under their own, so hub's own groupedEntries agrees with the refined semantics for standalone viewers. - Reconcile DEFAULT_CATEGORIES_ORDER to put framework first (matching what Vite DevTools ships) and export it from the main, node and client public entry points so downstream kits can import the single source of truth. Created with the help of an agent. --- docs/guide/client-context.md | 2 +- docs/guide/hub.md | 15 +++++++ .../hub/src/client/__tests__/host.test.ts | 44 +++++++++++++++++++ packages/hub/src/client/host.ts | 15 ++++++- packages/hub/src/client/index.ts | 3 ++ packages/hub/src/constants.ts | 8 +++- packages/hub/src/index.ts | 1 + packages/hub/src/node/index.ts | 3 ++ packages/hub/src/types/docks.ts | 30 +++++++++++-- .../@devframes/hub/client.snapshot.d.ts | 1 + .../tsnapi/@devframes/hub/client.snapshot.js | 4 ++ .../tsnapi/@devframes/hub/index.snapshot.d.ts | 1 + .../tsnapi/@devframes/hub/index.snapshot.js | 1 + .../tsnapi/@devframes/hub/node.snapshot.d.ts | 1 + .../tsnapi/@devframes/hub/node.snapshot.js | 4 ++ 15 files changed, 127 insertions(+), 6 deletions(-) diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md index 94386ea7..9a4bca3b 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -50,7 +50,7 @@ Boot the host once per page: a second boot replaces the published context and lo |----------|-------------| | `rpc` | The [RPC client](./client) — call server functions, register client-side functions, access shared state. | | `clientType` | `'embedded'` (runtime inside your app) or `'standalone'` (independent hub page). | -| `docks` | Dock entries and selection — `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`. | +| `docks` | Dock entries and selection — `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`. `groupedEntries` buckets entries by outer category (a grouped member takes its group's category; see [Grouping dock entries](./hub#grouping-dock-entries)). | | `panel` | Dock panel state: position, size, drag/resize flags. | | `commands` | The command palette: `register()`, `execute()`, `getKeybindings()`. | | `renderers` | Dock-renderer registry — `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a host-registered renderer (e.g. [JSON-Render](./json-render)); the hub ships none. | diff --git a/docs/guide/hub.md b/docs/guide/hub.md index 95cc8e0a..4d86abb6 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -199,6 +199,21 @@ ctx.docks.register({ `groupId` lives on every entry kind, so iframes, launchers, custom-render views, and integration-contributed types (e.g. json-render panels) all join groups the same way. The group and its members stay independent top-level entries in `devframe:docks`; a downstream UI derives the visual collapse by matching each member's `groupId` to the group's `id` and renders members in a popover or sub-navigation. `defaultChildId` names the member opened when the group button is activated. +### The dual role of `category` + +`category` decides an entry's outer bucket on the dock bar (ordered by `DEFAULT_CATEGORIES_ORDER`) — but its meaning shifts once an entry joins a group: + +- **Ungrouped entry** — `category` is the entry's outer dock-bar bucket, defaulting to `'default'`. +- **Grouped entry** (a `groupId` that resolves to a registered group) — the outer bucket comes from the **group's** own `category` instead. The member's own `category` is reinterpreted as an **in-group sub-category** that sub-divides and sorts members inside the group's popover / sub-navigation. + +The group's `category` is therefore the single outer bucket shared by the group button and all its members. Fallbacks keep the rule total: + +- A **group** with no `category` buckets itself and its members under `'default'`. +- A grouped **member** with no `category` defaults its in-group sub-category to `'default'`. +- **Orphan tolerance** — a member whose `groupId` never resolves to a registered group renders as a normal top-level entry and uses its **own** `category` as the outer bucket, exactly as an ungrouped entry does. + +In the example above, `nuxt:overview` renders under the `framework` bucket (from the `nuxt` group) even though it could carry its own `category`; that own `category` would only sort it against sibling members inside the Nuxt group. + Grouping is one level deep: members join a group, and a group is always a top-level button. A member whose group is never registered renders as a normal top-level entry, so registration order is free. ## The protocol — what the UI sees diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index 31689040..f63c298a 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -62,6 +62,10 @@ function iframeEntry(id: string, extra?: Record): DevframeDockE return { id, title: id, icon: 'ph:cube', type: 'iframe', url: `/__${id}/`, ...extra } as DevframeDockEntry } +function groupEntry(id: string, extra?: Record): DevframeDockEntry { + return { id, title: id, icon: 'ph:folder', type: 'group', ...extra } as DevframeDockEntry +} + describe('createDevframeClientHost', () => { it('publishes the global client context with the full surface', async () => { const { rpc } = createStubRpc() @@ -95,6 +99,46 @@ describe('createDevframeClientHost', () => { 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 }) + + states.get('devframe:docks')!.push([ + // (a) member with groupId + its own category → outer bucket = group's category ('framework'), + // and its own 'app' category is reinterpreted as an in-group sub-category. + groupEntry('nuxt', { category: 'framework' }), + iframeEntry('nuxt:overview', { groupId: 'nuxt', category: 'app' }), + // (b) group with no category → members bucket to 'default'. + groupEntry('misc'), + iframeEntry('misc:one', { groupId: 'misc', category: 'web' }), + // (c) orphan member (groupId with no registered group) → its own category ('web'). + iframeEntry('orphan', { groupId: 'ghost', category: 'web' }), + // plain ungrouped entry keeps its own category. + iframeEntry('plain', { category: 'app' }), + ]) + + const grouped = Object.fromEntries( + host.context.docks.groupedEntries.map(([cat, entries]) => [cat, entries.map(e => e.id)]), + ) + + // (a) grouped member lands under the group's category, alongside the group button. + expect(grouped.framework).toEqual(['nuxt', 'nuxt:overview']) + // (b) group without a category, and its member, bucket to 'default'. + expect(grouped.default).toEqual(['misc', 'misc:one']) + // (c) orphan + plain keep their own categories. + expect(grouped.web).toEqual(['orphan']) + expect(grouped.app).toEqual(['plain']) + + // Categories sort by DEFAULT_CATEGORIES_ORDER — framework first. + expect(host.context.docks.groupedEntries.map(([cat]) => cat)).toEqual([ + 'framework', + 'default', + 'app', + 'web', + ]) + host.dispose() + }) + it('switches entries with activation/deactivation events and when-context updates', async () => { const { rpc, states } = createStubRpc() const host = await createDevframeClientHost({ rpc }) diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index 2a118493..1efa882b 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -419,9 +419,22 @@ function createPanelContext(clientType: DockClientType): DocksPanelContext { } function groupByCategory(entries: DevframeDockEntry[]): DevframeDockEntriesGrouped { + // Index registered groups so a member whose `groupId` resolves takes its + // OUTER bucket from the group's category, not its own (which becomes the + // member's in-group sub-category). Orphan members — a `groupId` with no + // registered group — fall back to their own category. + const groupsById = new Map() + for (const entry of entries) { + if (entry.type === 'group') + groupsById.set(entry.id, entry) + } + const groups = new Map() for (const entry of entries) { - const category = entry.category ?? 'default' + const resolvedGroup = entry.groupId ? groupsById.get(entry.groupId) : undefined + const category = resolvedGroup + ? resolvedGroup.category ?? 'default' + : entry.category ?? 'default' let list = groups.get(category) if (!list) { list = [] diff --git a/packages/hub/src/client/index.ts b/packages/hub/src/client/index.ts index 2c91c629..020912a0 100644 --- a/packages/hub/src/client/index.ts +++ b/packages/hub/src/client/index.ts @@ -1,3 +1,6 @@ +// Re-export the category-order table so client-side viewers can import the +// single source of truth from `@devframes/hub/client`. +export { DEFAULT_CATEGORIES_ORDER } from '../constants' export * from './client-script' export * from './context' export * from './docks' diff --git a/packages/hub/src/constants.ts b/packages/hub/src/constants.ts index 474884b5..c0b3592d 100644 --- a/packages/hub/src/constants.ts +++ b/packages/hub/src/constants.ts @@ -2,10 +2,16 @@ import type { DevframeDocksUserSettings } from './types/settings' export * from 'devframe/constants' +/** + * The default ordering weight for each known dock category — lower sorts + * earlier. Downstream viewers (e.g. `@vitejs/devtools-kit`) import this as the + * single source of truth so the hub and its viewers agree on category order. + * `framework` sorts first; `~builtin` (the viewer's own built-in views) last. + */ export const DEFAULT_CATEGORIES_ORDER: Record = { + 'framework': -100, 'default': 0, 'app': 100, - 'framework': 200, 'web': 300, 'advanced': 400, '~builtin': 1000, diff --git a/packages/hub/src/index.ts b/packages/hub/src/index.ts index fca59c2b..ddcfb098 100644 --- a/packages/hub/src/index.ts +++ b/packages/hub/src/index.ts @@ -1,2 +1,3 @@ +export { DEFAULT_CATEGORIES_ORDER } from './constants' export * from './define' export type * from './types' diff --git a/packages/hub/src/node/index.ts b/packages/hub/src/node/index.ts index c5160ded..8e4678d2 100644 --- a/packages/hub/src/node/index.ts +++ b/packages/hub/src/node/index.ts @@ -1,3 +1,6 @@ +// Re-export the category-order table so node-side consumers can import the +// single source of truth from `@devframes/hub/node`. +export { DEFAULT_CATEGORIES_ORDER } from '../constants' export * from './context' export * from './host-commands' export * from './host-docks' diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index fba10e06..ffd72c38 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -76,7 +76,19 @@ export interface DevframeDockEntryBase { */ defaultOrder?: number /** - * The category of the entry + * The category of the entry — a field with a dual role that depends on + * whether {@link groupId} resolves to a registered {@link DevframeViewGroup}: + * + * - **Ungrouped (or orphan) entry** — `category` is the entry's OUTER bucket + * on the dock bar, ordered by {@link import('../constants').DEFAULT_CATEGORIES_ORDER}. + * - **Grouped entry** (a `groupId` that resolves to a registered group) — + * the OUTER bucket is instead the group's own `category`, and this field is + * reinterpreted as the entry's IN-GROUP sub-category, used to sub-divide and + * sort members inside the group's popover / sub-navigation. + * + * Falls back to `'default'` when omitted — both as an outer bucket and, for a + * grouped member, as its in-group sub-bucket. + * * @default 'default' */ category?: DevframeDockEntryCategory @@ -102,8 +114,14 @@ export interface DevframeDockEntryBase { * * This is a flat pointer — membership, not containment. The entry stays an * independently-registered, top-level entry; only its rendering is grouped - * downstream. If the referenced group is never registered, the entry renders - * as a normal top-level entry (orphan tolerance). + * downstream. + * + * When the referenced group **is** registered, it supplies the entry's OUTER + * dock-bar category (the group's own {@link category}), and this entry's own + * {@link category} is reinterpreted as its IN-GROUP sub-category. When the + * referenced group is **never** registered, the entry renders as a normal + * top-level entry and falls back to using its own {@link category} as the + * outer bucket (orphan tolerance). * * @see {@link DevframeViewGroup} */ @@ -277,6 +295,12 @@ export interface DevframeViewJsonRender extends DevframeDockEntryBase { * through the same `register`/`update`/`values` machinery as every other entry, * keyed by `id`. * + * The group's `category` is the OUTER bucket for the group button itself AND + * for every one of its members — a member's own `category` no longer decides + * its outer bucket, but is reinterpreted as an in-group sub-category that + * sub-divides and sorts members inside this group. A group with no `category` + * buckets itself and its members under `'default'`. + * * Grouping is one level deep: a group entry must not itself set `groupId`. */ export interface DevframeViewGroup extends DevframeDockEntryBase { diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index bbea0efa..7ed9d145 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -130,6 +130,7 @@ export * from "devframe/client"; // #endregion // #region Other +export { DEFAULT_CATEGORIES_ORDER } export { DevframeClientRpcHost } export { RpcClientEvents } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js index 79c9f6e5..0331cf7f 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js @@ -16,4 +16,8 @@ export var CLIENT_CONTEXT_KEY /* const */ // #region Re-exports export * from "devframe/client"; +// #endregion + +// #region Other +export { DEFAULT_CATEGORIES_ORDER } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 5ae4bb64..32d68dd9 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -20,6 +20,7 @@ export declare const defineHubRpcFunction: Date: Thu, 23 Jul 2026 02:32:10 +0000 Subject: [PATCH 2/2] feat(hub): add ui/data/performance/docs builtin dock categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the known dock category vocabulary with four more buckets and slot them into DEFAULT_CATEGORIES_ORDER between the existing ones (intentional weight gaps let kits interleave their own): - ui (150) — components, design system, styling - data (250) — state, storage, queries, database - performance (350) — profiling, metrics, budgets - docs (500) — documentation, references Also list the full category vocabulary in the hub grouping docs. The DevframeDockEntryCategory union still allows arbitrary strings via `(string & {})`, so kits keep passing custom ids. Created with the help of an agent. --- docs/guide/hub.md | 19 +++++++++++++++++++ packages/hub/src/constants.ts | 8 ++++++++ packages/hub/src/types/docks.ts | 17 +++++++++++------ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/guide/hub.md b/docs/guide/hub.md index 4d86abb6..cda6ef71 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -216,6 +216,25 @@ In the example above, `nuxt:overview` renders under the `framework` bucket (from Grouping is one level deep: members join a group, and a group is always a top-level button. A member whose group is never registered renders as a normal top-level entry, so registration order is free. +#### Known categories + +`DEFAULT_CATEGORIES_ORDER` (exported from `@devframes/hub`, `@devframes/hub/node`, `@devframes/hub/client` and `@devframes/hub/constants`) names the buckets the hub orders by default, reading from "closest to your app" toward "peripheral": + +| Category | Weight | Typical use | +|---|---|---| +| `framework` | `-100` | Framework internals (Vue / Nuxt / Vite). | +| `default` | `0` | Uncategorized entries. | +| `app` | `100` | The user's own app tools. | +| `ui` | `150` | Components, design system, styling. | +| `data` | `250` | State, storage, queries, database. | +| `web` | `300` | Network, platform, accessibility. | +| `performance` | `350` | Profiling, metrics, budgets. | +| `advanced` | `400` | Power-user / low-level tools. | +| `docs` | `500` | Documentation, references. | +| `~builtin` | `1000` | The viewer's own built-in views; always last. | + +The gaps are intentional — a kit can interleave its own category ids (or override any weight) without editing this table. An unknown category sorts as weight `0`. + ## The protocol — what the UI sees A hub-aware UI doesn't import any hub classes; it reads three shared-state keys and one RPC method: diff --git a/packages/hub/src/constants.ts b/packages/hub/src/constants.ts index c0b3592d..f49665e8 100644 --- a/packages/hub/src/constants.ts +++ b/packages/hub/src/constants.ts @@ -7,13 +7,21 @@ export * from 'devframe/constants' * earlier. Downstream viewers (e.g. `@vitejs/devtools-kit`) import this as the * single source of truth so the hub and its viewers agree on category order. * `framework` sorts first; `~builtin` (the viewer's own built-in views) last. + * + * The buckets read from "closest to your app" → "platform / analysis" → + * "peripheral". Gaps between the weights are intentional: a kit can interleave + * its own categories (or override these) without editing this table. */ export const DEFAULT_CATEGORIES_ORDER: Record = { 'framework': -100, 'default': 0, 'app': 100, + 'ui': 150, + 'data': 250, 'web': 300, + 'performance': 350, 'advanced': 400, + 'docs': 500, '~builtin': 1000, } diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index ffd72c38..c898e871 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -50,15 +50,20 @@ export interface DevframeDocksActiveState { activation: DevframeDockActivation | null } -// Known categories the hub orders by default. Kits may pass their own -// category ids; `(string & {})` keeps autocomplete on the known set while +// Known categories the hub orders by default (see +// {@link import('../constants').DEFAULT_CATEGORIES_ORDER}). Kits may pass their +// own category ids; `(string & {})` keeps autocomplete on the known set while // allowing arbitrary string values. `~builtin` is reserved for the viewer's // own built-in views (see {@link DevframeViewBuiltin}) and always sorts last. export type DevframeDockEntryCategory - = | 'app' - | 'framework' - | 'web' - | 'advanced' + = | 'framework' // framework internals (Vue / Nuxt / Vite) + | 'app' // the user's own app tools + | 'ui' // components, design system, styling + | 'data' // state, storage, queries, database + | 'web' // network, platform, accessibility + | 'performance' // profiling, metrics, budgets + | 'advanced' // power-user / low-level tools + | 'docs' // documentation, references | 'default' | '~builtin' | (string & {})