Skip to content

Commit 8cf443c

Browse files
committed
feat(hub-client): attributionEnabled preference + Authorship toggle + identity threading
Closes the Phase 5c port. Restores the bits dropped from the rebase that wire the renderer-side colouring (commit a0416b23) into the user-facing toggle and the Automerge identity table: - services/preferences/schema.ts: attributionEnabled boolean with z.default(false) so existing localStorage entries forward-compat without resetting other preferences. - components/tabs/SettingsTab.tsx: "Authorship" checkbox under Settings → Preview. - components/render/ReactPreview.tsx: read the preference via usePreference, drive useAttribution({ enabled }); accept an optional `identities` prop and pass it as the identities table so profile-metadata names win over the fnv1a fallback when available. - components/render/PreviewRouter.tsx: pass-through for the new prop (Preview doesn't take it). - components/Editor.tsx: thread the existing `identities` Automerge-presence map down to PreviewRouter. - hooks/usePreference.test.tsx: re-target the cross-instance reactivity test at attributionEnabled (its original Phase 5c fixture), undoing the temporary errorOverlayCollapsed decoupling done while the preference was absent. Off path stays byte-identical: when the toggle is off, useAttribution short-circuits to a null payload and the WASM call falls through to the unflagged q2-debug path.
1 parent 10dd3cf commit 8cf443c

4 files changed

Lines changed: 47 additions & 12 deletions

File tree

hub-client/src/components/Editor.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
10521052
onSlideChange={handleSlideChange}
10531053
onFormatChange={handleFormatChange}
10541054
onContentRewrite={handleContentRewrite}
1055+
identities={identities}
10551056
/>
10561057
</div>
10571058
</main>

hub-client/src/components/render/PreviewRouter.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type * as Monaco from 'monaco-editor';
33
import type { FileEntry } from '../../types/project';
44
import { isQmdFile } from '../../types/project';
55
import type { Diagnostic } from '../../types/diagnostic';
6+
import type { ActorIdentity } from '../../services/automergeSync';
67
import { parseQmdToAst, isWasmReady, initWasm } from '../../services/wasmRenderer';
78
import Preview from './Preview';
89
import ReactPreview from './ReactPreview';
@@ -29,6 +30,13 @@ interface PreviewRouterProps {
2930
onSlideChange?: (slideIndex: number) => void;
3031
onFormatChange?: (format: string | null) => void;
3132
onContentRewrite: (content: string) => void;
33+
/**
34+
* Automerge actor → display identity (name + colour). Threaded
35+
* through to ReactPreview's `useAttribution` so the Authorship
36+
* overlay uses profile-metadata names where available, instead
37+
* of the `actor.slice(0, 8)` fallback hash.
38+
*/
39+
identities?: Record<string, ActorIdentity>;
3240
}
3341

3442
/**
@@ -113,8 +121,9 @@ export default function PreviewRouter(props: PreviewRouterProps) {
113121
return <NonQmdPlaceholderView filename={props.currentFile?.path ?? 'no currentFile path'} />;
114122
}
115123

116-
// Render the appropriate preview component with shared WASM error banner
117-
const { onRegisterScrollToLine, onRegisterSetScrollRatio, onFormatChange, onContentRewrite, fileContents, ...commonProps } = props;
124+
// Render the appropriate preview component with shared WASM error banner.
125+
// `identities` is for ReactPreview only — Preview doesn't know about it.
126+
const { onRegisterScrollToLine, onRegisterSetScrollRatio, onFormatChange, onContentRewrite, fileContents, identities, ...commonProps } = props;
118127

119128
return (
120129
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
@@ -124,7 +133,7 @@ export default function PreviewRouter(props: PreviewRouterProps) {
124133
)}
125134
<div style={{ flex: 1, overflow: 'hidden' }}>
126135
{reactFormat ? (
127-
<ReactPreview {...commonProps} onContentRewrite={onContentRewrite} fileContents={fileContents} format={reactFormat} />
136+
<ReactPreview {...commonProps} onContentRewrite={onContentRewrite} fileContents={fileContents} format={reactFormat} identities={identities} />
128137
) : (
129138
// Phase 9 Decision 6: pass `fileContents` so any sibling
130139
// edit (including `_quarto.yml`) triggers a re-render via

hub-client/src/components/render/ReactPreview.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
22
import type * as Monaco from 'monaco-editor';
33
import type { FileEntry } from '../../types/project';
44
import type { Diagnostic } from '../../types/diagnostic';
5+
import type { ActorIdentity } from '../../services/automergeSync';
56
import {
67
parseQmdToAstWithAttribution,
78
renderPageInProject,
@@ -10,6 +11,7 @@ import {
1011
} from '../../services/wasmRenderer';
1112
import { pipelineKindForFormat } from '../../utils/pipelineKind';
1213
import { useAttribution } from '../../hooks/useAttribution';
14+
import { usePreference } from '../../hooks/usePreference';
1315
import { stripAnsi } from '../../utils/stripAnsi';
1416
import { PreviewErrorOverlay } from './PreviewErrorOverlay';
1517
import ReactRenderer from './ReactRenderer';
@@ -44,6 +46,14 @@ interface PreviewProps {
4446
onSlideChange?: (slideIndex: number) => void;
4547
onContentRewrite: (content: string) => void;
4648
format: string; // 'q2-slides', 'q2-debug', or 'q2-preview'
49+
/**
50+
* Automerge actor → display identity (name + colour). Consumed
51+
* by `useAttribution` to fill the `identities` half of the
52+
* attribution payload. Falls back to `actor.slice(0, 8)` +
53+
* `fnv1aHex8`-derived colour for any actor without a profile
54+
* entry, so the Phase 6 producer invariant always holds.
55+
*/
56+
identities?: Record<string, ActorIdentity>;
4757
}
4858

4959
// Result of rendering QMD content to AST
@@ -205,6 +215,7 @@ export default function ReactPreview({
205215
onSlideChange,
206216
onContentRewrite,
207217
format,
218+
identities,
208219
}: PreviewProps) {
209220
// Preview state machine for error handling
210221
const [previewState, setPreviewState] = useState<PreviewState>('START');
@@ -239,18 +250,19 @@ export default function ReactPreview({
239250
// payload stays `null` and the WASM call falls through to the
240251
// byte-identical no-attribution path.
241252
//
242-
// The toggle UI is intentionally a separate piece of work — once a
243-
// Authorship switch is plumbed through `Editor.tsx`, flipping
244-
// `enabled: false` to `enabled: someToggleState` is all that's
245-
// needed. Profile-metadata identity lookup (`identities`) can be
246-
// wired the same way; until then the hook uses its
247-
// `(actor.slice(0, 8), actorColor(fnv1aHex8(actor)))` fallback so
248-
// the Phase 6 producer invariant still holds.
253+
// Phase 5c — `enabled` is driven by the persisted
254+
// `attributionEnabled` preference, surfaced as the "Authorship"
255+
// checkbox under Settings → Preview. `identities` is the
256+
// Automerge actor → display-name/colour table threaded down from
257+
// `Editor.tsx`; missing entries fall back to the hook's
258+
// `(actor.slice(0, 8), actorColor(fnv1aHex8(actor)))` so the
259+
// Phase 6 producer invariant always holds.
260+
const [attributionEnabled] = usePreference('attributionEnabled');
249261
const attributionPayload = useAttribution({
250-
enabled: false,
262+
enabled: attributionEnabled,
251263
filePath: currentFile?.path ?? null,
252264
sourceText: content,
253-
identities: {},
265+
identities: identities ?? {},
254266
});
255267

256268
// Debounce rendering

hub-client/src/components/tabs/SettingsTab.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export default function SettingsTab({
2222
onScrollSyncChange,
2323
}: SettingsTabProps) {
2424
const [errorOverlayCollapsed, setErrorOverlayCollapsed] = usePreference('errorOverlayCollapsed');
25+
const [attributionEnabled, setAttributionEnabled] = usePreference('attributionEnabled');
2526
const [isCapturing, setIsCapturing] = useState(false);
2627

2728
const handleScreenshot = async () => {
@@ -93,6 +94,18 @@ export default function SettingsTab({
9394
Show errors as a small indicator instead of expanded panel
9495
</span>
9596
</label>
97+
<label className="setting-toggle">
98+
<input
99+
type="checkbox"
100+
checked={attributionEnabled}
101+
onChange={(e) => setAttributionEnabled(e.target.checked)}
102+
/>
103+
<span className="setting-name">Authorship</span>
104+
<span className="setting-description">
105+
Colour each node by its last-touch Automerge author in the
106+
debug preview
107+
</span>
108+
</label>
96109
<div style={{ marginTop: '16px' }}>
97110
<button
98111
className="screenshot-button"

0 commit comments

Comments
 (0)