diff --git a/.claude/skills/nexus-testing/refinement-log.md b/.claude/skills/nexus-testing/refinement-log.md index 23593c1d..a255721e 100644 --- a/.claude/skills/nexus-testing/refinement-log.md +++ b/.claude/skills/nexus-testing/refinement-log.md @@ -231,3 +231,7 @@ The plan asked for an in-app `obsidian eval` check of slider markup. It was sett ### 2026-10-14 — `plugin:reload … vault=` reloaded the focused vault Linux, four vaults open. `obsidian plugin:reload id=nexus vault="Nexus Redesign Test"` did not reload the named vault: its plugin instance's `lifecycleManager.getInitializationStatus().startTime` did not move, while the focused Synaptic-Labs vault's did. The first call printed `Reloaded: nexus`, later ones printed nothing; neither output says which vault. What worked and is provably targeted: one guarded `eval` in the named vault that asserts `app.vault.getName()`, then runs `app.plugins.disablePlugin('nexus').then(() => app.plugins.enablePlugin('nexus'))` into a `window` status object, polled separately; confirm the new instance by its startTime. Here `vault=` came after the command, the shape step 3 of `live-loop.md` shows; the 2026-10-13 entry above found that putting it before the command targeted correctly, which was not retried. Never verify a reload by its command output. Prefer that in-band reload whenever more than one vault is open. + +### 2026-10-18 — Continuous status carousel + +The status-line regression fails against the previous implementation. Geometry fixtures in Jest verify sequencing and duplication; a Chromium run using the bundled production component and stylesheet verifies constant motion, continuity across the lap boundary, resize and tense stability, new-status replacement, and reduced motion. Obsidian CLI was unavailable, so this is browser component evidence rather than in-app verification. No protocol change. diff --git a/.cline/skills/nexus-testing/refinement-log.md b/.cline/skills/nexus-testing/refinement-log.md index 23593c1d..a255721e 100644 --- a/.cline/skills/nexus-testing/refinement-log.md +++ b/.cline/skills/nexus-testing/refinement-log.md @@ -231,3 +231,7 @@ The plan asked for an in-app `obsidian eval` check of slider markup. It was sett ### 2026-10-14 — `plugin:reload … vault=` reloaded the focused vault Linux, four vaults open. `obsidian plugin:reload id=nexus vault="Nexus Redesign Test"` did not reload the named vault: its plugin instance's `lifecycleManager.getInitializationStatus().startTime` did not move, while the focused Synaptic-Labs vault's did. The first call printed `Reloaded: nexus`, later ones printed nothing; neither output says which vault. What worked and is provably targeted: one guarded `eval` in the named vault that asserts `app.vault.getName()`, then runs `app.plugins.disablePlugin('nexus').then(() => app.plugins.enablePlugin('nexus'))` into a `window` status object, polled separately; confirm the new instance by its startTime. Here `vault=` came after the command, the shape step 3 of `live-loop.md` shows; the 2026-10-13 entry above found that putting it before the command targeted correctly, which was not retried. Never verify a reload by its command output. Prefer that in-band reload whenever more than one vault is open. + +### 2026-10-18 — Continuous status carousel + +The status-line regression fails against the previous implementation. Geometry fixtures in Jest verify sequencing and duplication; a Chromium run using the bundled production component and stylesheet verifies constant motion, continuity across the lap boundary, resize and tense stability, new-status replacement, and reduced motion. Obsidian CLI was unavailable, so this is browser component evidence rather than in-app verification. No protocol change. diff --git a/.codex/skills/nexus-testing/refinement-log.md b/.codex/skills/nexus-testing/refinement-log.md index 23593c1d..a255721e 100644 --- a/.codex/skills/nexus-testing/refinement-log.md +++ b/.codex/skills/nexus-testing/refinement-log.md @@ -231,3 +231,7 @@ The plan asked for an in-app `obsidian eval` check of slider markup. It was sett ### 2026-10-14 — `plugin:reload … vault=` reloaded the focused vault Linux, four vaults open. `obsidian plugin:reload id=nexus vault="Nexus Redesign Test"` did not reload the named vault: its plugin instance's `lifecycleManager.getInitializationStatus().startTime` did not move, while the focused Synaptic-Labs vault's did. The first call printed `Reloaded: nexus`, later ones printed nothing; neither output says which vault. What worked and is provably targeted: one guarded `eval` in the named vault that asserts `app.vault.getName()`, then runs `app.plugins.disablePlugin('nexus').then(() => app.plugins.enablePlugin('nexus'))` into a `window` status object, polled separately; confirm the new instance by its startTime. Here `vault=` came after the command, the shape step 3 of `live-loop.md` shows; the 2026-10-13 entry above found that putting it before the command targeted correctly, which was not retried. Never verify a reload by its command output. Prefer that in-band reload whenever more than one vault is open. + +### 2026-10-18 — Continuous status carousel + +The status-line regression fails against the previous implementation. Geometry fixtures in Jest verify sequencing and duplication; a Chromium run using the bundled production component and stylesheet verifies constant motion, continuity across the lap boundary, resize and tense stability, new-status replacement, and reduced motion. Obsidian CLI was unavailable, so this is browser component evidence rather than in-app verification. No protocol change. diff --git a/.skills/nexus-testing/refinement-log.md b/.skills/nexus-testing/refinement-log.md index 23593c1d..a255721e 100644 --- a/.skills/nexus-testing/refinement-log.md +++ b/.skills/nexus-testing/refinement-log.md @@ -231,3 +231,7 @@ The plan asked for an in-app `obsidian eval` check of slider markup. It was sett ### 2026-10-14 — `plugin:reload … vault=` reloaded the focused vault Linux, four vaults open. `obsidian plugin:reload id=nexus vault="Nexus Redesign Test"` did not reload the named vault: its plugin instance's `lifecycleManager.getInitializationStatus().startTime` did not move, while the focused Synaptic-Labs vault's did. The first call printed `Reloaded: nexus`, later ones printed nothing; neither output says which vault. What worked and is provably targeted: one guarded `eval` in the named vault that asserts `app.vault.getName()`, then runs `app.plugins.disablePlugin('nexus').then(() => app.plugins.enablePlugin('nexus'))` into a `window` status object, polled separately; confirm the new instance by its startTime. Here `vault=` came after the command, the shape step 3 of `live-loop.md` shows; the 2026-10-13 entry above found that putting it before the command targeted correctly, which was not retried. Never verify a reload by its command output. Prefer that in-band reload whenever more than one vault is open. + +### 2026-10-18 — Continuous status carousel + +The status-line regression fails against the previous implementation. Geometry fixtures in Jest verify sequencing and duplication; a Chromium run using the bundled production component and stylesheet verifies constant motion, continuity across the lap boundary, resize and tense stability, new-status replacement, and reduced motion. Obsidian CLI was unavailable, so this is browser component evidence rather than in-app verification. No protocol change. diff --git a/src/ui/chat/components/toolStatusLine.ts b/src/ui/chat/components/toolStatusLine.ts index 84a01fd8..ddb2cf8e 100644 --- a/src/ui/chat/components/toolStatusLine.ts +++ b/src/ui/chat/components/toolStatusLine.ts @@ -11,11 +11,11 @@ export type { ToolStatusEntry }; * always looking at the words being "typed". * * Once the reveal finishes, a line that does not fit the row does not park on - * its tail: it rewinds to the head and carousels — hold, travel left at a - * reading pace, hold, snap back — looping until the next entry replaces it, so - * an uncapped goal sentence stays readable end to end. The loop is a pure CSS - * animation (no per-frame timers); JS only measures the overflow and hands the - * distance and duration to the stylesheet as custom properties. The row is + * its tail: two copies travel left at a reading pace in a seamless loop + * until the next entry replaces it, so an uncapped goal sentence stays + * readable end to end. The loop is a pure CSS + * animation (no per-frame timers); JS measures the repeated track and hands + * its duration to the stylesheet as a custom property. The row is * watched for resizes and the lap re-measured against the new width, so the * carousel tracks the pane rather than the width it happened to start at. * @@ -41,12 +41,7 @@ export class ToolStatusLine { // Carousel travel speed. Slow enough to read, brisk enough that a long // goal completes a lap while the tools it describes are still running. private static readonly MARQUEE_SPEED_PX_PER_S = 45; - // Fraction of the cycle spent travelling — the remainder is the hold at - // each end. Must match the keyframe stops of `tool-status-marquee` - // (0%–15% head hold, 15%–85% travel, 85%–100% tail hold). - private static readonly MARQUEE_TRAVEL_FRACTION = 0.7; - // Floor on a full lap so a barely-overflowing line drifts instead of - // twitching back and forth. + // Keep even a short overflowing line slow enough to read. private static readonly MARQUEE_MIN_CYCLE_MS = 3000; // Sub-pixel overflow is a rounding artifact, not text worth scrolling to. private static readonly MARQUEE_MIN_OVERFLOW_PX = 4; @@ -55,13 +50,15 @@ export class ToolStatusLine { private static readonly RESIZE_SETTLE_MS = 150; private currentSlot: HTMLElement | null = null; + private currentTrack: HTMLElement | null = null; private currentText: HTMLElement | null = null; + private repeatedText: HTMLElement | null = null; private currentEntry: ToolStatusEntry | null = null; private queuedEntry: ToolStatusEntry | null = null; private animating = false; // Lap distance currently handed to the stylesheet, so a resize that leaves // the endpoint where it was writes nothing. - private appliedShiftPx: number | null = null; + private appliedDistancePx: number | null = null; private resizePending = false; private timeouts: ManagedTimeoutTracker; @@ -127,9 +124,11 @@ export class ToolStatusLine { this.queuedEntry = null; this.currentEntry = null; this.currentSlot = null; + this.currentTrack = null; this.currentText = null; + this.repeatedText = null; this.animating = false; - this.appliedShiftPx = null; + this.appliedDistancePx = null; // Remove every slot, including any mid-exit one whose scheduled removal // was just cancelled by timeouts.clear(). const host = this.slot as HTMLElement & { empty?: () => void }; @@ -161,14 +160,16 @@ export class ToolStatusLine { const nextSlot = this.slot.createDiv({ cls: `tool-status-text-${entry.state} entering`, }); - // The text lives in an inner element so the carousel can own `transform` - // without fighting the slot's enter/exit translateY, and so the full text - // stays available on hover for readers who get no carousel at all. - const nextText = nextSlot.createSpan({ cls: 'tool-status-text-inner' }); + // A separate track owns the carousel's transform without fighting the + // slot's enter/exit translateY. The title exposes the full text on hover. + const nextTrack = nextSlot.createSpan({ cls: 'tool-status-track' }); + const nextText = nextTrack.createSpan({ cls: 'tool-status-text-inner' }); nextSlot.setAttribute('title', entry.text); this.currentSlot = nextSlot; + this.currentTrack = nextTrack; this.currentText = nextText; - this.appliedShiftPx = null; + this.repeatedText = null; + this.appliedDistancePx = null; this.timeouts.schedule(() => { nextSlot.removeClass('entering'); nextSlot.addClass('active'); @@ -228,15 +229,18 @@ export class ToolStatusLine { * * A no-op when the row has no width yet (status bar still collapsed) or * under reduced motion — the `title` attribute carries the full text in - * those cases. Only the custom properties change on a re-measure, never the - * `animation` shorthand, so a running lap keeps its position instead of - * snapping back to the head mid-resize. + * those cases. A width change that still needs a loop leaves its distance + * and duration alone, preserving the running lap. */ private syncMarquee(): void { const outer = this.currentSlot; + const track = this.currentTrack; const inner = this.currentText; - if (!outer || !inner) return; - if (this.prefersReducedMotion()) return; + if (!outer || !track || !inner) return; + if (this.prefersReducedMotion()) { + this.stopMarquee(); + return; + } const rowWidth = outer.clientWidth; const textWidth = inner.scrollWidth; @@ -248,33 +252,41 @@ export class ToolStatusLine { return; } - const shiftPx = -Math.round(overflow); - if (this.appliedShiftPx === null) { + if (!this.repeatedText) { + this.repeatedText = track.createSpan({ + cls: 'tool-status-text-inner', + text: this.currentEntry?.text ?? '', + attr: { 'aria-hidden': 'true' }, + }); + } + track.addClass('tool-status-marquee'); + // Two equal copies, each followed by the same CSS spacing. Moving half + // the track puts the second copy exactly where the first one started. + const distancePx = track.getBoundingClientRect().width / 2; + if (this.appliedDistancePx === null) { // First lap for this line: the reveal left the row scrolled to the tail // and the loop drives the text with `transform`, so hand it back a row // scrolled to the head. A re-measure of a running lap leaves it alone. outer.scrollLeft = 0; } - if (this.appliedShiftPx !== shiftPx) { - const travelMs = (overflow / ToolStatusLine.MARQUEE_SPEED_PX_PER_S) * 1000; + if (this.appliedDistancePx !== distancePx) { + const travelMs = (distancePx / ToolStatusLine.MARQUEE_SPEED_PX_PER_S) * 1000; const cycleMs = Math.max( ToolStatusLine.MARQUEE_MIN_CYCLE_MS, - travelMs / ToolStatusLine.MARQUEE_TRAVEL_FRACTION + travelMs ); - inner.style.setProperty('--tool-status-marquee-shift', `${shiftPx}px`); - inner.style.setProperty('--tool-status-marquee-duration', `${Math.round(cycleMs)}ms`); - this.appliedShiftPx = shiftPx; + track.style.setProperty('--tool-status-marquee-duration', `${Math.round(cycleMs)}ms`); + this.appliedDistancePx = distancePx; } - inner.addClass('tool-status-marquee'); } /** The text fits the row now — park it at the head, unlooped. */ private stopMarquee(): void { - if (this.appliedShiftPx === null) return; - this.appliedShiftPx = null; - this.currentText?.removeClass('tool-status-marquee'); - this.currentText?.style.removeProperty('--tool-status-marquee-shift'); - this.currentText?.style.removeProperty('--tool-status-marquee-duration'); + this.appliedDistancePx = null; + this.currentTrack?.removeClass('tool-status-marquee'); + this.currentTrack?.style.removeProperty('--tool-status-marquee-duration'); + this.repeatedText?.remove(); + this.repeatedText = null; } private prefersReducedMotion(): boolean { diff --git a/styles.css b/styles.css index 5dd379e7..57c1e2d2 100644 --- a/styles.css +++ b/styles.css @@ -8264,17 +8264,23 @@ body.is-mobile .nexus-task-board-shell { No ellipsis: ToolStatusLine streams the text word-by-word and follows the newest word via scrollLeft when the line outgrows the row, so overflow stays hidden but nothing is truncated. Once the reveal ends, a line that - does not fit carousels via the inner element's transform. */ + does not fit carousels via the track's transform. */ line-height: 1.46em; white-space: nowrap; overflow: hidden; transition: transform 0.2s ease-out, opacity 0.2s ease-out; } -/* The text itself. Owns `transform` for the carousel, so the slot above is - free to keep using `transform` for its enter/exit slide. */ +/* The track owns the horizontal loop; the slot keeps its enter/exit slide. */ +.tool-status-track { + display: inline-flex; + width: max-content; + vertical-align: top; +} + .tool-status-text-inner { display: inline-block; + flex: none; /* Top-aligned, not baseline: an inline-block on the baseline can grow the line box past the row's fixed height and push the text into the clip. */ vertical-align: top; @@ -8306,7 +8312,7 @@ body.is-mobile .nexus-task-board-shell { /* Shimmer animation for active/present status. Painted on the inner element so the gradient travels with the text when the carousel is running — a background clipped to the slot would stay put while the words slid past. */ -.tool-status-text-present > .tool-status-text-inner { +.tool-status-text-present .tool-status-text-inner { background: linear-gradient( 90deg, var(--text-muted) 0%, @@ -8322,25 +8328,20 @@ body.is-mobile .nexus-task-board-shell { animation: shimmer-sweep 2s linear infinite; } -/* Carousel: hold at the head, travel left at a reading pace, hold at the - tail, snap back, repeat — until the next status entry replaces the line. - ToolStatusLine measures the overflow and sets the two custom properties; - the stops here must match its MARQUEE_TRAVEL_FRACTION (15% + 15% held). +/* Two equal copies, each with trailing space: the second reaches the first's + starting position exactly at the lap boundary. No holds or visible rewind. + Shimmer stays on the text, so a tense change leaves this loop untouched. */ +.tool-status-track.tool-status-marquee { + animation: tool-status-marquee var(--tool-status-marquee-duration, 6s) linear infinite; +} - Declared after the shimmer rule (equal specificity, later wins) and with - the same two-animation list in both tenses, so the present→past flip on a - looping line does not restart the shorthand and yank the text back to its - head mid-lap. Past/failed lines have no gradient behind them, so their - shimmer half animates nothing. */ -.tool-status-text-inner.tool-status-marquee { - animation: - shimmer-sweep 2s linear infinite, - tool-status-marquee var(--tool-status-marquee-duration, 6s) linear infinite; +.tool-status-marquee > .tool-status-text-inner { + margin-inline-end: var(--space-xl); } @keyframes tool-status-marquee { - 0%, 15% { transform: translateX(0); } - 85%, 100% { transform: translateX(var(--tool-status-marquee-shift, 0px)); } + from { transform: translateX(0); } + to { transform: translateX(-50%); } } @keyframes shimmer-sweep { @@ -8387,7 +8388,7 @@ body.is-mobile .nexus-task-board-shell { } @media (prefers-reduced-motion: reduce) { - .tool-status-text-present > .tool-status-text-inner { + .tool-status-text-present .tool-status-text-inner { animation: none; -webkit-text-fill-color: initial; background: none; @@ -8396,11 +8397,19 @@ body.is-mobile .nexus-task-board-shell { /* No carousel for motion-sensitive readers — ToolStatusLine skips it too; the slot's `title` attribute carries the full text instead. */ - .tool-status-text-inner.tool-status-marquee { + .tool-status-track.tool-status-marquee { animation: none; transform: none; } + .tool-status-marquee > .tool-status-text-inner { + margin-inline-end: 0; + } + + .tool-status-marquee > [aria-hidden="true"] { + display: none; + } + .tool-status-text-present.entering, .tool-status-text-past.entering, .tool-status-text-failed.entering, @@ -9514,7 +9523,8 @@ body.is-mobile .nexus-workflow-attachment button { min-height: 44px; } } .reasoning-catch-up { position: absolute; - right: var(--space-m); + left: 50%; + transform: translateX(-50%); bottom: var(--space-m); display: grid; place-items: center; diff --git a/tests/unit/ToolStatusLine.test.ts b/tests/unit/ToolStatusLine.test.ts index d880c2b3..5fca1ca7 100644 --- a/tests/unit/ToolStatusLine.test.ts +++ b/tests/unit/ToolStatusLine.test.ts @@ -7,8 +7,8 @@ * incoming events are. While one entry plays, later ones collapse into a * single queued slot (latest wins). A same-text tense flip restyles the * visible line in place instead of re-rolling it. Once a revealed line that - * overflows the row has no successor waiting, it rewinds and carousels on a - * loop so the whole sentence stays readable. + * overflows the row has no successor waiting, it repeats continuously so the whole sentence stays readable. + * Layout dimensions below are fixtures; browser checks verify CSS continuity. */ import { Component, createMockElement } from 'obsidian'; @@ -17,15 +17,21 @@ import { ToolStatusLine } from '../../src/ui/chat/components/toolStatusLine'; const WORD_MS = 90; const DWELL_MS = 450; -/** The text lives in an inner span; the outer div is the clipping row. */ -function innerOf(outer: HTMLElement): HTMLElement { +/** The clipping row contains a moving track with one or two text copies. */ +function trackOf(outer: HTMLElement): HTMLElement { return (outer.createSpan as jest.Mock).mock.results[0].value as HTMLElement; } +function innerOf(outer: HTMLElement): HTMLElement { + return (trackOf(outer).createSpan as jest.Mock).mock.results[0].value as HTMLElement; +} + /** Give a played line real geometry so the overflow measurement can run. */ function measure(outer: HTMLElement, rowWidth: number, textWidth: number): void { (outer as unknown as { clientWidth: number }).clientWidth = rowWidth; (innerOf(outer) as unknown as { scrollWidth: number }).scrollWidth = textWidth; + // Simulate the two-copy layout; real CSS geometry is checked in a browser. + trackOf(outer).getBoundingClientRect = jest.fn(() => ({ width: 2 * (textWidth + 32) } as DOMRect)); } /** @@ -200,105 +206,115 @@ describe('ToolStatusLine — clear', () => { }); }); -describe('ToolStatusLine — carousel', () => { +describe('ToolStatusLine — continuous carousel', () => { beforeEach(() => jest.useFakeTimers()); afterEach(() => jest.useRealTimers()); - it('rewinds and loops a revealed line that outgrows the row', () => { - const { line, createdSlots, createdTexts } = makeLine(); - - line.update('a goal far wider than the row', 'present'); + it('repeats the complete text once, hidden from screen readers, and loops the full copy distance', () => { + const { line, createdSlots } = makeLine(); + const goal = 'a goal far wider than the row'; + line.update(goal, 'present'); const [outer] = createdSlots(); - measure(outer, 200, 500); // 300px of text hangs off the row + measure(outer, 200, 500); + const track = trackOf(outer); - // Mid-reveal the line is still following the newest word, not looping. jest.advanceTimersByTime(WORD_MS * 6); - expect(createdTexts()[0].addClass).not.toHaveBeenCalledWith('tool-status-marquee'); + expect(track.createSpan).toHaveBeenCalledTimes(1); + expect(track.addClass).not.toHaveBeenCalledWith('tool-status-marquee'); - // Reveal done + dwell → the carousel takes over from the parked tail. jest.advanceTimersByTime(DWELL_MS); - - const [text] = createdTexts(); - expect(text.addClass).toHaveBeenCalledWith('tool-status-marquee'); + expect(track.createSpan).toHaveBeenLastCalledWith({ + cls: 'tool-status-text-inner', text: goal, attr: { 'aria-hidden': 'true' }, + }); + expect(track.addClass).toHaveBeenCalledWith('tool-status-marquee'); expect((outer as unknown as { scrollLeft: number }).scrollLeft).toBe(0); - // 300px at 45px/s = 6.67s of travel, held either side → ~9.5s per lap. - expect(text.style.setProperty).toHaveBeenCalledWith('--tool-status-marquee-shift', '-300px'); - expect(text.style.setProperty).toHaveBeenCalledWith( - '--tool-status-marquee-duration', - '9524ms' - ); - }); + // The lap covers a whole copy plus its 32px gap at 45px/s, with no holds. + expect(track.style.setProperty).toHaveBeenCalledWith('--tool-status-marquee-duration', '11822ms'); - it('floors the lap time so a barely-overflowing line drifts instead of twitching', () => { - const { line, createdSlots, createdTexts } = makeLine(); + jest.advanceTimersByTime(60_000); + expect(track.createSpan).toHaveBeenCalledTimes(2); + expect(createdSlots()).toHaveLength(1); + }); + it('floors the lap time for a short overflowing line', () => { + const { line, createdSlots } = makeLine(); line.update('just over', 'present'); - measure(createdSlots()[0], 200, 220); + measure(createdSlots()[0], 50, 70); jest.advanceTimersByTime(WORD_MS + DWELL_MS); - - expect(createdTexts()[0].style.setProperty).toHaveBeenCalledWith( - '--tool-status-marquee-duration', - '3000ms' + expect(trackOf(createdSlots()[0]).style.setProperty).toHaveBeenCalledWith( + '--tool-status-marquee-duration', '3000ms' ); }); it('leaves a line that fits the row alone', () => { - const { line, createdSlots, createdTexts } = makeLine(); - + const { line, createdSlots } = makeLine(); line.update('short goal', 'present'); - measure(createdSlots()[0], 400, 402); // 2px: rounding, not text + measure(createdSlots()[0], 400, 402); jest.advanceTimersByTime(WORD_MS + DWELL_MS); - - expect(createdTexts()[0].addClass).not.toHaveBeenCalledWith('tool-status-marquee'); + const track = trackOf(createdSlots()[0]); + expect(track.addClass).not.toHaveBeenCalledWith('tool-status-marquee'); + expect(track.createSpan).toHaveBeenCalledTimes(1); }); it('does not loop a line that is about to be replaced', () => { - const { line, createdSlots, createdTexts } = makeLine(); - + const { line, createdSlots } = makeLine(); line.update('first long goal', 'present'); measure(createdSlots()[0], 100, 500); - line.update('second long goal', 'present'); // queued mid-reveal - + line.update('second long goal', 'present'); jest.advanceTimersByTime(WORD_MS * 3 + DWELL_MS); - - expect(createdTexts()[0].addClass).not.toHaveBeenCalledWith('tool-status-marquee'); + expect(trackOf(createdSlots()[0]).addClass).not.toHaveBeenCalledWith('tool-status-marquee'); expect(createdSlots()).toHaveLength(2); }); - it('keeps looping across a tense flip instead of re-rolling the line', () => { - const { line, createdSlots, createdTexts } = makeLine(); - - line.update('a goal far wider than the row', 'present'); + it('keeps the same track and copies across duplicate events and tense flips', () => { + const { line, createdSlots } = makeLine(); + const goal = 'a goal far wider than the row'; + line.update(goal, 'present'); const [outer] = createdSlots(); measure(outer, 200, 500); jest.advanceTimersByTime(WORD_MS * 7 + DWELL_MS); + const track = trackOf(outer); + (track.addClass as jest.Mock).mockClear(); + (track.style.setProperty as jest.Mock).mockClear(); - const [text] = createdTexts(); - (text.addClass as jest.Mock).mockClear(); - - line.update('a goal far wider than the row', 'past'); - - expect(createdSlots()).toHaveLength(1); // same line, restyled in place + line.update(goal, 'present'); + line.update(goal, 'past'); + expect(createdSlots()).toHaveLength(1); expect(outer.addClass).toHaveBeenCalledWith('tool-status-text-past'); - // The loop lives on the inner element, so the tense swap never touches it. - expect(text.addClass).not.toHaveBeenCalled(); + expect(track.createSpan).toHaveBeenCalledTimes(2); + expect(track.addClass).not.toHaveBeenCalled(); + expect(track.style.setProperty).not.toHaveBeenCalled(); }); - it('skips the carousel under reduced motion and exposes the full text on the row', () => { - const matchMedia = jest.fn().mockReturnValue({ matches: true }); - (window as unknown as { matchMedia: unknown }).matchMedia = matchMedia; - - const { line, createdSlots, createdTexts } = makeLine(); - line.update('a goal far wider than the row', 'present'); - const [outer] = createdSlots(); - measure(outer, 200, 500); - jest.advanceTimersByTime(DWELL_MS); - - expect(createdTexts()[0].textContent).toBe('a goal far wider than the row'); - expect(createdTexts()[0].addClass).not.toHaveBeenCalledWith('tool-status-marquee'); - expect(outer.setAttribute).toHaveBeenCalledWith('title', 'a goal far wider than the row'); + it('replaces the looping entry when a new tool status arrives', () => { + const { line, createdSlots } = makeLine(); + line.update('first long goal', 'present'); + const [first] = createdSlots(); + measure(first, 100, 500); + jest.advanceTimersByTime(WORD_MS * 3 + DWELL_MS); + line.update('next tool', 'present'); + jest.advanceTimersByTime(WORD_MS); + expect(innerOf(createdSlots()[1]).textContent).toBe('next tool'); + expect(first.classList.add).toHaveBeenCalledWith('exiting'); + jest.advanceTimersByTime(200); + expect(first.remove).toHaveBeenCalled(); + }); - (window as unknown as { matchMedia: unknown }).matchMedia = undefined; + it('skips the carousel under reduced motion and exposes the full text on the row', () => { + (window as unknown as { matchMedia: unknown }).matchMedia = jest.fn().mockReturnValue({ matches: true }); + try { + const { line, createdSlots } = makeLine(); + line.update('a goal far wider than the row', 'present'); + const [outer] = createdSlots(); + measure(outer, 200, 500); + jest.advanceTimersByTime(DWELL_MS); + expect(innerOf(outer).textContent).toBe('a goal far wider than the row'); + expect(trackOf(outer).createSpan).toHaveBeenCalledTimes(1); + expect(trackOf(outer).addClass).not.toHaveBeenCalledWith('tool-status-marquee'); + expect(outer.setAttribute).toHaveBeenCalledWith('title', 'a goal far wider than the row'); + } finally { + (window as unknown as { matchMedia: unknown }).matchMedia = undefined; + } }); }); @@ -315,14 +331,13 @@ describe('ToolStatusLine — carousel follows the row width', () => { jest.useRealTimers(); }); - /** Play a line to a settled, looping state at the given geometry. */ function looping(rowWidth: number, textWidth: number) { const line = makeLine(); line.line.update('a goal far wider than the row', 'present'); const [outer] = line.createdSlots(); measure(outer, rowWidth, textWidth); jest.advanceTimersByTime(WORD_MS * 7 + DWELL_MS); - return { ...line, outer, text: innerOf(outer) }; + return { ...line, outer, track: trackOf(outer) }; } it('observes the row the line is drawn into', () => { @@ -330,92 +345,61 @@ describe('ToolStatusLine — carousel follows the row width', () => { expect(resize.observed).toEqual([slot]); }); - it('re-measures the lap against the new width when the pane narrows', () => { - const { outer, text } = looping(200, 500); - expect(text.style.setProperty).toHaveBeenCalledWith('--tool-status-marquee-shift', '-300px'); - (text.style.setProperty as jest.Mock).mockClear(); - - measure(outer, 120, 500); // pane dragged narrower — 80px more to travel + it('keeps the same lap and speed when the pane narrows or its height changes', () => { + const { outer, track } = looping(200, 500); + (track.style.setProperty as jest.Mock).mockClear(); + measure(outer, 120, 500); resize.fire(); jest.advanceTimersByTime(RESIZE_SETTLE_MS); - - expect(text.style.setProperty).toHaveBeenCalledWith('--tool-status-marquee-shift', '-380px'); - expect(text.style.setProperty).toHaveBeenCalledWith( - '--tool-status-marquee-duration', - '12063ms' - ); - }); - - it('keeps a running lap where it is when the width does not actually change', () => { - const { text } = looping(200, 500); - (text.style.setProperty as jest.Mock).mockClear(); - - resize.fire(); // e.g. a height-only change - jest.advanceTimersByTime(RESIZE_SETTLE_MS); - - expect(text.style.setProperty).not.toHaveBeenCalled(); - }); - - it('collapses several resize ticks into one measurement', () => { - const { outer, text } = looping(200, 500); - (text.style.setProperty as jest.Mock).mockClear(); - - measure(outer, 190, 500); - resize.fire(); - measure(outer, 160, 500); - resize.fire(); - measure(outer, 150, 500); resize.fire(); jest.advanceTimersByTime(RESIZE_SETTLE_MS); - - // Only the settled width is written, not every frame of the drag. - const shifts = (text.style.setProperty as jest.Mock).mock.calls - .filter(([name]) => name === '--tool-status-marquee-shift') - .map(([, value]) => value); - expect(shifts).toEqual(['-350px']); + // Viewport width does not change the distance between repeated copies. + expect(track.style.setProperty).not.toHaveBeenCalled(); + expect(track.createSpan).toHaveBeenCalledTimes(2); }); - it('stops looping when the pane widens enough to fit the line', () => { - const { outer, text } = looping(200, 500); - + it('removes the repeated copy when the pane widens enough, then can loop again', () => { + const { outer, track } = looping(200, 500); + const repeat = (track.createSpan as jest.Mock).mock.results[1].value as HTMLElement; measure(outer, 520, 500); resize.fire(); jest.advanceTimersByTime(RESIZE_SETTLE_MS); + expect(track.removeClass).toHaveBeenCalledWith('tool-status-marquee'); + expect(track.style.removeProperty).toHaveBeenCalledWith('--tool-status-marquee-duration'); + expect(repeat.remove).toHaveBeenCalled(); - expect(text.removeClass).toHaveBeenCalledWith('tool-status-marquee'); - expect(text.style.removeProperty).toHaveBeenCalledWith('--tool-status-marquee-shift'); + measure(outer, 200, 500); + resize.fire(); + jest.advanceTimersByTime(RESIZE_SETTLE_MS); + expect(track.createSpan).toHaveBeenCalledTimes(3); + expect(track.addClass).toHaveBeenLastCalledWith('tool-status-marquee'); }); it('starts looping when a row that had no width gets one', () => { - const { line, createdSlots, createdTexts } = makeLine(); + const { line, createdSlots } = makeLine(); line.update('a goal far wider than the row', 'present'); const [outer] = createdSlots(); - measure(outer, 0, 500); // status bar still collapsed — nothing to measure + measure(outer, 0, 500); jest.advanceTimersByTime(WORD_MS * 7 + DWELL_MS); - expect(createdTexts()[0].addClass).not.toHaveBeenCalledWith('tool-status-marquee'); - + expect(trackOf(outer).addClass).not.toHaveBeenCalledWith('tool-status-marquee'); measure(outer, 200, 500); resize.fire(); jest.advanceTimersByTime(RESIZE_SETTLE_MS); - - expect(createdTexts()[0].addClass).toHaveBeenCalledWith('tool-status-marquee'); - // The stream parked the row on the tail; the first lap rewinds it. + expect(trackOf(outer).addClass).toHaveBeenCalledWith('tool-status-marquee'); expect((outer as unknown as { scrollLeft: number }).scrollLeft).toBe(0); }); - it('ignores a resize that lands mid-reveal — the reveal measures at its end', () => { - const { line, createdSlots, createdTexts } = makeLine(); + it('ignores a resize mid-reveal and measures after the reveal ends', () => { + const { line, createdSlots } = makeLine(); line.update('a goal far wider than the row', 'present'); const [outer] = createdSlots(); measure(outer, 200, 500); - jest.advanceTimersByTime(WORD_MS * 2); resize.fire(); jest.advanceTimersByTime(RESIZE_SETTLE_MS); - expect(createdTexts()[0].addClass).not.toHaveBeenCalledWith('tool-status-marquee'); - + expect(trackOf(outer).addClass).not.toHaveBeenCalledWith('tool-status-marquee'); jest.advanceTimersByTime(WORD_MS * 7 + DWELL_MS); - expect(createdTexts()[0].addClass).toHaveBeenCalledWith('tool-status-marquee'); + expect(trackOf(outer).addClass).toHaveBeenCalledWith('tool-status-marquee'); }); it('disconnects the observer when the owning component unloads', () => {