Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/web/src/components/home-hero/PlaceholderCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,14 @@ export function PlaceholderCarousel({ scenarios, active, paused = false, onScena
const visible = reducedMotion || visualStabilityMode
? scenario.text
: scenario.text.slice(0, state.charCount);
// Caret is nested in the text span so it follows the last character when the
// placeholder wraps (long follow-up scenarios in the chat composer).
return (
<div className="home-hero__carousel" aria-hidden="true" data-testid="home-hero-carousel">
<span className="home-hero__carousel-text">{visible}</span>
<span className="home-hero__carousel-caret" />
<span className="home-hero__carousel-text">
{visible}
<span className="home-hero__carousel-caret" />
</span>
</div>
);
}
26 changes: 17 additions & 9 deletions apps/web/src/styles/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -1483,33 +1483,41 @@
white-space: nowrap;
max-width: calc(100% - 8px);
}
/* Follow-up scenarios can be full paragraphs (visualPolish is ~270 chars);
the placeholder wraps within the composer's width instead of clipping.
pre-wrap (not normal) keeps the typewriter's trailing spaces at wrap
edges so the caret doesn't jitter to the next line for one frame.
The wrap grows to fit up to four wrapped lines (13px * 1.6 * 4 + padding)
only while the carousel is mounted, so the composer chrome doesn't
bounce as the typewriter rotates and the toolbar row stays uncovered. */
.composer-input-wrap:has(.home-hero__carousel) {
min-height: 104px;
}
.composer-input-wrap .home-hero__carousel {
position: absolute;
left: 9px;
right: 9px;
top: 8px;
z-index: 2;
display: flex;
align-items: center;
gap: 1px;
color: var(--text-faint);
font-size: 13px;
line-height: 1.6;
pointer-events: none;
user-select: none;
white-space: nowrap;
overflow: hidden;
}
.composer-input-wrap .home-hero__carousel-text {
min-width: 0;
white-space: pre-wrap;
overflow-wrap: break-word;
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
text-overflow: clip;
}
.composer-input-wrap .home-hero__carousel-caret {
width: 1px;
height: 1.25em;
flex: 0 0 auto;
background: var(--accent);
opacity: 0.85;
animation: home-hero-carousel-caret 900ms steps(1) infinite;
}
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/styles/home/home-hero.css
Original file line number Diff line number Diff line change
Expand Up @@ -796,11 +796,13 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* Caret is nested inline in the text span (see PlaceholderCarousel.tsx). */
.home-hero__carousel-caret {
flex: none;
display: inline-block;
width: 1.5px;
align-self: stretch;
margin: 3px 0 3px 2px;
height: 1.1em;
vertical-align: text-bottom;
margin-inline-start: 2px;
background: var(--accent);
border-radius: 1px;
animation: home-hero-caret-blink 1.05s steps(1, end) infinite;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
//
// The caret must be nested inside the text span, not a sibling, so it
// follows the last character when the placeholder wraps in the chat
// composer. Layout measurements live in the Playwright spec at
// `e2e/ui/composer-carousel-placeholder-wrap.test.ts`; jsdom cannot lay
// out, so this file locks the DOM shape only.

import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from '@testing-library/react';

import { PlaceholderCarousel } from '../../../src/components/home-hero/PlaceholderCarousel';

afterEach(() => {
cleanup();
vi.useRealTimers();
});

const LONG_SCENARIO = {
id: 'visual-polish',
text: 'Polish this design until it is ready to ship: check hierarchy, typography, spacing, responsive behavior, button states, empty/loading/error states, and accessibility; directly fix the most important issues.',
chipId: 'design-toolbox',
} as const;

describe('PlaceholderCarousel caret is nested inline in the text span', () => {
it('renders caret as the last child of .home-hero__carousel-text (not a sibling)', () => {
const { container } = render(
<PlaceholderCarousel
scenarios={[LONG_SCENARIO]}
active
onScenarioChange={() => {}}
/>,
);

const carousel = container.querySelector('[data-testid="home-hero-carousel"]');
expect(carousel, 'carousel root missing').not.toBeNull();

const textSpan = carousel!.querySelector('.home-hero__carousel-text');
expect(textSpan, 'text span missing').not.toBeNull();

const caret = textSpan!.querySelector('.home-hero__carousel-caret');
expect(caret, 'caret must be a descendant of the text span').not.toBeNull();
expect(caret!.parentElement).toBe(textSpan);
expect(textSpan!.lastElementChild).toBe(caret);

const siblingCaret = Array.from(carousel!.children).find(
(child) => child !== textSpan && child.classList.contains('home-hero__carousel-caret'),
);
expect(siblingCaret, 'caret must not appear as a sibling of the text span').toBeUndefined();

expect(carousel!.getAttribute('aria-hidden')).toBe('true');
});
});
Loading
Loading