diff --git a/docs/guide/hub.md b/docs/guide/hub.md index 95cc8e0..cda6ef7 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -199,8 +199,42 @@ 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. +#### 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/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index 648a9f1..db214ab 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 d2ff89c..7abd376 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -479,9 +479,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 2c91c62..020912a 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 474884b..f49665e 100644 --- a/packages/hub/src/constants.ts +++ b/packages/hub/src/constants.ts @@ -2,12 +2,26 @@ 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. + * + * 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, - 'framework': 200, + 'ui': 150, + 'data': 250, 'web': 300, + 'performance': 350, 'advanced': 400, + 'docs': 500, '~builtin': 1000, } diff --git a/packages/hub/src/index.ts b/packages/hub/src/index.ts index fca59c2..ddcfb09 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 c5160de..8e4678d 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 fba10e0..c898e87 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 & {}) @@ -76,7 +81,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 +119,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 +300,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 aaf444c..334ca62 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -136,6 +136,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 79c9f6e..0331cf7 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 5ae4bb6..32d68dd 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: