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
17 changes: 10 additions & 7 deletions hub-client/src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}
/>
</div>
Expand All @@ -1079,9 +1079,12 @@ 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}
attributionDisabled={
currentFormat !== 'q2-debug' && currentFormat !== 'q2-preview'
}
/>
)}

Expand Down
41 changes: 25 additions & 16 deletions hub-client/src/components/ReplayDrawer.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -321,23 +321,32 @@
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: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);
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;
Expand All @@ -347,7 +356,7 @@
transition: opacity 0.15s;
}

.replay-drawer__authorship--on .replay-drawer__authorship-dot {
.replay-drawer__attribution--on .replay-drawer__attribution-dot {
opacity: 1;
}

Expand All @@ -361,17 +370,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: '<angle>';
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;
Expand All @@ -381,7 +390,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,
Expand All @@ -398,18 +407,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;
}
}
136 changes: 102 additions & 34 deletions hub-client/src/components/ReplayDrawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,17 +269,17 @@ 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(
<ReplayDrawer
state={makeState()}
controls={controls}
authorshipOn={false}
onAuthorshipChange={vi.fn()}
attributionOn={false}
onAttributionChange={vi.fn()}
/>,
);
expect(screen.getByLabelText(/Authorship/)).toBeDefined();
expect(screen.getByLabelText(/Authors/)).toBeDefined();
});

it('renders in expanded state', () => {
Expand All @@ -296,46 +296,46 @@ describe('ReplayDrawer', () => {
<ReplayDrawer
state={activeState}
controls={controls}
authorshipOn={false}
onAuthorshipChange={vi.fn()}
attributionOn={false}
onAttributionChange={vi.fn()}
/>,
);
expect(screen.getByLabelText(/Authorship/)).toBeDefined();
expect(screen.getByLabelText(/Authors/)).toBeDefined();
});

it('reflects authorshipOn state via aria-pressed', () => {
it('reflects attributionOn state via aria-pressed', () => {
const { rerender } = render(
<ReplayDrawer
state={makeState()}
controls={controls}
authorshipOn={false}
onAuthorshipChange={vi.fn()}
attributionOn={false}
onAttributionChange={vi.fn()}
/>,
);
expect(screen.getByLabelText(/Authorship/).getAttribute('aria-pressed')).toBe('false');
expect(screen.getByLabelText(/Authors/).getAttribute('aria-pressed')).toBe('false');

rerender(
<ReplayDrawer
state={makeState()}
controls={controls}
authorshipOn={true}
onAuthorshipChange={vi.fn()}
attributionOn={true}
onAttributionChange={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', () => {
it('clicking toggles via onAttributionChange', () => {
const onChange = vi.fn();
render(
<ReplayDrawer
state={makeState()}
controls={controls}
authorshipOn={false}
onAuthorshipChange={onChange}
attributionOn={false}
onAttributionChange={onChange}
/>,
);
fireEvent.click(screen.getByLabelText(/Authorship/));
fireEvent.click(screen.getByLabelText(/Authors/));
expect(onChange).toHaveBeenCalledWith(true);
});

Expand All @@ -344,44 +344,112 @@ describe('ReplayDrawer', () => {
<ReplayDrawer
state={makeState()}
controls={controls}
authorshipOn={false}
onAuthorshipChange={vi.fn()}
attributionOn={false}
onAttributionChange={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', () => {
it('is omitted when onAttributionChange is not provided', () => {
render(<ReplayDrawer state={makeState()} controls={controls} />);
expect(screen.queryByLabelText(/Authorship/)).toBeNull();
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(
<ReplayDrawer
state={makeState()}
controls={controls}
authorshipOn={true}
onAuthorshipChange={vi.fn()}
authorshipGenerating={false}
attributionOn={true}
onAttributionChange={vi.fn()}
attributionGenerating={false}
/>,
);
const pill = screen.getByLabelText(/Authorship/);
expect(pill.className).not.toContain('replay-drawer__authorship--generating');
const pill = screen.getByLabelText(/Authors/);
expect(pill.className).not.toContain('replay-drawer__attribution--generating');
expect(pill.getAttribute('aria-busy')).toBeNull();

rerender(
<ReplayDrawer
state={makeState()}
controls={controls}
authorshipOn={true}
onAuthorshipChange={vi.fn()}
authorshipGenerating={true}
attributionOn={true}
onAttributionChange={vi.fn()}
attributionGenerating={true}
/>,
);
expect(pill.className).toContain('replay-drawer__authorship--generating');
expect(pill.className).toContain('replay-drawer__attribution--generating');
expect(pill.getAttribute('aria-busy')).toBe('true');
});

it('title text follows the on/off state when enabled', () => {
const { rerender } = render(
<ReplayDrawer
state={makeState()}
controls={controls}
attributionOn={false}
onAttributionChange={vi.fn()}
/>,
);
expect(screen.getByLabelText(/Authors/).getAttribute('title')).toBe('Show authors overlay');

rerender(
<ReplayDrawer
state={makeState()}
controls={controls}
attributionOn={true}
onAttributionChange={vi.fn()}
/>,
);
expect(screen.getByLabelText(/Authors/).getAttribute('title')).toBe('Hide authors overlay');
});

it('renders disabled with an explanatory title when attributionDisabled is true', () => {
render(
<ReplayDrawer
state={makeState()}
controls={controls}
attributionOn={false}
onAttributionChange={vi.fn()}
attributionDisabled={true}
/>,
);
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(
<ReplayDrawer
state={makeState()}
controls={controls}
attributionOn={true}
onAttributionChange={vi.fn()}
attributionGenerating={true}
attributionDisabled={true}
/>,
);
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(
<ReplayDrawer
state={makeState()}
controls={controls}
attributionOn={false}
onAttributionChange={onChange}
attributionDisabled={true}
/>,
);
fireEvent.click(screen.getByLabelText(/unavailable/));
expect(onChange).not.toHaveBeenCalled();
});
});
});
Loading