diff --git a/src/client/icon.tsx b/src/client/icon.tsx new file mode 100644 index 0000000..3898e35 --- /dev/null +++ b/src/client/icon.tsx @@ -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 = [ + ['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 ( + + ) +} + +/** + * 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 ( + <> + + {t('tab')} + + ) + } +} diff --git a/src/client/sidebar.ts b/src/client/sidebar.ts index 5cf8c1e..72c0f29 100644 --- a/src/client/sidebar.ts +++ b/src/client/sidebar.ts @@ -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) => 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() + } }) } diff --git a/src/client/styles/base.css b/src/client/styles/base.css index 4533155..b50818f 100644 --- a/src/client/styles/base.css +++ b/src/client/styles/base.css @@ -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 diff --git a/tests/baselines.ts b/tests/baselines.ts index 42d8568..4669215 100644 --- a/tests/baselines.ts +++ b/tests/baselines.ts @@ -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: [ diff --git a/tests/client/compat/clientFaces.spec.ts b/tests/client/compat/clientFaces.spec.ts index 06d5eb3..0acaabf 100644 --- a/tests/client/compat/clientFaces.spec.ts +++ b/tests/client/compat/clientFaces.spec.ts @@ -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() }) diff --git a/tests/client/icon.spec.ts b/tests/client/icon.spec.ts new file mode 100644 index 0000000..7f179aa --- /dev/null +++ b/tests/client/icon.spec.ts @@ -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(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(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(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(m.container, 'svg').getAttribute('width'), '16') + const label = query(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() + }) +}) diff --git a/tests/client/sidebar-resilience.spec.ts b/tests/client/sidebar-resilience.spec.ts deleted file mode 100644 index d3a9ffe..0000000 --- a/tests/client/sidebar-resilience.spec.ts +++ /dev/null @@ -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() - }) -}) diff --git a/tests/client/sidebar.spec.ts b/tests/client/sidebar.spec.ts index 60b389f..c8fef13 100644 --- a/tests/client/sidebar.spec.ts +++ b/tests/client/sidebar.spec.ts @@ -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(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') }) }) diff --git a/tests/compat/matrix.spec.ts b/tests/compat/matrix.spec.ts index b0c7a76..8be2ad6 100644 --- a/tests/compat/matrix.spec.ts +++ b/tests/compat/matrix.spec.ts @@ -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)', () => {