From 3de2d55bd7b98c1128b4524cbdaf84a6abf82daf Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 21 May 2026 11:44:30 +0100 Subject: [PATCH 1/4] 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. --- hub-client/src/components/ReplayDrawer.test.tsx | 16 ++++++++-------- hub-client/src/components/ReplayDrawer.tsx | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index acda80593..10ec90c1c 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -279,7 +279,7 @@ describe('ReplayDrawer', () => { onAuthorshipChange={vi.fn()} />, ); - expect(screen.getByLabelText(/Authorship/)).toBeDefined(); + expect(screen.getByLabelText(/Authors/)).toBeDefined(); }); it('renders in expanded state', () => { @@ -300,7 +300,7 @@ describe('ReplayDrawer', () => { onAuthorshipChange={vi.fn()} />, ); - expect(screen.getByLabelText(/Authorship/)).toBeDefined(); + expect(screen.getByLabelText(/Authors/)).toBeDefined(); }); it('reflects authorshipOn state via aria-pressed', () => { @@ -312,7 +312,7 @@ describe('ReplayDrawer', () => { onAuthorshipChange={vi.fn()} />, ); - expect(screen.getByLabelText(/Authorship/).getAttribute('aria-pressed')).toBe('false'); + expect(screen.getByLabelText(/Authors/).getAttribute('aria-pressed')).toBe('false'); rerender( { onAuthorshipChange={vi.fn()} />, ); - expect(screen.getByLabelText(/Authorship/).getAttribute('aria-pressed')).toBe('true'); + expect(screen.getByLabelText(/Authors/).getAttribute('aria-pressed')).toBe('true'); }); it('clicking toggles via onAuthorshipChange', () => { @@ -335,7 +335,7 @@ describe('ReplayDrawer', () => { onAuthorshipChange={onChange} />, ); - fireEvent.click(screen.getByLabelText(/Authorship/)); + fireEvent.click(screen.getByLabelText(/Authors/)); expect(onChange).toHaveBeenCalledWith(true); }); @@ -348,13 +348,13 @@ describe('ReplayDrawer', () => { onAuthorshipChange={vi.fn()} />, ); - fireEvent.click(screen.getByLabelText(/Authorship/)); + fireEvent.click(screen.getByLabelText(/Authors/)); expect(controls.enter).not.toHaveBeenCalled(); }); it('is omitted when onAuthorshipChange is not provided', () => { render(); - expect(screen.queryByLabelText(/Authorship/)).toBeNull(); + expect(screen.queryByLabelText(/Authors/)).toBeNull(); }); it('applies the generating modifier class and aria-busy when authorshipGenerating is true', () => { @@ -367,7 +367,7 @@ describe('ReplayDrawer', () => { authorshipGenerating={false} />, ); - const pill = screen.getByLabelText(/Authorship/); + const pill = screen.getByLabelText(/Authors/); expect(pill.className).not.toContain('replay-drawer__authorship--generating'); expect(pill.getAttribute('aria-busy')).toBeNull(); diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index 9a7b29118..50dfcf17b 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -56,12 +56,12 @@ function AuthorshipToggle({ authorshipOn, onAuthorshipChange, generating }: Auth onAuthorshipChange(!authorshipOn); }} aria-pressed={authorshipOn} - aria-label={`Authorship overlay ${authorshipOn ? 'on' : 'off'}`} + aria-label={`Authors overlay ${authorshipOn ? 'on' : 'off'}`} aria-busy={generating || undefined} title="Highlight authors" > - Authorship + Authors ); } From 960a42148ac7d68876d44c4db049bca560b397ed Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 21 May 2026 11:47:29 +0100 Subject: [PATCH 2/4] =?UTF-8?q?refactor(ui):=20unify=20Authorship=20?= =?UTF-8?q?=E2=86=92=20Attribution=20internal=20vocabulary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hub-client/src/components/Editor.tsx | 14 ++-- hub-client/src/components/ReplayDrawer.css | 32 ++++----- .../src/components/ReplayDrawer.test.tsx | 52 +++++++-------- hub-client/src/components/ReplayDrawer.tsx | 66 +++++++++---------- .../src/components/render/PreviewRouter.tsx | 14 ++-- .../src/components/render/ReactPreview.tsx | 18 ++--- .../render/iframeMessageDispatch.ts | 2 +- hub-client/src/hooks/useAttribution.ts | 4 +- hub-client/src/services/preferences/schema.ts | 2 +- hub-client/src/utils/palette.test.ts | 2 +- hub-client/src/utils/palette.ts | 2 +- 11 files changed, 104 insertions(+), 104 deletions(-) diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index de6976e32..439b2f430 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -219,16 +219,16 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC // Scroll sync state (persisted in localStorage) const [scrollSyncEnabled, setScrollSyncEnabled] = usePreference('scrollSyncEnabled'); - // Authorship overlay — session-only useState (not persisted). + // Attribution overlay — session-only useState (not persisted). // Owned here, surfaced via the toggle in the replay bar, and // threaded into ReactPreview where `useAttribution` consumes it // as the `enabled` flag. Treated as an inspection mode rather // than a setting: resets on reload so a previously-curious view // doesn't bleed into the next session. - const [authorshipOn, setAuthorshipOn] = useState(false); + const [attributionOn, setAttributionOn] = useState(false); // `useAttribution` (inside ReactPreview) reports whether it's // mid-build via `onAttributionGeneratingChange`; the flag drives - // the rotating-gradient border on the Authorship pill so a slow + // the rotating-gradient border on the Attribution pill so a slow // run-list build on a large document is visible to the user. const [attributionGenerating, setAttributionGenerating] = useState(false); // Track if editor has focus (to prevent scroll feedback loop) @@ -1066,7 +1066,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC onFormatChange={handleFormatChange} onContentRewrite={handleContentRewrite} identities={identities} - authorshipOn={authorshipOn} + attributionOn={attributionOn} onAttributionGeneratingChange={setAttributionGenerating} /> @@ -1079,9 +1079,9 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC controls={replayControls} disabled={!!currentFile && isBinaryExtension(currentFile.path)} identities={identities} - authorshipOn={authorshipOn} - onAuthorshipChange={setAuthorshipOn} - authorshipGenerating={attributionGenerating} + attributionOn={attributionOn} + onAttributionChange={setAttributionOn} + attributionGenerating={attributionGenerating} /> )} diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css index 21d675a30..2e4377e01 100644 --- a/hub-client/src/components/ReplayDrawer.css +++ b/hub-client/src/components/ReplayDrawer.css @@ -52,7 +52,7 @@ } /* Collapsed: toggle grows to fill the bar so most of it is still - clickable to enter replay, while leaving room for the Authorship + clickable to enter replay, while leaving room for the Attribution pill on the right. */ .replay-drawer--collapsed .replay-drawer__toggle { flex: 1; @@ -298,10 +298,10 @@ pointer-events: none; } -/* Authorship overlay toggle — sits flush-right in both collapsed and +/* Attribution overlay toggle — sits flush-right in both collapsed and expanded states. Off-state matches the dim transport-button look; on-state lights up with the editor accent. */ -.replay-drawer__authorship { +.replay-drawer__attribution { display: inline-flex; align-items: center; gap: 6px; @@ -321,23 +321,23 @@ user-select: none; } -.replay-drawer__authorship:hover { +.replay-drawer__attribution:hover { color: var(--editor-text); border-color: var(--editor-text-dim); } -.replay-drawer__authorship--on { +.replay-drawer__attribution--on { color: var(--editor-success); border-color: var(--editor-success); background: var(--editor-success-bg); } -.replay-drawer__authorship--on:hover { +.replay-drawer__attribution--on:hover { background: var(--editor-success); color: var(--editor-bg); } -.replay-drawer__authorship-dot { +.replay-drawer__attribution-dot { display: inline-block; width: 7px; height: 7px; @@ -347,7 +347,7 @@ transition: opacity 0.15s; } -.replay-drawer__authorship--on .replay-drawer__authorship-dot { +.replay-drawer__attribution--on .replay-drawer__attribution-dot { opacity: 1; } @@ -361,17 +361,17 @@ We intentionally keep the static border underneath — the rotating highlight rides on top, so the pill never looks "borderless" mid- animation if a browser ignores the pseudo-element. */ -@property --replay-drawer__authorship-angle { +@property --replay-drawer__attribution-angle { syntax: ''; inherits: false; initial-value: 0deg; } -.replay-drawer__authorship--generating { +.replay-drawer__attribution--generating { position: relative; } -.replay-drawer__authorship--generating::before { +.replay-drawer__attribution--generating::before { content: ''; position: absolute; inset: 0; @@ -381,7 +381,7 @@ indicator, no transparent gap. First and last stops match so the seam is invisible across the 0/360 boundary. */ background: conic-gradient( - from var(--replay-drawer__authorship-angle), + from var(--replay-drawer__attribution-angle), #ff3b30 0deg, #ff9500 60deg, #ffcc00 120deg, @@ -398,18 +398,18 @@ linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask-composite: exclude; - animation: replay-drawer__authorship-spin 2s linear infinite; + animation: replay-drawer__attribution-spin 2s linear infinite; pointer-events: none; } -@keyframes replay-drawer__authorship-spin { +@keyframes replay-drawer__attribution-spin { to { - --replay-drawer__authorship-angle: 360deg; + --replay-drawer__attribution-angle: 360deg; } } @media (prefers-reduced-motion: reduce) { - .replay-drawer__authorship--generating::before { + .replay-drawer__attribution--generating::before { animation: none; } } diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index 10ec90c1c..cde019204 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -269,14 +269,14 @@ describe('ReplayDrawer', () => { }); }); - describe('Authorship toggle', () => { - it('renders in collapsed state when authorshipOn + onAuthorshipChange are passed', () => { + describe('Attribution toggle', () => { + it('renders in collapsed state when attributionOn + onAttributionChange are passed', () => { render( , ); expect(screen.getByLabelText(/Authors/)).toBeDefined(); @@ -296,20 +296,20 @@ describe('ReplayDrawer', () => { , ); expect(screen.getByLabelText(/Authors/)).toBeDefined(); }); - it('reflects authorshipOn state via aria-pressed', () => { + it('reflects attributionOn state via aria-pressed', () => { const { rerender } = render( , ); expect(screen.getByLabelText(/Authors/).getAttribute('aria-pressed')).toBe('false'); @@ -318,21 +318,21 @@ describe('ReplayDrawer', () => { , ); expect(screen.getByLabelText(/Authors/).getAttribute('aria-pressed')).toBe('true'); }); - it('clicking toggles via onAuthorshipChange', () => { + it('clicking toggles via onAttributionChange', () => { const onChange = vi.fn(); render( , ); fireEvent.click(screen.getByLabelText(/Authors/)); @@ -344,43 +344,43 @@ describe('ReplayDrawer', () => { , ); fireEvent.click(screen.getByLabelText(/Authors/)); expect(controls.enter).not.toHaveBeenCalled(); }); - it('is omitted when onAuthorshipChange is not provided', () => { + it('is omitted when onAttributionChange is not provided', () => { render(); expect(screen.queryByLabelText(/Authors/)).toBeNull(); }); - it('applies the generating modifier class and aria-busy when authorshipGenerating is true', () => { + it('applies the generating modifier class and aria-busy when attributionGenerating is true', () => { const { rerender } = render( , ); const pill = screen.getByLabelText(/Authors/); - expect(pill.className).not.toContain('replay-drawer__authorship--generating'); + expect(pill.className).not.toContain('replay-drawer__attribution--generating'); expect(pill.getAttribute('aria-busy')).toBeNull(); rerender( , ); - expect(pill.className).toContain('replay-drawer__authorship--generating'); + expect(pill.className).toContain('replay-drawer__attribution--generating'); expect(pill.getAttribute('aria-busy')).toBe('true'); }); }); diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index 50dfcf17b..7f4d2a00e 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -11,39 +11,39 @@ interface Props { disabled?: boolean; identities?: Record; /** - * Authorship overlay state. Lives alongside replay because both + * Attribution overlay state. Lives alongside replay because both * surfaces share the same per-actor colour palette and the - * authorship inspection is a peer of replay. Both props must be + * attribution inspection is a peer of replay. Both props must be * supplied to render the toggle; if either is omitted (e.g. in * a non-editor surface) the toggle is hidden. * * Session-only — kept as React state in the parent, never * persisted, so the overlay resets on reload. */ - authorshipOn?: boolean; - onAuthorshipChange?: (next: boolean) => void; + attributionOn?: boolean; + onAttributionChange?: (next: boolean) => void; /** * Whether the attribution producer (`useAttribution` in * ReactPreview) is currently building or updating the payload. * When true the pill border animates with a rotating gradient so * the user knows work is happening on a large document. Default - * `false`; effectively gated by `authorshipOn` upstream because + * `false`; effectively gated by `attributionOn` upstream because * the hook only generates when the toggle is on. */ - authorshipGenerating?: boolean; + attributionGenerating?: boolean; } -interface AuthorshipToggleProps { - authorshipOn: boolean; - onAuthorshipChange: (next: boolean) => void; +interface AttributionToggleProps { + attributionOn: boolean; + onAttributionChange: (next: boolean) => void; generating: boolean; } -function AuthorshipToggle({ authorshipOn, onAuthorshipChange, generating }: AuthorshipToggleProps) { +function AttributionToggle({ attributionOn, onAttributionChange, generating }: AttributionToggleProps) { const classes = [ - 'replay-drawer__authorship', - authorshipOn && 'replay-drawer__authorship--on', - generating && 'replay-drawer__authorship--generating', + 'replay-drawer__attribution', + attributionOn && 'replay-drawer__attribution--on', + generating && 'replay-drawer__attribution--generating', ] .filter(Boolean) .join(' '); @@ -53,15 +53,15 @@ function AuthorshipToggle({ authorshipOn, onAuthorshipChange, generating }: Auth className={classes} onClick={(e) => { e.stopPropagation(); - onAuthorshipChange(!authorshipOn); + onAttributionChange(!attributionOn); }} - aria-pressed={authorshipOn} - aria-label={`Authors overlay ${authorshipOn ? 'on' : 'off'}`} + aria-pressed={attributionOn} + aria-label={`Authors overlay ${attributionOn ? 'on' : 'off'}`} aria-busy={generating || undefined} title="Highlight authors" > - - Authors + + Authors ); } @@ -98,12 +98,12 @@ export default function ReplayDrawer({ controls, disabled, identities, - authorshipOn, - onAuthorshipChange, - authorshipGenerating, + attributionOn, + onAttributionChange, + attributionGenerating, }: Props) { - const showAuthorshipToggle = - authorshipOn !== undefined && onAuthorshipChange !== undefined; + const showAttributionToggle = + attributionOn !== undefined && onAttributionChange !== undefined; const currentActorId = getActorId(); const drawerRef = useRef(null); @@ -207,11 +207,11 @@ export default function ReplayDrawer({ Replay - {showAuthorshipToggle && ( - )} @@ -269,11 +269,11 @@ export default function ReplayDrawer({ - {showAuthorshipToggle && ( - )} diff --git a/hub-client/src/components/render/PreviewRouter.tsx b/hub-client/src/components/render/PreviewRouter.tsx index 3c5fb3785..a47714506 100644 --- a/hub-client/src/components/render/PreviewRouter.tsx +++ b/hub-client/src/components/render/PreviewRouter.tsx @@ -32,20 +32,20 @@ interface PreviewRouterProps { onContentRewrite: (content: string) => void; /** * Automerge actor → display identity (name + colour). Threaded - * through to ReactPreview's `useAttribution` so the Authorship + * through to ReactPreview's `useAttribution` so the Attribution * overlay uses profile-metadata names where available, instead * of the `actor.slice(0, 8)` fallback hash. */ identities?: Record; /** - * Authorship overlay on/off. Session-only — owned by `Editor.tsx` + * Attribution overlay on/off. Session-only — owned by `Editor.tsx` * as `useState`, threaded down here and into `ReactPreview` to * drive `useAttribution`. */ - authorshipOn: boolean; + attributionOn: boolean; /** * Reports `useAttribution`'s in-flight state up to `Editor.tsx` so - * the Authorship pill can animate its border while attribution + * the Attribution pill can animate its border while attribution * data is being generated. Only fires from the ReactPreview branch; * the non-React `Preview` branch never computes attribution. */ @@ -135,9 +135,9 @@ export default function PreviewRouter(props: PreviewRouterProps) { } // Render the appropriate preview component with shared WASM error banner. - // `identities` and `authorshipOn` are for ReactPreview only — Preview + // `identities` and `attributionOn` are for ReactPreview only — Preview // doesn't know about either. - const { onRegisterScrollToLine, onRegisterSetScrollRatio, onFormatChange, onContentRewrite, fileContents, identities, authorshipOn, onAttributionGeneratingChange, ...commonProps } = props; + const { onRegisterScrollToLine, onRegisterSetScrollRatio, onFormatChange, onContentRewrite, fileContents, identities, attributionOn, onAttributionGeneratingChange, ...commonProps } = props; return (
@@ -147,7 +147,7 @@ export default function PreviewRouter(props: PreviewRouterProps) { )}
{reactFormat ? ( - + ) : ( // Phase 9 Decision 6: pass `fileContents` so any sibling // edit (including `_quarto.yml`) triggers a re-render via diff --git a/hub-client/src/components/render/ReactPreview.tsx b/hub-client/src/components/render/ReactPreview.tsx index ca426ba79..fa34e36ca 100644 --- a/hub-client/src/components/render/ReactPreview.tsx +++ b/hub-client/src/components/render/ReactPreview.tsx @@ -55,14 +55,14 @@ interface PreviewProps { */ identities?: Record; /** - * Authorship overlay on/off. Session-only, owned by `Editor.tsx` + * Attribution overlay on/off. Session-only, owned by `Editor.tsx` * and driven by the toggle in the replay bar. When false, * `useAttribution` short-circuits and the WASM call falls through * to the byte-identical no-attribution path. */ - authorshipOn: boolean; + attributionOn: boolean; /** - * Reports whether `useAttribution` is mid-build. The Authorship + * Reports whether `useAttribution` is mid-build. The Attribution * pill animates its border while true so a long run-list build on * a large document gives visible feedback that work is happening. * Called once on mount with the current value and once on unmount @@ -248,7 +248,7 @@ export default function ReactPreview({ onContentRewrite, format, identities, - authorshipOn, + attributionOn, onAttributionGeneratingChange, }: PreviewProps) { // Preview state machine for error handling @@ -284,25 +284,25 @@ export default function ReactPreview({ // // `useAttribution` returns the JSON payload (`{ runs, identities }`) // for `parseQmdToAstWithAttribution`. The hook short-circuits when - // `enabled` is false (Authorship toggle off), in which case the + // `enabled` is false (Attribution toggle off), in which case the // payload stays `null` and the WASM call falls through to the // byte-identical no-attribution path. // - // `enabled` is driven by the session-only `authorshipOn` prop owned - // by `Editor.tsx`, surfaced as the Authorship toggle in the replay + // `enabled` is driven by the session-only `attributionOn` prop owned + // by `Editor.tsx`, surfaced as the Attribution toggle in the replay // bar. `identities` is the Automerge actor → display-name/colour // table threaded down from `Editor.tsx`; missing entries fall back // to the hook's `(actor.slice(0, 8), actorColor(fnv1aHex8(actor)))` // so the Phase 6 producer invariant always holds. const { payload: attributionPayload, generating: attributionGenerating } = useAttribution({ - enabled: authorshipOn, + enabled: attributionOn, filePath: currentFile?.path ?? null, sourceText: content, identities: identities ?? {}, }); - // Surface the hook's generating flag to the Authorship pill in the + // Surface the hook's generating flag to the Attribution pill in the // replay bar. Cleanup emits `false` on unmount so switching to a // non-q2 file (where ReactPreview tears down) clears the indicator. useEffect(() => { diff --git a/hub-client/src/components/render/iframeMessageDispatch.ts b/hub-client/src/components/render/iframeMessageDispatch.ts index 49e0a5eae..2c2b77094 100644 --- a/hub-client/src/components/render/iframeMessageDispatch.ts +++ b/hub-client/src/components/render/iframeMessageDispatch.ts @@ -23,7 +23,7 @@ * to align such that the *second-arrived* message fired first and * the *first-arrived* message overwrote it. In the attribution * pipeline this manifested as the no-attribution AST clobbering the - * with-attribution AST, so the Authorship colouring never appeared + * with-attribution AST, so the Attribution colouring never appeared * on first render for large files with `render-components: - * html.tsx`. See `iframeMessageDispatch.test.ts` for the * deterministic reproduction. diff --git a/hub-client/src/hooks/useAttribution.ts b/hub-client/src/hooks/useAttribution.ts index 2a87639d8..865746c0b 100644 --- a/hub-client/src/hooks/useAttribution.ts +++ b/hub-client/src/hooks/useAttribution.ts @@ -15,7 +15,7 @@ * `astContext.attribution` + `astContext.attributionActors` for the * q2-debug renderer to consume. * - * **Disabled path:** when `enabled` is false (Authorship toggle off), + * **Disabled path:** when `enabled` is false (Attribution toggle off), * the hook short-circuits and returns `null`. Callers then route through * `parseQmdToAst(content)` (or `parseQmdToAstWithAttribution(content, null)`), * which is byte-identical to today's q2-debug output (Phase 0 test #10). @@ -136,7 +136,7 @@ export interface UseAttributionResult { /** * True while the hook is doing work the user is waiting on: the * cold-start build, the debounced incremental update window, and - * the synchronous update step itself. Drives the Authorship pill's + * the synchronous update step itself. Drives the Attribution pill's * "work in progress" border animation upstream. * * Distinct from `payload === null` because incremental updates diff --git a/hub-client/src/services/preferences/schema.ts b/hub-client/src/services/preferences/schema.ts index 526845578..2e81fafbc 100644 --- a/hub-client/src/services/preferences/schema.ts +++ b/hub-client/src/services/preferences/schema.ts @@ -6,7 +6,7 @@ export type ColorScheme = z.infer; // Schema definition - single source of truth // -// The Authorship overlay is intentionally NOT persisted. It lives as +// The Attribution overlay is intentionally NOT persisted. It lives as // session-only React state in Editor.tsx and is toggled via the pill // in the replay bar — treating it as an inspection mode rather than a // setting avoids leaking a previously-curious view across reloads. diff --git a/hub-client/src/utils/palette.test.ts b/hub-client/src/utils/palette.test.ts index 760bddd99..bdd958283 100644 --- a/hub-client/src/utils/palette.test.ts +++ b/hub-client/src/utils/palette.test.ts @@ -6,7 +6,7 @@ import { actorColor, fnv1aHex8 } from './palette'; // `crates/quarto-core/src/attribution/palette.rs`. A divergence here // is a producer/consumer drift bug; the rendered colour for a given // actor would no longer match between the replay drawer and the -// Authorship overlay. +// Attribution overlay. describe('actorColor', () => { it('matches the Rust `actor_color` formula for known inputs', () => { diff --git a/hub-client/src/utils/palette.ts b/hub-client/src/utils/palette.ts index d6b657d57..f7d32d097 100644 --- a/hub-client/src/utils/palette.ts +++ b/hub-client/src/utils/palette.ts @@ -3,7 +3,7 @@ * (`useReplayMode` / `ReplayDrawer`) and the attribution producer * (`useAttribution`). Both must produce identical visual output for the * same actor — colours seen during replay must match colours seen on - * Authorship overlays. + * Attribution overlays. * * **Drift discipline.** Both functions MUST stay bit-for-bit identical * with their Rust siblings in From 9eac07d81a69712d922c1ad2ca96b3df0866610b Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 21 May 2026 11:52:33 +0100 Subject: [PATCH 3/4] feat(replay-drawer): disable Authors pill on unsupported formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hub-client/src/components/Editor.tsx | 3 ++ hub-client/src/components/ReplayDrawer.css | 9 ++++ .../src/components/ReplayDrawer.test.tsx | 46 +++++++++++++++++++ hub-client/src/components/ReplayDrawer.tsx | 33 ++++++++++--- 4 files changed, 84 insertions(+), 7 deletions(-) diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index 439b2f430..ee01f5421 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -1082,6 +1082,9 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC attributionOn={attributionOn} onAttributionChange={setAttributionOn} attributionGenerating={attributionGenerating} + attributionDisabled={ + currentFormat !== 'q2-debug' && currentFormat !== 'q2-preview' + } /> )} diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css index 2e4377e01..ce475658d 100644 --- a/hub-client/src/components/ReplayDrawer.css +++ b/hub-client/src/components/ReplayDrawer.css @@ -326,6 +326,15 @@ border-color: var(--editor-text-dim); } +.replay-drawer__attribution:disabled, +.replay-drawer__attribution:disabled:hover { + cursor: not-allowed; + opacity: 0.4; + color: var(--editor-text-dim); + border-color: var(--editor-accent-border); + background: var(--editor-accent-bg); +} + .replay-drawer__attribution--on { color: var(--editor-success); border-color: var(--editor-success); diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index cde019204..a2135ce02 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -383,5 +383,51 @@ describe('ReplayDrawer', () => { expect(pill.className).toContain('replay-drawer__attribution--generating'); expect(pill.getAttribute('aria-busy')).toBe('true'); }); + + it('renders disabled with an explanatory title when attributionDisabled is true', () => { + render( + , + ); + const pill = screen.getByLabelText(/unavailable/); + expect((pill as HTMLButtonElement).disabled).toBe(true); + expect(pill.getAttribute('title')).toMatch(/not available for this format/); + }); + + it('suppresses on/generating modifier classes while disabled', () => { + render( + , + ); + const pill = screen.getByLabelText(/unavailable/); + expect(pill.className).not.toContain('replay-drawer__attribution--on'); + expect(pill.className).not.toContain('replay-drawer__attribution--generating'); + }); + + it('does not fire onAttributionChange when clicked while disabled', () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByLabelText(/unavailable/)); + expect(onChange).not.toHaveBeenCalled(); + }); }); }); diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index 7f4d2a00e..ab6004483 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -31,22 +31,37 @@ interface Props { * the hook only generates when the toggle is on. */ attributionGenerating?: boolean; + /** + * When true the pill renders greyed-out and non-interactive. Used + * for formats that don't surface attribution visually + * (everything but q2-debug / q2-preview today). The `attributionOn` + * state is preserved across the disabled period so toggling back + * to a supported format restores the user's previous preference. + */ + attributionDisabled?: boolean; } interface AttributionToggleProps { attributionOn: boolean; onAttributionChange: (next: boolean) => void; generating: boolean; + disabled: boolean; } -function AttributionToggle({ attributionOn, onAttributionChange, generating }: AttributionToggleProps) { +function AttributionToggle({ attributionOn, onAttributionChange, generating, disabled }: AttributionToggleProps) { const classes = [ 'replay-drawer__attribution', - attributionOn && 'replay-drawer__attribution--on', - generating && 'replay-drawer__attribution--generating', + attributionOn && !disabled && 'replay-drawer__attribution--on', + generating && !disabled && 'replay-drawer__attribution--generating', ] .filter(Boolean) .join(' '); + const titleText = disabled + ? 'Authors overlay is not available for this format' + : 'Highlight authors'; + const ariaLabel = disabled + ? 'Authors overlay unavailable for this format' + : `Authors overlay ${attributionOn ? 'on' : 'off'}`; return (
@@ -274,6 +292,7 @@ export default function ReplayDrawer({ attributionOn={attributionOn!} onAttributionChange={onAttributionChange!} generating={!!attributionGenerating} + disabled={!!attributionDisabled} /> )}
From c9d87529d095842cf971aaee9ca2dcd8ea9bc5fe Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 21 May 2026 11:56:19 +0100 Subject: [PATCH 4/4] 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. --- .../src/components/ReplayDrawer.test.tsx | 22 +++++++++++++++++++ hub-client/src/components/ReplayDrawer.tsx | 4 +++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index a2135ce02..99e966c23 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -384,6 +384,28 @@ describe('ReplayDrawer', () => { expect(pill.getAttribute('aria-busy')).toBe('true'); }); + it('title text follows the on/off state when enabled', () => { + const { rerender } = render( + , + ); + expect(screen.getByLabelText(/Authors/).getAttribute('title')).toBe('Show authors overlay'); + + rerender( + , + ); + expect(screen.getByLabelText(/Authors/).getAttribute('title')).toBe('Hide authors overlay'); + }); + it('renders disabled with an explanatory title when attributionDisabled is true', () => { render(