diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index 7b4ad52..00e1d17 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -47,6 +47,13 @@ export interface ContextBrowserProps { previewSeq?: number | null /** Pin-seq: a pin selects that step; pinSeq null returns the browser to the live surface. */ pinSeq?: number | null + /** + * DNA mode's on/off, LINKED across cards: the Context tab passes its trend card's toggle state + * so both DNA switches move as one. Controlled only when BOTH props arrive; absent (the + * /context modal) — the browser keeps its own mount-local toggle. + */ + dna?: boolean + onDnaChange?: (on: boolean) => void /** * One-shot reveal request (the step brief's locate, a trend-DNA band pick): select the step, open the * item's category and element, scroll it into view. `key` is the band key both DNA surfaces share — @@ -681,8 +688,20 @@ export function makeContextBrowser( // Mount-time default from the plugin settings card; in-toolbar toggling // stays mount-local and never writes back. const [toolSort, setToolSort] = useState(() => settings.defaultToolSort()) - // DNA mode: the composition bar redraws as ONE band per context item in prompt order (dna.ts), hovered/clicked per item. - const [dna, setDna] = useState(false) + // DNA mode: the composition bar redraws as ONE band per context item in prompt order (dna.ts), hovered/clicked per + // item. Parent-linked when both props arrive — the Context tab's trend toggle moves this one too; a lone + // prop is ignored — otherwise mount-local (the /context modal). + const [dnaLocal, setDnaLocal] = useState(false) + const dnaLinked = props.dna !== undefined && props.onDnaChange !== undefined + const dna = dnaLinked ? props.dna === true : dnaLocal + const setDna = (on: boolean): void => { + if (dnaLinked) { + /* v8 ignore next 1 -- `dnaLinked` requires both props, so the handler is always present. */ + props.onDnaChange?.(on) + } else { + setDnaLocal(on) + } + } const [dnaKey, setDnaKey] = useState(null) // δ baseline toggle: 'step' diffs against the immediately preceding record, 'turn' against the // previous turn's last step. Mount default from the plugin settings card; in-toolbar toggling @@ -1229,7 +1248,7 @@ export function makeContextBrowser( diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index d35f385..1a78fa7 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -111,8 +111,9 @@ export function makeContextView( // like granularity, the default is read at mount and in-chart toggling never writes back. const [trendMode, setTrendMode] = useState<'total' | 'delta'>(() => settings.defaultTrendMode()) // DNA mode: the trend bars become per-item fingerprints of each request's context (dna.ts); like the - // toggles above, mount-local and never written back. Orthogonal to the Total/Delta switch — delta - // diffs the bands against the previous bar, so the two combine into a per-item change view. + // toggles above, mount-local and never written back — and SHARED with the Context browser, whose DNA + // toggle rides this same state so both switches move as one. Orthogonal to the Total/Delta switch — + // delta diffs the bands against the previous bar, so the two combine into a per-item change view. const [dna, setDna] = useState(false) // Adaptive scale (the title-adjacent toggle): the trend bars rescale to the visible window; like the two // toggles above, mount-local and never written back. @@ -566,6 +567,9 @@ export function makeContextView( loadImage={loadImage} detailState={source.detailState} onDetailRetry={source.retryDetail} + // The DNA switch is shared with the trend card: both toggles move as one. + dna={dna} + onDnaChange={setDna} /> ) diff --git a/tests/client/components/browser.spec.ts b/tests/client/components/browser.spec.ts index ce85a81..48f7fc1 100644 --- a/tests/client/components/browser.spec.ts +++ b/tests/client/components/browser.spec.ts @@ -1987,8 +1987,27 @@ describe('ContextBrowser DNA mode and the open-category bar pin', () => { return queryAll(m.container, '.lc-stacked-seg') } - test('the toggle redraws the composition bar as one band per item, in prompt order', async () => { - const m = await mount(h(Browser, props({ data: dnaData, headers: dnaHeaders }))) + test('the DNA toggle is parent-linked when both props arrive; a lone prop is ignored', async () => { + // Linked on: the bar redraws and the button reflects the parent's state without a click. + const m = await mount(h(Browser, props({ data: dnaData, headers: dnaHeaders, dna: true, onDnaChange: () => {} }))) + assert.equal(bands(m).length, 7) + assert.ok(dnaButton(m).className.includes('lc-gran-on')) + // A lone `dna` prop without the handler does NOT link: the mount-local toggle still rules. + await m.update(h(Browser, props({ data: dnaData, headers: dnaHeaders, dna: true }))) + assert.equal(bands(m).length, 6, 'unlinked: the local toggle is off') + // The click reports to the parent instead of flipping local state; the bar follows the prop. + const calls: boolean[] = [] + await m.update(h(Browser, props({ + data: dnaData, headers: dnaHeaders, dna: true, + onDnaChange: (on) => { calls.push(on) }, + }))) + await click(dnaButton(m)) + assert.deepEqual(calls, [false]) + assert.equal(bands(m).length, 7, 'still on until the parent re-renders the prop') + await m.unmount() + }) + + test('the toggle redraws the composition bar as one band per item, in prompt order', async () => { const m = await mount(h(Browser, props({ data: dnaData, headers: dnaHeaders }))) // Category mode: one segment per category in CATS order, pick cursor off, tooltip slot unmounted. assert.equal(bands(m).length, 6) const normal = bands(m).map(seg => seg.style.width) diff --git a/tests/client/components/contextView.spec.ts b/tests/client/components/contextView.spec.ts index 806a85b..cb0c8f5 100644 --- a/tests/client/components/contextView.spec.ts +++ b/tests/client/components/contextView.spec.ts @@ -532,8 +532,26 @@ describe('ContextView — interactions', () => { await m.unmount() }) - test('clicking a DNA band reveals that item in the Context browser', async () => { - const m = await mountRich('sv-dna-reveal') + test('the trend and browser DNA toggles move as one', async () => { + const m = await mountRich('sv-dna-link') + const trendDna = () => query(m.container, '.lc-trend-dna .lc-gran-btn') + const browserDna = () => query(m.container, '.lc-br-dna-ctl .lc-gran-btn') + assert.ok(!trendDna().className.includes('lc-gran-on') && !browserDna().className.includes('lc-gran-on'), 'both off at mount') + + // The browser follows the trend toggle — its composition bar redraws as a fingerprint too. + await click(trendDna()) + assert.ok(browserDna().className.includes('lc-gran-on'), 'the browser follows the trend toggle') + assert.equal(queryAll(m.container, '.lc-br-bar-dna').length, 1) + + // And the trend follows the browser toggle — the chart's strips drop away. + await click(browserDna()) + assert.ok(!trendDna().className.includes('lc-gran-on'), 'the trend follows the browser toggle') + assert.equal(queryAll(m.container, '.lc-bar-dna').length, 0) + assert.equal(queryAll(m.container, '.lc-br-bar-dna').length, 0) + await m.unmount() + }) + + test('clicking a DNA band reveals that item in the Context browser', async () => { const m = await mountRich('sv-dna-reveal') await click(buttonByText(m.container, DICT_EN['trend.dna'])) // Give the strip a real 112px stack area, then click its very top: the hit is the LAST band of // bar seq 4 — the 'file output' tool result (system and the header lead the strip below it).