Skip to content

Commit 2c3bd97

Browse files
authored
ui(replay-drawer): rename Authorship pill to Authors + tighten its affordance (#224)
* ui(replay-drawer): rename Authorship pill label to "Authors" Visible string + aria-label only; internal vocabulary (component name, props, CSS classes, test describe block) still says authorship and is slated for unification with attribution in a follow-up. * refactor(ui): unify Authorship → Attribution internal vocabulary Pure rename across the UI surface (component, props, CSS classes, keyframes, custom properties, test descriptions, comments) so the internal vocabulary matches the data API and the contract doc. User-facing strings ("Authors" pill label, "Authors overlay" aria-label, "Highlight authors" tooltip) are unchanged. * feat(replay-drawer): disable Authors pill on unsupported formats The pill was rendering for every file type but only `q2-debug` and `q2-preview` actually surface attribution visually — toggling it for other formats did nothing the user could see. Now the pill greys out with an explanatory title when `currentFormat` is anything else. `attributionOn` state is preserved across the disabled period so returning to a supported format restores the user's preference. * ui(replay-drawer): state-aware tooltip on Authors pill Replaces the static "Highlight authors" tooltip with "Show authors overlay" / "Hide authors overlay" depending on the on/off state, so the noun-phrase matches the disabled-state message ("Authors overlay is not available for this format") and the verb tracks what clicking will actually do.
1 parent d12bd61 commit 2c3bd97

11 files changed

Lines changed: 215 additions & 114 deletions

File tree

hub-client/src/components/Editor.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -219,16 +219,16 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
219219
// Scroll sync state (persisted in localStorage)
220220
const [scrollSyncEnabled, setScrollSyncEnabled] = usePreference('scrollSyncEnabled');
221221

222-
// Authorship overlay — session-only useState (not persisted).
222+
// Attribution overlay — session-only useState (not persisted).
223223
// Owned here, surfaced via the toggle in the replay bar, and
224224
// threaded into ReactPreview where `useAttribution` consumes it
225225
// as the `enabled` flag. Treated as an inspection mode rather
226226
// than a setting: resets on reload so a previously-curious view
227227
// doesn't bleed into the next session.
228-
const [authorshipOn, setAuthorshipOn] = useState(false);
228+
const [attributionOn, setAttributionOn] = useState(false);
229229
// `useAttribution` (inside ReactPreview) reports whether it's
230230
// mid-build via `onAttributionGeneratingChange`; the flag drives
231-
// the rotating-gradient border on the Authorship pill so a slow
231+
// the rotating-gradient border on the Attribution pill so a slow
232232
// run-list build on a large document is visible to the user.
233233
const [attributionGenerating, setAttributionGenerating] = useState(false);
234234
// Track if editor has focus (to prevent scroll feedback loop)
@@ -1066,7 +1066,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
10661066
onFormatChange={handleFormatChange}
10671067
onContentRewrite={handleContentRewrite}
10681068
identities={identities}
1069-
authorshipOn={authorshipOn}
1069+
attributionOn={attributionOn}
10701070
onAttributionGeneratingChange={setAttributionGenerating}
10711071
/>
10721072
</div>
@@ -1079,9 +1079,12 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
10791079
controls={replayControls}
10801080
disabled={!!currentFile && isBinaryExtension(currentFile.path)}
10811081
identities={identities}
1082-
authorshipOn={authorshipOn}
1083-
onAuthorshipChange={setAuthorshipOn}
1084-
authorshipGenerating={attributionGenerating}
1082+
attributionOn={attributionOn}
1083+
onAttributionChange={setAttributionOn}
1084+
attributionGenerating={attributionGenerating}
1085+
attributionDisabled={
1086+
currentFormat !== 'q2-debug' && currentFormat !== 'q2-preview'
1087+
}
10851088
/>
10861089
)}
10871090

hub-client/src/components/ReplayDrawer.css

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
}
5353

5454
/* Collapsed: toggle grows to fill the bar so most of it is still
55-
clickable to enter replay, while leaving room for the Authorship
55+
clickable to enter replay, while leaving room for the Attribution
5656
pill on the right. */
5757
.replay-drawer--collapsed .replay-drawer__toggle {
5858
flex: 1;
@@ -298,10 +298,10 @@
298298
pointer-events: none;
299299
}
300300

301-
/* Authorship overlay toggle — sits flush-right in both collapsed and
301+
/* Attribution overlay toggle — sits flush-right in both collapsed and
302302
expanded states. Off-state matches the dim transport-button look;
303303
on-state lights up with the editor accent. */
304-
.replay-drawer__authorship {
304+
.replay-drawer__attribution {
305305
display: inline-flex;
306306
align-items: center;
307307
gap: 6px;
@@ -321,23 +321,32 @@
321321
user-select: none;
322322
}
323323

324-
.replay-drawer__authorship:hover {
324+
.replay-drawer__attribution:hover {
325325
color: var(--editor-text);
326326
border-color: var(--editor-text-dim);
327327
}
328328

329-
.replay-drawer__authorship--on {
329+
.replay-drawer__attribution:disabled,
330+
.replay-drawer__attribution:disabled:hover {
331+
cursor: not-allowed;
332+
opacity: 0.4;
333+
color: var(--editor-text-dim);
334+
border-color: var(--editor-accent-border);
335+
background: var(--editor-accent-bg);
336+
}
337+
338+
.replay-drawer__attribution--on {
330339
color: var(--editor-success);
331340
border-color: var(--editor-success);
332341
background: var(--editor-success-bg);
333342
}
334343

335-
.replay-drawer__authorship--on:hover {
344+
.replay-drawer__attribution--on:hover {
336345
background: var(--editor-success);
337346
color: var(--editor-bg);
338347
}
339348

340-
.replay-drawer__authorship-dot {
349+
.replay-drawer__attribution-dot {
341350
display: inline-block;
342351
width: 7px;
343352
height: 7px;
@@ -347,7 +356,7 @@
347356
transition: opacity 0.15s;
348357
}
349358

350-
.replay-drawer__authorship--on .replay-drawer__authorship-dot {
359+
.replay-drawer__attribution--on .replay-drawer__attribution-dot {
351360
opacity: 1;
352361
}
353362

@@ -361,17 +370,17 @@
361370
We intentionally keep the static border underneath — the rotating
362371
highlight rides on top, so the pill never looks "borderless" mid-
363372
animation if a browser ignores the pseudo-element. */
364-
@property --replay-drawer__authorship-angle {
373+
@property --replay-drawer__attribution-angle {
365374
syntax: '<angle>';
366375
inherits: false;
367376
initial-value: 0deg;
368377
}
369378

370-
.replay-drawer__authorship--generating {
379+
.replay-drawer__attribution--generating {
371380
position: relative;
372381
}
373382

374-
.replay-drawer__authorship--generating::before {
383+
.replay-drawer__attribution--generating::before {
375384
content: '';
376385
position: absolute;
377386
inset: 0;
@@ -381,7 +390,7 @@
381390
indicator, no transparent gap. First and last stops match so
382391
the seam is invisible across the 0/360 boundary. */
383392
background: conic-gradient(
384-
from var(--replay-drawer__authorship-angle),
393+
from var(--replay-drawer__attribution-angle),
385394
#ff3b30 0deg,
386395
#ff9500 60deg,
387396
#ffcc00 120deg,
@@ -398,18 +407,18 @@
398407
linear-gradient(#000 0 0) content-box,
399408
linear-gradient(#000 0 0);
400409
mask-composite: exclude;
401-
animation: replay-drawer__authorship-spin 2s linear infinite;
410+
animation: replay-drawer__attribution-spin 2s linear infinite;
402411
pointer-events: none;
403412
}
404413

405-
@keyframes replay-drawer__authorship-spin {
414+
@keyframes replay-drawer__attribution-spin {
406415
to {
407-
--replay-drawer__authorship-angle: 360deg;
416+
--replay-drawer__attribution-angle: 360deg;
408417
}
409418
}
410419

411420
@media (prefers-reduced-motion: reduce) {
412-
.replay-drawer__authorship--generating::before {
421+
.replay-drawer__attribution--generating::before {
413422
animation: none;
414423
}
415424
}

hub-client/src/components/ReplayDrawer.test.tsx

Lines changed: 102 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -269,17 +269,17 @@ describe('ReplayDrawer', () => {
269269
});
270270
});
271271

272-
describe('Authorship toggle', () => {
273-
it('renders in collapsed state when authorshipOn + onAuthorshipChange are passed', () => {
272+
describe('Attribution toggle', () => {
273+
it('renders in collapsed state when attributionOn + onAttributionChange are passed', () => {
274274
render(
275275
<ReplayDrawer
276276
state={makeState()}
277277
controls={controls}
278-
authorshipOn={false}
279-
onAuthorshipChange={vi.fn()}
278+
attributionOn={false}
279+
onAttributionChange={vi.fn()}
280280
/>,
281281
);
282-
expect(screen.getByLabelText(/Authorship/)).toBeDefined();
282+
expect(screen.getByLabelText(/Authors/)).toBeDefined();
283283
});
284284

285285
it('renders in expanded state', () => {
@@ -296,46 +296,46 @@ describe('ReplayDrawer', () => {
296296
<ReplayDrawer
297297
state={activeState}
298298
controls={controls}
299-
authorshipOn={false}
300-
onAuthorshipChange={vi.fn()}
299+
attributionOn={false}
300+
onAttributionChange={vi.fn()}
301301
/>,
302302
);
303-
expect(screen.getByLabelText(/Authorship/)).toBeDefined();
303+
expect(screen.getByLabelText(/Authors/)).toBeDefined();
304304
});
305305

306-
it('reflects authorshipOn state via aria-pressed', () => {
306+
it('reflects attributionOn state via aria-pressed', () => {
307307
const { rerender } = render(
308308
<ReplayDrawer
309309
state={makeState()}
310310
controls={controls}
311-
authorshipOn={false}
312-
onAuthorshipChange={vi.fn()}
311+
attributionOn={false}
312+
onAttributionChange={vi.fn()}
313313
/>,
314314
);
315-
expect(screen.getByLabelText(/Authorship/).getAttribute('aria-pressed')).toBe('false');
315+
expect(screen.getByLabelText(/Authors/).getAttribute('aria-pressed')).toBe('false');
316316

317317
rerender(
318318
<ReplayDrawer
319319
state={makeState()}
320320
controls={controls}
321-
authorshipOn={true}
322-
onAuthorshipChange={vi.fn()}
321+
attributionOn={true}
322+
onAttributionChange={vi.fn()}
323323
/>,
324324
);
325-
expect(screen.getByLabelText(/Authorship/).getAttribute('aria-pressed')).toBe('true');
325+
expect(screen.getByLabelText(/Authors/).getAttribute('aria-pressed')).toBe('true');
326326
});
327327

328-
it('clicking toggles via onAuthorshipChange', () => {
328+
it('clicking toggles via onAttributionChange', () => {
329329
const onChange = vi.fn();
330330
render(
331331
<ReplayDrawer
332332
state={makeState()}
333333
controls={controls}
334-
authorshipOn={false}
335-
onAuthorshipChange={onChange}
334+
attributionOn={false}
335+
onAttributionChange={onChange}
336336
/>,
337337
);
338-
fireEvent.click(screen.getByLabelText(/Authorship/));
338+
fireEvent.click(screen.getByLabelText(/Authors/));
339339
expect(onChange).toHaveBeenCalledWith(true);
340340
});
341341

@@ -344,44 +344,112 @@ describe('ReplayDrawer', () => {
344344
<ReplayDrawer
345345
state={makeState()}
346346
controls={controls}
347-
authorshipOn={false}
348-
onAuthorshipChange={vi.fn()}
347+
attributionOn={false}
348+
onAttributionChange={vi.fn()}
349349
/>,
350350
);
351-
fireEvent.click(screen.getByLabelText(/Authorship/));
351+
fireEvent.click(screen.getByLabelText(/Authors/));
352352
expect(controls.enter).not.toHaveBeenCalled();
353353
});
354354

355-
it('is omitted when onAuthorshipChange is not provided', () => {
355+
it('is omitted when onAttributionChange is not provided', () => {
356356
render(<ReplayDrawer state={makeState()} controls={controls} />);
357-
expect(screen.queryByLabelText(/Authorship/)).toBeNull();
357+
expect(screen.queryByLabelText(/Authors/)).toBeNull();
358358
});
359359

360-
it('applies the generating modifier class and aria-busy when authorshipGenerating is true', () => {
360+
it('applies the generating modifier class and aria-busy when attributionGenerating is true', () => {
361361
const { rerender } = render(
362362
<ReplayDrawer
363363
state={makeState()}
364364
controls={controls}
365-
authorshipOn={true}
366-
onAuthorshipChange={vi.fn()}
367-
authorshipGenerating={false}
365+
attributionOn={true}
366+
onAttributionChange={vi.fn()}
367+
attributionGenerating={false}
368368
/>,
369369
);
370-
const pill = screen.getByLabelText(/Authorship/);
371-
expect(pill.className).not.toContain('replay-drawer__authorship--generating');
370+
const pill = screen.getByLabelText(/Authors/);
371+
expect(pill.className).not.toContain('replay-drawer__attribution--generating');
372372
expect(pill.getAttribute('aria-busy')).toBeNull();
373373

374374
rerender(
375375
<ReplayDrawer
376376
state={makeState()}
377377
controls={controls}
378-
authorshipOn={true}
379-
onAuthorshipChange={vi.fn()}
380-
authorshipGenerating={true}
378+
attributionOn={true}
379+
onAttributionChange={vi.fn()}
380+
attributionGenerating={true}
381381
/>,
382382
);
383-
expect(pill.className).toContain('replay-drawer__authorship--generating');
383+
expect(pill.className).toContain('replay-drawer__attribution--generating');
384384
expect(pill.getAttribute('aria-busy')).toBe('true');
385385
});
386+
387+
it('title text follows the on/off state when enabled', () => {
388+
const { rerender } = render(
389+
<ReplayDrawer
390+
state={makeState()}
391+
controls={controls}
392+
attributionOn={false}
393+
onAttributionChange={vi.fn()}
394+
/>,
395+
);
396+
expect(screen.getByLabelText(/Authors/).getAttribute('title')).toBe('Show authors overlay');
397+
398+
rerender(
399+
<ReplayDrawer
400+
state={makeState()}
401+
controls={controls}
402+
attributionOn={true}
403+
onAttributionChange={vi.fn()}
404+
/>,
405+
);
406+
expect(screen.getByLabelText(/Authors/).getAttribute('title')).toBe('Hide authors overlay');
407+
});
408+
409+
it('renders disabled with an explanatory title when attributionDisabled is true', () => {
410+
render(
411+
<ReplayDrawer
412+
state={makeState()}
413+
controls={controls}
414+
attributionOn={false}
415+
onAttributionChange={vi.fn()}
416+
attributionDisabled={true}
417+
/>,
418+
);
419+
const pill = screen.getByLabelText(/unavailable/);
420+
expect((pill as HTMLButtonElement).disabled).toBe(true);
421+
expect(pill.getAttribute('title')).toMatch(/not available for this format/);
422+
});
423+
424+
it('suppresses on/generating modifier classes while disabled', () => {
425+
render(
426+
<ReplayDrawer
427+
state={makeState()}
428+
controls={controls}
429+
attributionOn={true}
430+
onAttributionChange={vi.fn()}
431+
attributionGenerating={true}
432+
attributionDisabled={true}
433+
/>,
434+
);
435+
const pill = screen.getByLabelText(/unavailable/);
436+
expect(pill.className).not.toContain('replay-drawer__attribution--on');
437+
expect(pill.className).not.toContain('replay-drawer__attribution--generating');
438+
});
439+
440+
it('does not fire onAttributionChange when clicked while disabled', () => {
441+
const onChange = vi.fn();
442+
render(
443+
<ReplayDrawer
444+
state={makeState()}
445+
controls={controls}
446+
attributionOn={false}
447+
onAttributionChange={onChange}
448+
attributionDisabled={true}
449+
/>,
450+
);
451+
fireEvent.click(screen.getByLabelText(/unavailable/));
452+
expect(onChange).not.toHaveBeenCalled();
453+
});
386454
});
387455
});

0 commit comments

Comments
 (0)