Skip to content

Commit 39a5157

Browse files
committed
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.
1 parent 6ee61dd commit 39a5157

15 files changed

Lines changed: 127 additions & 6 deletions

File tree

docs/guide/client-context.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Boot the host once per page: a second boot replaces the published context and lo
5050
|----------|-------------|
5151
| `rpc` | The [RPC client](./client) — call server functions, register client-side functions, access shared state. |
5252
| `clientType` | `'embedded'` (runtime inside your app) or `'standalone'` (independent hub page). |
53-
| `docks` | Dock entries and selection — `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`. |
53+
| `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)). |
5454
| `panel` | Dock panel state: position, size, drag/resize flags. |
5555
| `commands` | The command palette: `register()`, `execute()`, `getKeybindings()`. |
5656
| `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. |

docs/guide/hub.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,21 @@ ctx.docks.register({
199199

200200
`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.
201201

202+
### The dual role of `category`
203+
204+
`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:
205+
206+
- **Ungrouped entry**`category` is the entry's outer dock-bar bucket, defaulting to `'default'`.
207+
- **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.
208+
209+
The group's `category` is therefore the single outer bucket shared by the group button and all its members. Fallbacks keep the rule total:
210+
211+
- A **group** with no `category` buckets itself and its members under `'default'`.
212+
- A grouped **member** with no `category` defaults its in-group sub-category to `'default'`.
213+
- **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.
214+
215+
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.
216+
202217
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.
203218

204219
## The protocol — what the UI sees

packages/hub/src/client/__tests__/host.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ function iframeEntry(id: string, extra?: Record<string, unknown>): DevframeDockE
6262
return { id, title: id, icon: 'ph:cube', type: 'iframe', url: `/__${id}/`, ...extra } as DevframeDockEntry
6363
}
6464

65+
function groupEntry(id: string, extra?: Record<string, unknown>): DevframeDockEntry {
66+
return { id, title: id, icon: 'ph:folder', type: 'group', ...extra } as DevframeDockEntry
67+
}
68+
6569
describe('createDevframeClientHost', () => {
6670
it('publishes the global client context with the full surface', async () => {
6771
const { rpc } = createStubRpc()
@@ -95,6 +99,46 @@ describe('createDevframeClientHost', () => {
9599
host.dispose()
96100
})
97101

102+
it('groups entries by category — grouped members bucket under their group, orphans by their own', async () => {
103+
const { rpc, states } = createStubRpc()
104+
const host = await createDevframeClientHost({ rpc })
105+
106+
states.get('devframe:docks')!.push([
107+
// (a) member with groupId + its own category → outer bucket = group's category ('framework'),
108+
// and its own 'app' category is reinterpreted as an in-group sub-category.
109+
groupEntry('nuxt', { category: 'framework' }),
110+
iframeEntry('nuxt:overview', { groupId: 'nuxt', category: 'app' }),
111+
// (b) group with no category → members bucket to 'default'.
112+
groupEntry('misc'),
113+
iframeEntry('misc:one', { groupId: 'misc', category: 'web' }),
114+
// (c) orphan member (groupId with no registered group) → its own category ('web').
115+
iframeEntry('orphan', { groupId: 'ghost', category: 'web' }),
116+
// plain ungrouped entry keeps its own category.
117+
iframeEntry('plain', { category: 'app' }),
118+
])
119+
120+
const grouped = Object.fromEntries(
121+
host.context.docks.groupedEntries.map(([cat, entries]) => [cat, entries.map(e => e.id)]),
122+
)
123+
124+
// (a) grouped member lands under the group's category, alongside the group button.
125+
expect(grouped.framework).toEqual(['nuxt', 'nuxt:overview'])
126+
// (b) group without a category, and its member, bucket to 'default'.
127+
expect(grouped.default).toEqual(['misc', 'misc:one'])
128+
// (c) orphan + plain keep their own categories.
129+
expect(grouped.web).toEqual(['orphan'])
130+
expect(grouped.app).toEqual(['plain'])
131+
132+
// Categories sort by DEFAULT_CATEGORIES_ORDER — framework first.
133+
expect(host.context.docks.groupedEntries.map(([cat]) => cat)).toEqual([
134+
'framework',
135+
'default',
136+
'app',
137+
'web',
138+
])
139+
host.dispose()
140+
})
141+
98142
it('switches entries with activation/deactivation events and when-context updates', async () => {
99143
const { rpc, states } = createStubRpc()
100144
const host = await createDevframeClientHost({ rpc })

packages/hub/src/client/host.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,9 +419,22 @@ function createPanelContext(clientType: DockClientType): DocksPanelContext {
419419
}
420420

421421
function groupByCategory(entries: DevframeDockEntry[]): DevframeDockEntriesGrouped {
422+
// Index registered groups so a member whose `groupId` resolves takes its
423+
// OUTER bucket from the group's category, not its own (which becomes the
424+
// member's in-group sub-category). Orphan members — a `groupId` with no
425+
// registered group — fall back to their own category.
426+
const groupsById = new Map<string, DevframeDockEntry>()
427+
for (const entry of entries) {
428+
if (entry.type === 'group')
429+
groupsById.set(entry.id, entry)
430+
}
431+
422432
const groups = new Map<string, DevframeDockEntry[]>()
423433
for (const entry of entries) {
424-
const category = entry.category ?? 'default'
434+
const resolvedGroup = entry.groupId ? groupsById.get(entry.groupId) : undefined
435+
const category = resolvedGroup
436+
? resolvedGroup.category ?? 'default'
437+
: entry.category ?? 'default'
425438
let list = groups.get(category)
426439
if (!list) {
427440
list = []

packages/hub/src/client/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Re-export the category-order table so client-side viewers can import the
2+
// single source of truth from `@devframes/hub/client`.
3+
export { DEFAULT_CATEGORIES_ORDER } from '../constants'
14
export * from './client-script'
25
export * from './context'
36
export * from './docks'

packages/hub/src/constants.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@ import type { DevframeDocksUserSettings } from './types/settings'
22

33
export * from 'devframe/constants'
44

5+
/**
6+
* The default ordering weight for each known dock category — lower sorts
7+
* earlier. Downstream viewers (e.g. `@vitejs/devtools-kit`) import this as the
8+
* single source of truth so the hub and its viewers agree on category order.
9+
* `framework` sorts first; `~builtin` (the viewer's own built-in views) last.
10+
*/
511
export const DEFAULT_CATEGORIES_ORDER: Record<string, number> = {
12+
'framework': -100,
613
'default': 0,
714
'app': 100,
8-
'framework': 200,
915
'web': 300,
1016
'advanced': 400,
1117
'~builtin': 1000,

packages/hub/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export { DEFAULT_CATEGORIES_ORDER } from './constants'
12
export * from './define'
23
export type * from './types'

packages/hub/src/node/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Re-export the category-order table so node-side consumers can import the
2+
// single source of truth from `@devframes/hub/node`.
3+
export { DEFAULT_CATEGORIES_ORDER } from '../constants'
14
export * from './context'
25
export * from './host-commands'
36
export * from './host-docks'

packages/hub/src/types/docks.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,19 @@ export interface DevframeDockEntryBase {
7676
*/
7777
defaultOrder?: number
7878
/**
79-
* The category of the entry
79+
* The category of the entry — a field with a dual role that depends on
80+
* whether {@link groupId} resolves to a registered {@link DevframeViewGroup}:
81+
*
82+
* - **Ungrouped (or orphan) entry** — `category` is the entry's OUTER bucket
83+
* on the dock bar, ordered by {@link import('../constants').DEFAULT_CATEGORIES_ORDER}.
84+
* - **Grouped entry** (a `groupId` that resolves to a registered group) —
85+
* the OUTER bucket is instead the group's own `category`, and this field is
86+
* reinterpreted as the entry's IN-GROUP sub-category, used to sub-divide and
87+
* sort members inside the group's popover / sub-navigation.
88+
*
89+
* Falls back to `'default'` when omitted — both as an outer bucket and, for a
90+
* grouped member, as its in-group sub-bucket.
91+
*
8092
* @default 'default'
8193
*/
8294
category?: DevframeDockEntryCategory
@@ -102,8 +114,14 @@ export interface DevframeDockEntryBase {
102114
*
103115
* This is a flat pointer — membership, not containment. The entry stays an
104116
* independently-registered, top-level entry; only its rendering is grouped
105-
* downstream. If the referenced group is never registered, the entry renders
106-
* as a normal top-level entry (orphan tolerance).
117+
* downstream.
118+
*
119+
* When the referenced group **is** registered, it supplies the entry's OUTER
120+
* dock-bar category (the group's own {@link category}), and this entry's own
121+
* {@link category} is reinterpreted as its IN-GROUP sub-category. When the
122+
* referenced group is **never** registered, the entry renders as a normal
123+
* top-level entry and falls back to using its own {@link category} as the
124+
* outer bucket (orphan tolerance).
107125
*
108126
* @see {@link DevframeViewGroup}
109127
*/
@@ -277,6 +295,12 @@ export interface DevframeViewJsonRender extends DevframeDockEntryBase {
277295
* through the same `register`/`update`/`values` machinery as every other entry,
278296
* keyed by `id`.
279297
*
298+
* The group's `category` is the OUTER bucket for the group button itself AND
299+
* for every one of its members — a member's own `category` no longer decides
300+
* its outer bucket, but is reinterpreted as an in-group sub-category that
301+
* sub-divides and sorts members inside this group. A group with no `category`
302+
* buckets itself and its members under `'default'`.
303+
*
280304
* Grouping is one level deep: a group entry must not itself set `groupId`.
281305
*/
282306
export interface DevframeViewGroup extends DevframeDockEntryBase {

tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ export * from "devframe/client";
130130
// #endregion
131131

132132
// #region Other
133+
export { DEFAULT_CATEGORIES_ORDER }
133134
export { DevframeClientRpcHost }
134135
export { RpcClientEvents }
135136
// #endregion

0 commit comments

Comments
 (0)