feat(client): brand the sidebar Context tab with the document emblem

Bundle the colourful document sheet as the plugin's own icon and fill the
right-Sidebar tab type's two glyph seats with it, the way the shipped files
type does: the guide capsule's `icon`, and a new `sidebar.right.pane.tab.title`
registration that draws the emblem beside the chip label.

The chip's close control sits out of flow over the title's last 14px and an
active chip fades the 16px before it, so the shipped files chip stays readable
only by its short label. The label now carries a trailing gutter so that fade
lands past the text instead of on the last glyphs.

Registration turns rollback-safe (each disposer is tracked) so a partial
failure unwinds cleanly, and the obsolete primitives-icon resilience spec goes
with the icon read it guarded.
This commit is contained in:
bowenliang123 committed 2026-09-10 17:32:40 +08:00
1 parent 40bb97c563
commit 8212526d6c
9 files changed
+229 -73

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
/**
* The Context emblem: the colourful document sheet, bundled rather than read
* off the harness primitives so the plugin's identity is self-contained.
*
* It fills the right-Sidebar tab type's two glyph seats, exactly as the shipped
* files type fills its own: the guide capsule before the tab is open
* (`SidebarGuideEntry.icon`) and the chip title beside the label once it is
* (`sidebar.right.pane.tab.title`). Fixed fills, never `currentColor` — the
* sheet is deliberately polychrome on both light and dark chrome.
*/
import type { ReactElement } from 'react'
import type { Translate } from './i18n'
/** The sheet's strokes, in paint order, as `[path, fill]` pairs. */
const SHEET_PATHS: ReadonlyArray<readonly [string, string]> = [
['M899.984 19.873h-3.452c-26.123 0-47.296 21.172-47.296 47.296v888.508c0 26.127 21.173 47.298 47.296 47.298h3.452c26.119 0 47.297-21.171 47.297-47.298V67.169c0-26.124-21.177-47.296-47.297-47.296z', '#4A5699'],
['M132.643 19.873h-3.449c-26.12 0-47.296 21.172-47.296 47.296v888.508c0 26.127 21.177 47.298 47.296 47.298h3.449c26.123 0 47.299-21.171 47.299-47.298V67.169c0-26.124-21.176-47.296-47.299-47.296z', '#C45FA0'],
['M899.463 19.873H129.194c-26.12 0-47.296 21.172-47.296 47.296v3.377c0 26.12 21.177 47.299 47.296 47.299h770.269c26.123 0 47.296-21.179 47.296-47.299v-3.377c0-26.124-21.173-47.296-47.296-47.296z', '#6277BA'],
['M899.463 905.006H129.194c-26.12 0-47.296 21.17-47.296 47.29v3.381c0 26.127 21.177 47.298 47.296 47.298h770.269c26.123 0 47.296-21.171 47.296-47.298v-3.381c0-26.12-21.173-47.29-47.296-47.29z', '#C45FA0'],
['M717.962 543.153H542.047c-26.121 0-47.298 21.175-47.298 47.297v3.724c0 26.123 21.177 47.293 47.298 47.293h175.915c26.121 0 47.297-21.17 47.297-47.293v-3.724c0-26.122-21.176-47.297-47.297-47.297z', '#E5594F'],
['M689.268 198.849H513.355c-26.122 0-47.298 21.175-47.298 47.297v3.722c0 26.12 21.176 47.297 47.298 47.297h175.912c26.122 0 47.298-21.177 47.298-47.297v-3.722c0-26.122-21.175-47.297-47.297-47.297z', '#F0D043'],
['M757.789 353.081H261.17c-26.121 0-47.297 21.172-47.297 47.296v3.377c0 26.121 21.177 47.299 47.297 47.299h496.619c26.121 0 47.296-21.178 47.296-47.299v-3.377c0-26.125-21.175-47.296-47.296-47.296z', '#E5594F'],
['M762.638 726.225h-496.62c-26.12 0-47.294 21.18-47.294 47.301v3.377c0 26.12 21.174 47.3 47.294 47.3h496.62c26.122 0 47.296-21.18 47.296-47.3v-3.377c0-26.122-21.174-47.301-47.296-47.301z', '#6277BA'],
['M355.734 543.328H281.41c-26.122 0-47.297 21.17-47.297 47.293v3.378c0 26.118 21.175 47.297 47.297 47.297h74.324c26.123 0 47.296-21.179 47.296-47.297v-3.378c0-26.123-21.174-47.293-47.296-47.293z', '#F39A2B'],
['M334.85 248.006m-48.986 0a48.986 48.986 0 1 0 97.972 0 48.986 48.986 0 1 0-97.972 0Z', '#F39A2B'],
]
/** The emblem's props, matching the harness `IconProps` the guide capsule hands it. */
export interface ContextIconProps {
/** Square edge in px. */
size?: number
/** Extra class for layout placement. */
className?: string
}
/** The colourful document sheet at the requested square edge. */
export function ContextIcon({ size = 20, className }: ContextIconProps): ReactElement {
return (
<svg
width={size}
height={size}
viewBox="0 0 1024 1024"
className={className}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
>
{SHEET_PATHS.map(([d, fill]) => <path key={d} d={d} fill={fill} />)}
</svg>
)
}
/**
* The tab chip's title seat (`sidebar.right.pane.tab.title`): the emblem before
* the label, so the chip reads as the files chip does. The label comes from the
* plugin's own bound translate — read at render, so the chip follows the active
* locale — rather than the tab-information hook, which a foreign or
* not-yet-committed tab record can throw on. It carries a trailing gutter
* (`.lc-title-label`) so the active chip's fade lands past the text, never on
* the last glyphs.
* @param t - the plugin-namespace translate bound in `apply`.
* @returns the title component to register under the tab type's id.
*/
export function makeContextTabTitle(t: Translate): () => ReactElement {
return function ContextTabTitle(): ReactElement {
return (
<>
<ContextIcon size={16} className="lc-title-icon" />
<span className="lc-title-label">{t('tab')}</span>
</>
)
}
}
+30 -31
View File
@@ -8,7 +8,8 @@
* one component with one data path. The tab type contributes a guide entry, so
* the sidebar's guide page offers "Context" and picking it opens the panel —
* the product's own path, exactly as the shipped Files type does: a capsule of
* glyph, title, and description line.
* glyph, title, and description line, plus the chip-title seat that puts the
* same glyph beside the label once the tab is open (`icon.tsx`).
*
* OPTIONAL BY CONTRACT. `ctx.sidebarRightTabs` and the seat ship only on the
* 0.1.5 line (0.1.5-rc.1+ supported); the registration therefore rides a
@@ -22,8 +23,8 @@
* @module dsh-context/client/sidebar
*/
import { IconContextInjectionOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ClientCtx, ContextViewProps, SidebarGuideEntryLike, SidebarTabsFace } from './services'
import { ContextIcon, makeContextTabTitle } from './icon'
import type { ClientCtx, ContextViewProps, SidebarTabsFace } from './services'
import type { Translate } from './i18n'
/** The tab type's identity in the sidebar's tab system (also its body-seat key). */
@@ -40,23 +41,8 @@ export const SIDEBAR_CONTEXT_KIND = 'dsh-context'
const GUIDE_ORDER = 20
/**
* The guide capsule's glyph, read defensively: the icon is OPTIONAL in the
* entry, and a primitives module that does not serve it — or whose namespace
* THROWS on the read (an interop/mock shape does exactly that) — must cost the
* glyph, never the whole tab.
* @returns the icon component, or undefined to register the tab without one.
*/
function guideIcon(): SidebarGuideEntryLike['icon'] {
try {
return IconContextInjectionOutline16
} catch {
return undefined
}
}
/**
* Register the Context tab type and its body on the right Sidebar, if — and
* only if — this harness serves the sidebar tab registry.
* Register the Context tab type, its body, and its chip title on the right
* Sidebar, if — and only if — this harness serves the sidebar tab registry.
* @param ctx - client root context carrying `slots` and the locale service.
* @param view - the Context view component factory result (the same one the
* conversation tab mounts).
@@ -75,10 +61,16 @@ export function watchSidebarContextTab(
// plugin is simply a conversation tab there — no pending fiber, no error.
ctx.inject(['sidebarRightTabs'], (raw) => {
const injected = raw as unknown as ClientCtx & { sidebarRightTabs?: SidebarTabsFace }
// Every registration that already landed, so a partial failure below
// unwinds exactly what this callback owns.
const disposers: (() => void)[] = []
const own = (result: unknown): void => {
if (typeof result === 'function') disposers.push(result as () => void)
}
try {
const tabs = injected.sidebarRightTabs
if (tabs === undefined || typeof tabs.register !== 'function') return
const disposeType = tabs.register({
own(tabs.register({
id: SIDEBAR_CONTEXT_ID,
kind: SIDEBAR_CONTEXT_KIND,
title: () => t('tab'),
@@ -86,23 +78,30 @@ export function watchSidebarContextTab(
order: GUIDE_ORDER,
title: () => t('tab'),
description: () => t('sidebar.guideDescription'),
icon: guideIcon(),
icon: ContextIcon,
}],
})
const disposeBody = injected.slots.inject('sidebar.right.pane.tab', () => injected.slots.register(
}))
own(injected.slots.inject('sidebar.right.pane.tab', () => injected.slots.register(
{ name: 'sidebar.right.pane.tab', key: SIDEBAR_CONTEXT_ID, locale: ns },
(props: { sessionId?: string } & Record<string, unknown>) => view({ ...props, host: 'sidebar' }),
))
// The inject callback's own disposer owns both registrations: cordis
// unloads them with the injected fiber (plugin stop, HMR reload).
return () => {
disposeType()
if (typeof disposeBody === 'function') (disposeBody as () => void)()
}
)))
// The chip title seat (0.1.5-rc.1+): the emblem beside the label, the
// files type's own idiom. Registered under the type id so the kit
// dispatches it for this type's tabs only.
own(injected.slots.inject('sidebar.right.pane.tab.title', () => injected.slots.register(
{ name: 'sidebar.right.pane.tab.title', key: SIDEBAR_CONTEXT_ID },
makeContextTabTitle(t),
)))
} catch {
// A foreign registry that throws on register leaves the sidebar without
// this tab; the conversation tab and every other seat keep working.
for (const dispose of disposers) dispose()
return undefined
}
// The inject callback's own disposer owns every registration: cordis
// unloads them with the injected fiber (plugin stop, HMR reload).
return () => {
for (const dispose of disposers) dispose()
}
})
}
+13
View File
@@ -19,6 +19,19 @@
*/
[data-sidebar-right-open] .lc-root,
[data-sidebar-right-float-host] .lc-root { height: auto; overflow: visible; padding: 12px; }
/* The Context tab chip's emblem: held at its drawn size in the title's flex
line (dockkit's `.tabTitle`; the files chip's own titleIcon idiom). */
.lc-title-icon { flex: none; }
/*
The chip's close control is OUT of flow over the title's last 14px, and an
active/hovered chip fades the 16px before it (dockkit `.tabTitle`'s mask) so
the text dies away under the close. The shipped files chip stays readable
only because its short label ends before that zone; our icon plus a longer
label runs into it. This gutter makes the fade land past the text: 30px is
the mask's own lead-in (`calc(100% - 30px)`), the one dockkit chrome
constant this touches — re-check it if that mask ever moves.
*/
.lc-title-label { padding-right: 30px; }
/*
Border roles: l2 (0.1 black ≈ #E5E5E5 on white) draws the VISIBLE edges —
cards and controls, matching dsh's own Button/Input chrome; l1 stays for
+7
View File
@@ -57,6 +57,12 @@ export interface ClientSeam {
/** The keyed body seat's declaring source. */
slotFile: string
slotNeedle: string
/**
* The keyed chip-title seat the plugin's tab type also registers into
* (the emblem-beside-label idiom the shipped files type uses). Present
* wherever the body seat is; probed separately so a rename names the seam.
*/
titleSlotNeedle: string
/**
* The guide-entry contract the plugin's contribution must satisfy: one
* needle per field `SidebarRightGuideEntry` carries on this generation.
@@ -224,6 +230,7 @@ export const BASELINES: readonly Baseline[] = [
serviceNeedle: 'sidebarRightTabs',
slotFile: 'packages/client/ui-sidebar-right/src/client/contract/slots.ts',
slotNeedle: 'sidebar.right.pane.tab',
titleSlotNeedle: 'sidebar.right.pane.tab.title',
guideEntry: {
file: 'packages/client/ui-sidebar-right/src/client/tab-registry.ts',
fields: [
+1 -3
View File
@@ -39,9 +39,6 @@ vi.mock('@deepseek-ai/dsh-client-ui-primitives', async () => {
captured.markdownProps = props
return React.createElement('div', null, String(props.text ?? ''))
},
// The sidebar guide glyph: present as a stub so the optional registration
// path sees the same shape the real primitives module serves.
IconContextInjectionOutline16: () => React.createElement('span', null),
// The rich-text copy control's glyphs and clipboard writer.
IconCopyOutline16: () => React.createElement('span', null),
IconCheckOutline16: () => React.createElement('span', null),
@@ -157,6 +154,7 @@ for (const baseline of BASELINES) {
})
assert.equal(definitions.length, 1, 'the generation with the seam gets the tab')
assert.equal(ctx.slots.of('sidebar.right.pane.tab').length, 1)
assert.equal(ctx.slots.of('sidebar.right.pane.tab.title').length, 1, 'the chip-title seat is registered on this generation')
}
ctx.dispose()
})
+57
View File
@@ -0,0 +1,57 @@
// The Context emblem (src/client/icon.tsx): the bundled polychrome document
// sheet that fills the right-Sidebar tab type's two glyph seats — the guide
// capsule and the chip title. Bundled rather than read off the harness
// primitives, so these specs render the real component.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { ContextIcon, makeContextTabTitle } from '../../src/client/icon'
import { makeKit, mount, query, queryAll, text } from './helpers/kit'
describe('ContextIcon', () => {
test('draws the colourful sheet at the requested edge', async () => {
const m = await mount(h(ContextIcon, { size: 16, className: 'lc-title-icon' }))
const svg = query<SVGSVGElement>(m.container, 'svg')
assert.equal(svg.getAttribute('width'), '16')
assert.equal(svg.getAttribute('height'), '16')
assert.equal(svg.getAttribute('viewBox'), '0 0 1024 1024')
assert.equal(svg.getAttribute('class'), 'lc-title-icon')
assert.equal(svg.getAttribute('aria-hidden'), 'true')
// Every stroke carries its own fixed fill — the sheet stays polychrome.
const paths = queryAll<SVGPathElement>(m.container, 'path')
assert.equal(paths.length, 10)
const fills = paths.map(p => p.getAttribute('fill'))
assert.ok(fills.every(f => typeof f === 'string' && f.startsWith('#')))
assert.equal(new Set(fills).size, 6, 'the six palette colours all appear')
await m.unmount()
})
test('defaults to a size and can drop the class', async () => {
const m = await mount(h(ContextIcon, {}))
const svg = query<SVGSVGElement>(m.container, 'svg')
assert.equal(svg.getAttribute('width'), '20')
assert.equal(svg.getAttribute('height'), '20')
assert.equal(svg.getAttribute('class'), null)
await m.unmount()
})
})
describe('makeContextTabTitle — the chip-title seat', () => {
test('renders the emblem beside the plugin label in the active locale', async () => {
const { t } = makeKit()
const Title = makeContextTabTitle(t)
const m = await mount(h(Title))
assert.equal(query<SVGSVGElement>(m.container, 'svg').getAttribute('width'), '16')
const label = query<HTMLSpanElement>(m.container, '.lc-title-label')
assert.equal(text(label), 'Context')
await m.unmount()
})
test('follows the bound translate at render (zh label)', async () => {
const Title = makeContextTabTitle(makeKit('zh').t)
const m = await mount(h(Title))
assert.equal(text(m.container), '上下文')
await m.unmount()
})
})
-34
View File
@@ -1,34 +0,0 @@
// The right Sidebar tab against a PARTIAL primitives module (src/client/sidebar.ts):
// the guide glyph is optional, and a namespace that throws on the icon read —
// vitest's ESM mock proxy does exactly that for an unserved export — must cost
// the glyph, never the whole tab. This is the client-side half of the
// "a missing capability degrades, it never breaks the view" contract.
import { describe, test, vi } from 'vitest'
import assert from 'node:assert/strict'
import { TestClientCtx, asClientCtx } from './helpers/harness'
// No `IconContextInjectionOutline16` export: the read throws.
vi.mock('@deepseek-ai/dsh-client-ui-primitives', () => ({}))
const { watchSidebarContextTab } = await import('../../src/client/sidebar')
type SidebarTabDefinitionLike = import('../../src/client/services').SidebarTabDefinitionLike
describe('watchSidebarContextTab — partial primitives module', () => {
test('the tab still registers, without a guide glyph', () => {
const ctx = new TestClientCtx()
const definitions: SidebarTabDefinitionLike[] = []
ctx.setService('sidebarRightTabs', {
register: (definition: SidebarTabDefinitionLike) => {
definitions.push(definition)
return () => {}
},
})
watchSidebarContextTab(asClientCtx(ctx), () => 'view', key => key, 'dsh-context')
assert.equal(definitions.length, 1, 'the missing icon did not cost the tab')
assert.equal(definitions[0].guide?.length, 1)
assert.equal(definitions[0].guide?.[0].icon, undefined, 'no glyph, still a guide entry')
assert.equal(ctx.slots.of('sidebar.right.pane.tab').length, 1, 'the body seat registered too')
ctx.dispose()
})
})
+47 -5
View File
@@ -1,8 +1,10 @@
// The right Sidebar's Context tab (src/client/sidebar.ts): the OPTIONAL
// two-stage registration — tab type into `sidebarRightTabs`, body into the
// keyed `sidebar.right.pane.tab` seat — and its silent degrade on every
// harness that serves no right Sidebar (the older supported lines).
// three-stage registration — tab type into `sidebarRightTabs`, body into the
// keyed `sidebar.right.pane.tab` seat, chip title into
// `sidebar.right.pane.tab.title` — and its silent degrade on every harness
// that serves no right Sidebar (the older supported lines).
import { createElement as h, type ReactElement } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { DICT_EN, DICT_ZH } from '../../src/client/i18n'
@@ -13,6 +15,7 @@ import {
} from '../../src/client/sidebar'
import type { ContextViewProps, SidebarTabDefinitionLike } from '../../src/client/services'
import { TestClientCtx, asClientCtx } from './helpers/harness'
import { mount, query, text } from './helpers/kit'
const NS = 'dsh-context'
@@ -72,12 +75,29 @@ describe('watchSidebarContextTab — the optional registration', () => {
['description', 'icon', 'order', 'title'],
'the capsule carries exactly the rc.1 guide-entry fields',
)
assert.equal(typeof def.guide?.[0].icon, 'function', 'the guide capsule carries a glyph')
assert.equal(typeof def.guide?.[0].icon, 'function', 'the guide capsule carries the bundled emblem')
const body = ctx.slots.of('sidebar.right.pane.tab')
assert.equal(body.length, 1)
assert.equal(body[0].registration.key, SIDEBAR_CONTEXT_ID, 'the keyed seat dispatches on the type id')
assert.equal(body[0].registration.locale, NS, 'the framework synthesizes the t seat for the panel')
const title = ctx.slots.of('sidebar.right.pane.tab.title')
assert.equal(title.length, 1, 'the chip-title seat is registered for the type')
assert.equal(title[0].registration.key, SIDEBAR_CONTEXT_ID)
ctx.dispose()
})
test('the chip title renders the emblem and the label', async () => {
const ctx = new TestClientCtx()
const tabs = registry()
ctx.setService('sidebarRightTabs', tabs)
wire(ctx)
const Title = ctx.slots.of('sidebar.right.pane.tab.title')[0].component
const m = await mount(h(Title as () => ReactElement))
assert.equal(query<SVGSVGElement>(m.container, 'svg').getAttribute('width'), '16')
assert.equal(text(m.container), 'Context')
await m.unmount()
ctx.dispose()
})
@@ -123,6 +143,26 @@ describe('watchSidebarContextTab — the optional registration', () => {
ctx.dispose()
})
test('a failure mid-registration unwinds the seats that already landed', () => {
const ctx = new TestClientCtx()
const tabs = registry()
ctx.setService('sidebarRightTabs', tabs)
// The slots face accepts the body seat then throws on the title seat: the
// type and body must be rolled back, never half-registered.
let calls = 0
;(ctx as unknown as { slots: unknown }).slots = {
inject: (_name: string, callback: () => unknown) => {
calls += 1
if (calls === 2) throw new Error('title seat unavailable')
return callback()
},
register: () => () => {},
}
assert.doesNotThrow(() => { wire(ctx) })
assert.equal(tabs.disposed, 1, 'the type registration was rolled back')
ctx.dispose()
})
test('a shapeless service (no register, a primitive) registers nothing', () => {
for (const service of [{}, 'registry', 7, null]) {
const ctx = new TestClientCtx()
@@ -146,14 +186,16 @@ describe('watchSidebarContextTab — the optional registration', () => {
assert.doesNotThrow(() => { ctx.dispose() })
})
test('unload disposes both registrations', () => {
test('unload disposes every registration', () => {
const ctx = new TestClientCtx()
const tabs = registry()
ctx.setService('sidebarRightTabs', tabs)
wire(ctx)
assert.equal(ctx.slots.of('sidebar.right.pane.tab').length, 1)
assert.equal(ctx.slots.of('sidebar.right.pane.tab.title').length, 1)
ctx.dispose()
assert.equal(tabs.disposed, 1, 'the type registration was disposed')
assert.deepEqual(ctx.slots.of('sidebar.right.pane.tab'), [], 'the body seat was disposed')
assert.deepEqual(ctx.slots.of('sidebar.right.pane.tab.title'), [], 'the chip-title seat was disposed')
})
})
+1
View File
@@ -146,6 +146,7 @@ describe.skipIf(reasons.length > 0)('compat matrix — real dsh sources per base
}
assert.equal(staging.dshHasString(baseline.tag, sidebar.serviceNeedle, sidebar.serviceFile), true, 'tab-type registry service')
assert.equal(staging.dshHasString(baseline.tag, sidebar.slotNeedle, sidebar.slotFile), true, 'keyed body seat')
assert.equal(staging.dshHasString(baseline.tag, sidebar.titleSlotNeedle, sidebar.slotFile), true, 'keyed chip-title seat')
})
test('client: the right Sidebar guide-entry contract (the contribution\'s shape)', () => {