Skip to content
Merged
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
2 changes: 1 addition & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { checkAndRefresh } from './utils/refreshUtils';
// thing that delivers them. Without these static imports the @font-face rules
// would move into the app shell's async CSS, and the public landing would paint
// in fallback fonts until the shell loaded (it never does on the landing).
import '@fontsource-variable/bricolage-grotesque';
import '@fontsource-variable/bricolage-grotesque/opsz.css';
import '@fontsource/ibm-plex-sans/400.css';
import '@fontsource/ibm-plex-sans/500.css';
import '@fontsource/ibm-plex-sans/600.css';
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2225,8 +2225,8 @@
"typing": "typing…",
"pinTitle": "Pin",
"unpinTitle": "Unpin",
"pinAriaLabel": "Pin pod",
"unpinAriaLabel": "Unpin pod",
"pinAriaLabel": "Pin {{name}}",
"unpinAriaLabel": "Unpin {{name}}",
"filters": {
"all": "All",
"team": "Team",
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2214,8 +2214,8 @@
"typing": "正在输入…",
"pinTitle": "置顶",
"unpinTitle": "取消置顶",
"pinAriaLabel": "置顶 Pod",
"unpinAriaLabel": "取消置顶 Pod",
"pinAriaLabel": "置顶 {{name}}",
"unpinAriaLabel": "取消置顶 {{name}}",
"filters": {
"all": "全部",
"team": "团队",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/v2/V2App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import RegistrationInviteRequired from '../components/RegistrationInviteRequired
import VerifyEmail from '../components/VerifyEmail';
import V2EmailVerificationBanner from './components/V2EmailVerificationBanner';
import ProtectedRoute from '../components/ProtectedRoute';
import '@fontsource-variable/bricolage-grotesque';
import '@fontsource-variable/bricolage-grotesque/opsz.css';
import '@fontsource/ibm-plex-sans/400.css';
import '@fontsource/ibm-plex-sans/500.css';
import '@fontsource/ibm-plex-sans/600.css';
Expand Down
74 changes: 55 additions & 19 deletions frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ const fleet42 = [
}),
];

// A row, not its pin: each pin is named "Pin <pod>", so a bare name matches both.
const rowButton = (name: RegExp) => {
const rows = screen.getAllByRole('button', { name }).filter((b) => b.classList.contains('v2-pods__row'));
expect(rows).toHaveLength(1);
return rows[0];
};

const renderSidebar = (pods, selectedPodId = 'sharpen', extra = {}) => render(
<MemoryRouter initialEntries={['/v2/pods/sharpen']}>
<V2PodsSidebar
Expand Down Expand Up @@ -190,13 +197,13 @@ describe('V2PodsSidebar — direction C', () => {
expect(within(connectors).getByLabelText('1 needs you')).toHaveTextContent('1');
expect(within(connectors).getByText('12m')).toBeInTheDocument();
// The queue page may render only its first page: the pill is the server total, never a list length.
expect(within(screen.getByRole('button', { name: /Commonly HQ/ })).getByLabelText('91 needs you')).toHaveTextContent('91');
const naming = screen.getByRole('button', { name: /Naming huddle/ });
expect(within(screen.getByRole('button', { name: /^Commonly HQ/ })).getByLabelText('91 needs you')).toHaveTextContent('91');
const naming = screen.getByRole('button', { name: /^Naming huddle/ });
expect(naming).not.toHaveClass('v2-pods__row--unread');
expect(within(naming).queryByLabelText(/needs you/)).not.toBeInTheDocument();
expect(within(naming).getByText('2h')).toBeInTheDocument();
// A DM is an ordinary row with the peer's avatar instead of initials.
const dm = screen.getByRole('button', { name: /DM test/ });
const dm = rowButton(/DM test/);
expect(dm.querySelector('.v2-pods__row-mark--avatar')).toBeTruthy();
expect(within(dm).getByText('1d')).toBeInTheDocument();
});
Expand All @@ -211,6 +218,10 @@ describe('V2PodsSidebar — direction C', () => {
renderSidebar([own, shared]);
expect(screen.getByRole('button', { name: /^My Workspace \(alex\)/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^My Workspace$/ })).toBeInTheDocument();
// Each pin carries the same owner-qualified name its row shows, so the two
// workspaces' pins are told apart by a screen reader too (#3415 review).
expect(screen.getByRole('button', { name: 'Pin My Workspace (alex)' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Pin My Workspace' })).toBeInTheDocument();
});

test('direct rows render the peer character for agents and Paper for unpicked people', () => {
Expand All @@ -225,7 +236,7 @@ describe('V2PodsSidebar — direction C', () => {
]);
renderSidebar([agentDm, agentRoom, humanDm], 'none');

const agentRow = screen.getByRole('button', { name: /Helper Agent/ });
const agentRow = rowButton(/Helper Agent/);
const agentImage = within(agentRow).getByRole('img', { name: 'Builder' });
const agentSource = characterAvatarFor('builder-id', 'agent');
expect(agentSource).not.toBeNull();
Expand All @@ -236,7 +247,7 @@ describe('V2PodsSidebar — direction C', () => {
'src', characterAvatarFor('scout-id', 'agent'),
);

const humanRow = screen.getByRole('button', { name: /Direct chat/ });
const humanRow = rowButton(/Direct chat/);
const humanImage = within(humanRow).getByRole('img', { name: 'Sam' });
expect(humanImage).toHaveAttribute('src', paperAvatarFor('sam-id'));
});
Expand Down Expand Up @@ -296,7 +307,7 @@ describe('V2PodsSidebar — direction C', () => {
kinds.forEach((kind) => expect(kind).toHaveAttribute('aria-expanded', 'false'));
// Pinned pods are in the inventory too.
fireEvent.click(screen.getByRole('button', { name: /^team\s+5/ }));
expect(within(section('team')).getByRole('button', { name: /Sharpen/ })).toHaveClass('v2-pods__row--in-fold');
expect(within(section('team')).getByRole('button', { name: /^Sharpen/ })).toHaveClass('v2-pods__row--in-fold');
expect(sessionStorage.getItem('v2:pods.view')).toBe('all');
expect(JSON.parse(sessionStorage.getItem('v2:pods.kindsOpen'))).toEqual(['team']);
});
Expand All @@ -306,7 +317,7 @@ describe('V2PodsSidebar — direction C', () => {
const input = screen.getByRole('searchbox', { name: 'Search pods' });
fireEvent.change(input, { target: { value: 'rewire' } });
expect(listRows().map((row) => row.textContent).join(' ')).not.toContain('Sharpen');
expect(screen.getAllByRole('button', { name: /Rewire/ }).map((b) => b.textContent)).toEqual([
expect(screen.getAllByRole('button', { name: /^Rewire/ }).map((b) => b.textContent)).toEqual([
expect.stringContaining('Rewire Live Demo'), expect.stringContaining('Rewire Demo Sprint'),
]);
fireEvent.change(input, { target: { value: 'zzz' } });
Expand Down Expand Up @@ -348,7 +359,7 @@ describe('V2PodsSidebar — direction C', () => {

test('a row navigates to its pod; the + square opens the create form', () => {
renderSidebar(fleet);
fireEvent.click(screen.getByRole('button', { name: /Naming huddle/ }));
fireEvent.click(screen.getByRole('button', { name: /^Naming huddle/ }));
expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/naming');
fireEvent.click(screen.getByRole('button', { name: 'New pod' }));
expect(screen.getByPlaceholderText('Pod name')).toBeInTheDocument();
Expand Down Expand Up @@ -419,18 +430,18 @@ describe('V2PodsSidebar — direction C', () => {
expect(counts.games).toBe('4');
const input = screen.getByRole('searchbox', { name: 'Search pods' });
fireEvent.change(input, { target: { value: 'Generated pod 30' } });
expect(screen.getAllByRole('button', { name: /Generated pod 30/ })).toHaveLength(1);
expect(screen.getAllByRole('button', { name: /^Generated pod 30/ })).toHaveLength(1);
});

test('every row carries a pin icon button that toggles the store without opening the pod', () => {
mockPinned.add('sharpen');
renderSidebar(fleet, null);
const unpin = screen.getByRole('button', { name: 'Unpin pod' });
const unpin = screen.getByRole('button', { name: /^Unpin / });
expect(unpin).toHaveClass('v2-pods__pin--active');
expect(unpin).toHaveAttribute('aria-pressed', 'true');
expect(unpin).toHaveAttribute('title', 'Unpin');
expect(unpin.querySelector('svg')).toBeTruthy();
const pins = screen.getAllByRole('button', { name: 'Pin pod' });
const pins = screen.getAllByRole('button', { name: /^Pin / });
expect(pins).toHaveLength(7);
// The pin sits beside the row (a button cannot nest a button) and shares
// its hover wrapper, which hides the time while the pin shows.
Expand All @@ -441,19 +452,44 @@ describe('V2PodsSidebar — direction C', () => {
expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/sharpen');
});

test('every pin stays in the accessibility tree at rest, focusable and named for its pod', () => {
mockPinned.add('sharpen');
renderSidebar(fleet, null);
// A screen reader reaches each pin by swiping, without the press and hold
// (Cofounder 11-04): never aria-hidden, never out of the tab order, and named
// for the pod it pins so one row's pin is told from the next.
const pins = screen.getAllByRole('button', { name: /^(Pin|Unpin) / });
expect(pins).toHaveLength(8);
for (const p of pins) {
expect(p).not.toHaveAttribute('aria-hidden');
expect(p).not.toHaveAttribute('tabindex', '-1');
const rowName = p.parentElement?.querySelector('.v2-pods__row-name')?.textContent;
expect(rowName).toBeTruthy();
expect(p).toHaveAccessibleName(`${p.getAttribute('aria-pressed') === 'true' ? 'Unpin' : 'Pin'} ${rowName}`);
}
expect(pins.filter((p) => p.getAttribute('aria-pressed') === 'true')).toHaveLength(1);
});

test('a touch press and hold reveals the row pin in the time slot without opening the pod', () => {
jest.useFakeTimers();
try {
renderSidebar(fleet, null);
const pin = screen.getAllByRole('button', { name: 'Pin pod' })[0];
const pin = screen.getAllByRole('button', { name: /^Pin / })[0];
const wrap = pin.parentElement as HTMLElement;
const row = wrap.querySelector('.v2-pods__row') as HTMLElement;
const before = screen.getByTestId('current-path').textContent;
// A quick tap opens the pod and reveals nothing.
fireEvent.pointerDown(row, { pointerType: 'touch' });
fireEvent.pointerUp(row, { pointerType: 'touch' });
// A quick tap on another pod's row opens that pod and reveals nothing. The
// route starts on the first row's pod, so the tap goes to a different row.
const tapWrap = screen.getAllByRole('button', { name: /^Pin / })[2].parentElement as HTMLElement;
const tapRow = tapWrap.querySelector('.v2-pods__row') as HTMLElement;
const atStart = screen.getByTestId('current-path').textContent;
fireEvent.pointerDown(tapRow, { pointerType: 'touch' });
fireEvent.pointerUp(tapRow, { pointerType: 'touch' });
fireEvent.click(tapRow);
act(() => { jest.advanceTimersByTime(600); });
expect(wrap).not.toHaveClass('v2-pods__rowwrap--reveal');
expect(tapWrap).not.toHaveClass('v2-pods__rowwrap--reveal');
expect(screen.getByTestId('current-path').textContent).not.toBe(atStart);
expect(screen.getByTestId('current-path').textContent).toMatch(/^\/v2\/pods\/./);
const before = screen.getByTestId('current-path').textContent;
// A hold reveals the pin; the click it ends in stays on the list.
fireEvent.pointerDown(row, { pointerType: 'touch' });
act(() => { jest.advanceTimersByTime(600); });
Expand All @@ -474,8 +510,8 @@ describe('V2PodsSidebar — direction C', () => {
expect(wrap).not.toHaveClass('v2-pods__rowwrap--reveal');
// A browser that sends no click after a long press must not leave the next
// keyboard Enter or mouse click swallowed (#3412 review).
const other = screen.getAllByRole('button', { name: 'Pin pod' })[1].parentElement
?.querySelector('.v2-pods__row') as HTMLElement;
// The quick tap above left another pod open, so the first row's pod is a change.
const other = row;
fireEvent.pointerDown(other, { pointerType: 'touch' });
act(() => { jest.advanceTimersByTime(600); });
fireEvent.pointerUp(other, { pointerType: 'touch' });
Expand Down
24 changes: 18 additions & 6 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,17 +597,21 @@ describe('v2 layout invariants (CSS rule presence)', () => {
// time's slot, out of sight at rest even when pinned.
expect(podsSidebar).toContain('togglePin(pod._id)');
expect(podsSidebar).toContain('<ShellGlyph name="pin"');
// visibility, not opacity: an opacity-0 pin still catches a tap on the time.
expect(ruleBody(v2, '.v2-root button.v2-pods__pin')).toContain('visibility: hidden');
expect(ruleBody(v2, '.v2-root button.v2-pods__pin')).not.toContain('opacity: 0');
expect(v2).toContain('.v2-pods__rowwrap:hover .v2-pods__pin,\n.v2-pods__rowwrap:has(:focus-visible) .v2-pods__pin,\n.v2-root .v2-pods__rowwrap--reveal .v2-pods__pin { visibility: visible; }');
// At rest the pin is opacity 0 so it stays in the accessibility tree (a screen
// reader reaches it by swiping, Cofounder 11-04), and pointer-events none so the
// hidden pin cannot catch a tap on the time. visibility: hidden would drop it from
// the tree and leave touch screen-reader users only the hold.
expect(ruleBody(v2, '.v2-root button.v2-pods__pin')).toContain('opacity: 0');
expect(ruleBody(v2, '.v2-root button.v2-pods__pin')).toContain('pointer-events: none');
expect(ruleBody(v2, '.v2-root button.v2-pods__pin')).not.toContain('visibility: hidden');
expect(v2).toContain('.v2-pods__rowwrap:hover .v2-pods__pin,\n.v2-pods__rowwrap:has(:focus-visible) .v2-pods__pin,\n.v2-root .v2-pods__rowwrap .v2-pods__pin:focus,\n.v2-root .v2-pods__rowwrap--reveal .v2-pods__pin { opacity: 1; pointer-events: auto; }');
// Touch has no hover: the 390 frame still draws the time at rest, and a press and
// hold (--reveal) is the row's hover state there, or a phone cannot pin.
expect(v2).not.toMatch(/@media \(hover: none\) and \(pointer: coarse\) \{\n \.v2-root button\.v2-pods__pin \{ visibility: visible; \}/);
expect(podsSidebar).toContain("revealedPin === pod._id ? 'v2-pods__rowwrap--reveal' : ''");
expect(v2).not.toContain('.v2-root button.v2-pods__pin--active { opacity: 1; }');
expect(v2).not.toContain("button.v2-pods__pin::before { content: '⌖'");
expect(v2).toContain('.v2-pods__rowwrap:hover .v2-pods__row-time,\n.v2-pods__rowwrap:has(:focus-visible) .v2-pods__row-time,\n.v2-pods__rowwrap--reveal .v2-pods__row-time { visibility: hidden; }');
expect(v2).toContain('.v2-pods__rowwrap:hover .v2-pods__row-time,\n.v2-pods__rowwrap:has(:focus-visible) .v2-pods__row-time,\n.v2-pods__rowwrap:has(.v2-pods__pin:focus) .v2-pods__row-time,\n.v2-pods__rowwrap--reveal .v2-pods__row-time { visibility: hidden; }');
expect(ruleBody(v2, '.v2-pods__row-time')).toContain('min-width: 24px');
// The shared segment: a tinted track with the checked option raised.
// D223: the segment draws as the primitives' v2p-seg; v2.css keeps only its states.
Expand Down Expand Up @@ -2970,11 +2974,19 @@ describe('v2 layout invariants (CSS rule presence)', () => {
// @font-face rules inside a lazy chunk would leave the public landing in
// fallback fonts until the app shell loaded — and on the landing it never
// loads at all.
expect(entry).toContain("import '@fontsource-variable/bricolage-grotesque';");
// The faces carry the optical-size axis the d215 frames are drawn with
// (Google Fonts' opsz,wght@12..96); the package's default file has only
// weight, which set display type about 2.5% wider than the frames.
expect(entry).toContain("import '@fontsource-variable/bricolage-grotesque/opsz.css';");
expect(entry).not.toMatch(/^import '@fontsource-variable\/bricolage-grotesque';$/m);
expect(entry).toContain("import '@fontsource/ibm-plex-sans/400.css';");
expect(entry).toContain("import './v2/v2.css';");

const shell = read('../V2App.tsx');
// The shell loads the same optical-size faces; the default file there would
// register weight-only faces after the entry's (#3415 review).
expect(shell).toContain("import '@fontsource-variable/bricolage-grotesque/opsz.css';");
expect(shell).not.toMatch(/^import '@fontsource-variable\/bricolage-grotesque';$/m);
// The composer lives in V2Layout, so V2Layout stays IN the shell chunk.
// Making it lazy would add a serial round trip to the very path this row
// is measured on while shrinking nothing that path does not need.
Expand Down
13 changes: 7 additions & 6 deletions frontend/src/v2/components/V2PodsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,9 @@ const V2PodsSidebar: React.FC<V2PodsSidebarProps> = ({
const selected = pod._id === selectedPodId;
const count = attentionCountByPod[pod._id] || 0;
const direct = isDirectPod(pod);
const rowName = pod.name === 'My Workspace' && pod.createdBy?._id && String(pod.createdBy._id) !== String(currentUser?._id)
? t('podNames.otherWorkspace', { name: pod.name, owner: pod.createdBy.username || t('podNames.shared') })
: pod.name;
const peer = direct ? directMemberFor(pod, currentUser?._id) : undefined;
const time = relativeTime(podMessageTime(pod) || null, now);
const isPinned = pinned.has(pod._id);
Expand Down Expand Up @@ -403,11 +406,7 @@ const V2PodsSidebar: React.FC<V2PodsSidebarProps> = ({
) : (
<span className="v2-pods__row-mark" aria-hidden="true">{podInitials(pod.name)}</span>
)}
<span className="v2-pods__row-name">
{pod.name === 'My Workspace' && pod.createdBy?._id && String(pod.createdBy._id) !== String(currentUser?._id)
? t('podNames.otherWorkspace', { name: pod.name, owner: pod.createdBy.username || t('podNames.shared') })
: pod.name}
</span>
<span className="v2-pods__row-name">{rowName}</span>
<span className="v2-pods__row-meta">
{count > 0 && (
<span className="v2-pods__row-pill" aria-label={t('podsSidebar.workspace.needsYouCount', { count })}>
Expand All @@ -422,7 +421,9 @@ const V2PodsSidebar: React.FC<V2PodsSidebarProps> = ({
type="button"
className={`v2-pods__pin${isPinned ? ' v2-pods__pin--active' : ''}`}
onClick={(event) => { event.stopPropagation(); togglePin(pod._id); }}
aria-label={isPinned ? t('podsSidebar.unpinAriaLabel') : t('podsSidebar.pinAriaLabel')}
// Named for its pod: the pin stays in the accessibility tree at rest, so a
// screen reader reaches it by swiping, one per row, without the hold.
aria-label={isPinned ? t('podsSidebar.unpinAriaLabel', { name: rowName }) : t('podsSidebar.pinAriaLabel', { name: rowName })}
title={isPinned ? t('podsSidebar.unpinTitle') : t('podsSidebar.pinTitle')}
aria-pressed={isPinned}
>
Expand Down
Loading
Loading