Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
f94925b
attribution pipeline: Phase 0 (TDD) — API skeleton + red tests
shikokuchuo May 11, 2026
40f2f41
attribution plan: mark Phase 0 checklist items complete
shikokuchuo May 12, 2026
16fcf64
attribution pipeline: Phase 1 — canonical types live
shikokuchuo May 12, 2026
7d77f2c
attribution pipeline: Phase 2 — generate transform live
shikokuchuo May 12, 2026
fe83bb7
attribution pipeline: Phase 3 — providers + CLI flag live
shikokuchuo May 12, 2026
750bc44
attribution pipeline: Phase 4 — render transform + HTML data-attr emi…
shikokuchuo May 12, 2026
662b06c
attribution pipeline: Phase 5a — q2-debug JSON wire shape + TS palett…
shikokuchuo May 12, 2026
f55702d
attribution pipeline: Phase 5b — TS producer + ReactPreview wire-up
shikokuchuo May 12, 2026
1e7c378
attribution pipeline: Phase 6 — producer-invariant tests + user docs
shikokuchuo May 12, 2026
f68fd57
attribution pipeline: Phase 7 — verification + deviation log
shikokuchuo May 12, 2026
ff41885
attribution pipeline: Phase 4b — HTML prose coalescing
shikokuchuo May 12, 2026
91cfe94
fix(hub-client): usePreference cross-instance reactivity + declare at…
shikokuchuo May 12, 2026
b61104b
changelog: usePreference cross-instance reactivity fix
shikokuchuo May 12, 2026
b4fea95
attribution pipeline: regenerate data-flow SVG (v2) to match shipped …
shikokuchuo May 12, 2026
7efb84d
fix(quarto-core): default new JsonConfig attribution fields at q2-pre…
shikokuchuo May 12, 2026
10dd3cf
feat(hub-client): port Phase 5c Authorship renderer colouring to fram…
shikokuchuo May 12, 2026
8cf443c
feat(hub-client): attributionEnabled preference + Authorship toggle +…
shikokuchuo May 12, 2026
df20065
changelog: Authorship toggle + renderer port for 10dd3cfc + 8cf443c1
shikokuchuo May 12, 2026
7ceb42c
fix(hub-client): serialize iframe UPDATE_AST through shared load promise
shikokuchuo May 12, 2026
d992d04
changelog: iframe message-dispatch race fix for 7ceb42c0
shikokuchuo May 12, 2026
f4f31b1
feat(quarto-core): wire attribution data through q2-preview pipeline
shikokuchuo May 13, 2026
5194cc5
feat(hub-client): call render_page_in_project_with_attribution from q…
shikokuchuo May 13, 2026
3827348
feat(hub-client): port Phase 5c Authorship renderer colouring to q2-p…
shikokuchuo May 13, 2026
46be15e
changelog: q2-preview Authorship wiring for 5194cc59 + 38273485
shikokuchuo May 13, 2026
b5baec4
refactor(quarto-core): centralize attribution writer-config translation
shikokuchuo May 13, 2026
b6b03dd
refactor(hub-client): consolidate attribution wrap + hover wiring
shikokuchuo May 13, 2026
a322e93
docs(hub-client): changelog for b6b03dde
shikokuchuo May 13, 2026
2ed023b
refactor(quarto): derive ValueEnum on AttributionMode
shikokuchuo May 13, 2026
1243fdf
refactor(quarto-core): rename from_config_value to identity_map_from_…
shikokuchuo May 13, 2026
2686203
refactor(hub-client): move actor palette helpers into utils/
shikokuchuo May 13, 2026
925ac57
docs(hub-client): changelog for 26862035; escape tilde in b6b03dde entry
shikokuchuo May 13, 2026
a728f99
refactor(quarto-core): tighten AttributionDataBuilder API
shikokuchuo May 13, 2026
f15e0bd
attribution pipeline: docs for static viewer overlay
shikokuchuo May 14, 2026
28f523b
attribution pipeline: tighten --attribution=git e2e regression test
shikokuchuo May 14, 2026
00bc014
attribution pipeline: unit test for formatRelativeTime
shikokuchuo May 14, 2026
4462555
attribution pipeline: unit test for buildAttributionPayload
shikokuchuo May 14, 2026
8b8349c
feat(attribution): auto-inject default viewer CSS/JS for HTML renders
shikokuchuo May 14, 2026
006ee4b
docs(hub-client): changelog for 8b8349c8
shikokuchuo May 14, 2026
f7e8c5c
fix(attribution): use committer-time, not author-time, for viewer fre…
shikokuchuo May 14, 2026
30d4692
refactor(attribution): move identity to render-time CSS variables
shikokuchuo May 15, 2026
7001629
feat(attribution): swap HSL palette for Tol Muted (10-colour CBS)
shikokuchuo May 15, 2026
3aa8a80
docs(hub-client): changelog for 70016298
shikokuchuo May 15, 2026
af7b21e
docs(attribution): update for CSS-variable wire shape and Tol Muted
shikokuchuo May 15, 2026
8203fac
docs(hub-client): scope the palette-change entry to CLI rendering
shikokuchuo May 15, 2026
6440445
refactor(hub-client): move attribution identity to render-time CSS
shikokuchuo May 15, 2026
6edcb28
docs(hub-client): changelog for 64404459
shikokuchuo May 15, 2026
00a6f6f
fix(attribution): scope dotted underline + help cursor to the CLI
shikokuchuo May 15, 2026
5228165
refactor(hub-client): move Authorship toggle to replay bar, drop pers…
shikokuchuo May 15, 2026
4b50afa
changelog: Authorship toggle relocation for 52281655
shikokuchuo May 15, 2026
f9fbc30
Update tooltip
shikokuchuo May 15, 2026
e939909
feat(hub-client): animate Authorship pill while attribution builds
shikokuchuo May 15, 2026
61c6c0a
changelog: Authorship pill animation for e9399093
shikokuchuo May 15, 2026
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
Prev Previous commit
Next Next commit
refactor(hub-client): move attribution identity to render-time CSS
Mirror the CLI refactor (30d4692) on the hub-client side. `AttributionWrap`
drops the inline `style={{ color }}` and adds `data-attr-actor={record.actor}`;
`useAttributionHover` extends its `<style>` block with one
`[data-attr-actor="…"] { --attr-color; --attr-name; }` rule per
distinct actor in the lookup. The `viewer.css` cascade then paints
each wrap via `var(--attr-color)`. Badge stays React-driven from the
lookup context — only paint moves to CSS. The wire (`astContext.attribution`
+ `astContext.attributionActors`) is unchanged.
  • Loading branch information
shikokuchuo committed May 15, 2026
commit 6440445958af022e656e5ced82ce02c9163c6172
60 changes: 60 additions & 0 deletions hub-client/src/components/render/framework/attribution.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect } from 'vitest';
import { buildActorStyles, cssEscape } from './attribution';
import type { NodeAttributionIdentity } from './AttributionLookupContext';

const sample = (
sid: number,
actor: string,
name: string,
color: string,
): [number, NodeAttributionIdentity] => [
sid,
{ actor, name, color, time: 0 },
];

describe('buildActorStyles', () => {
it('returns empty string when lookup is null', () => {
expect(buildActorStyles(null)).toBe('');
});

it('returns empty string when lookup has no entries', () => {
expect(buildActorStyles(new Map())).toBe('');
});

it('emits one rule per distinct actor, sorted ascending', () => {
// Three sid entries, two distinct actors. bob appears at the
// higher sid but must emit first (alphabetical).
const lookup = new Map<number, NodeAttributionIdentity>([
sample(1, 'bob', 'Bob', '#88CCEE'),
sample(2, 'alice', 'Alice', '#CC6677'),
sample(3, 'alice', 'Alice', '#CC6677'),
]);
const css = buildActorStyles(lookup);
const aliceAt = css.indexOf('[data-attr-actor="alice"]');
const bobAt = css.indexOf('[data-attr-actor="bob"]');
expect(aliceAt).toBeGreaterThanOrEqual(0);
expect(bobAt).toBeGreaterThanOrEqual(0);
expect(aliceAt).toBeLessThan(bobAt);
// Exactly one rule per actor — two `data-attr-actor=` selectors.
expect((css.match(/\[data-attr-actor=/g) ?? []).length).toBe(2);
expect(css).toContain('--attr-color: #CC6677');
expect(css).toContain('--attr-name: "Alice"');
expect(css).toContain('--attr-color: #88CCEE');
expect(css).toContain('--attr-name: "Bob"');
});
});

describe('cssEscape', () => {
it('passes safe characters through unchanged', () => {
expect(cssEscape('alice@example.com')).toBe('alice@example.com');
expect(cssEscape("Alice O'Hara")).toBe("Alice O'Hara");
expect(cssEscape('日本語')).toBe('日本語');
});

it('escapes quote, backslash, newline, and carriage return', () => {
expect(cssEscape('a"b\\c\nd\re')).toBe('a\\"b\\\\c\\A d\\D e');
});
});
93 changes: 80 additions & 13 deletions hub-client/src/components/render/framework/attribution.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useContext, useRef, useState } from 'react';
import React, { useCallback, useContext, useMemo, useRef, useState } from 'react';
import {
AttributionLookupContext,
useNodeAttribution,
Expand Down Expand Up @@ -64,21 +64,73 @@ export function AttributionBadge({
*/
export const attributionStyles = viewerCss;

/**
* Escape a string for inclusion inside a double-quoted CSS string
* literal. Mirrors `escape_css_string` in
* `crates/quarto-core/src/transforms/attribution_viewer.rs` so the
* CLI HTML and the hub-client iframe stylesheet share a single
* escaping contract. Per CSS Syntax Level 3, only `"`, `\`, and raw
* line terminators need escaping inside `"…"`.
*/
export function cssEscape(input: string): string {
let out = '';
for (const ch of input) {
if (ch === '\\') out += '\\\\';
else if (ch === '"') out += '\\"';
else if (ch === '\n') out += '\\A ';
else if (ch === '\r') out += '\\D ';
else out += ch;
}
return out;
}

/**
* Build the per-actor CSS rule block that publishes
* `--attr-color` / `--attr-name` for each distinct identity in
* `lookup`. Returns an empty string when there are no identities to
* publish, so concatenation with the static `viewer.css` is safe.
*
* Mirrors `render_per_actor_rules` in
* `crates/quarto-core/src/transforms/attribution_viewer.rs` — same
* shape, same alphabetical ordering, same escaping contract — so a
* theme author who learns one surface knows the other.
*/
export function buildActorStyles(
lookup: Map<number, NodeAttributionIdentity> | null,
): string {
if (!lookup) return '';
const seen = new Map<string, { name: string; color: string }>();
for (const record of lookup.values()) {
if (!seen.has(record.actor)) {
seen.set(record.actor, { name: record.name, color: record.color });
}
}
if (seen.size === 0) return '';
const entries = Array.from(seen.entries()).sort(([a], [b]) =>
a < b ? -1 : a > b ? 1 : 0,
);
let out = '\n';
for (const [actor, identity] of entries) {
out += `[data-attr-actor="${cssEscape(actor)}"] { --attr-color: ${identity.color}; --attr-name: "${cssEscape(identity.name)}"; }\n`;
}
return out;
}

/**
* Wrap `children` in a `.q2-attr-wrap` element when the AST node has
* resolved attribution; pass through unchanged otherwise.
*
* `as` selects a `<div>` (block-level wrapper) or `<span>` (inline-level
* wrapper) — the only structural divergence between Block/Inline
* dispatchers in q2-debug and q2-preview. `data-sid` is the lookup key
* the event-delegated badge in `useAttributionHover` uses to find the
* record for the hovered wrap.
* dispatchers in q2-debug and q2-preview.
*
* The wrapper paints body text in the author's colour (descendants
* inherit via the cascade). The CLI's auto-injected viewer JS
* (`resources/attribution/viewer.js`) does the same thing for static
* HTML output via a `querySelectorAll` + `style.color` pass, so both
* surfaces look the same on attributed regions.
* The wrap publishes two stable DOM hooks:
* - `data-attr-actor` — matches the per-actor CSS rule emitted by
* `useAttributionHover().stylesheet`, so the author colour
* applies via the cascade (`viewer.css` paints `[data-attr-actor]`
* via `var(--attr-color)`).
* - `data-sid` — the source-info pool id, used by the hover handler
* in `useAttributionHover` to look up the record for the badge.
*
* `node` is typed `unknown` to absorb the structural mismatch between
* the framework's loose `{ s?: number }` lookup key and the
Expand All @@ -99,16 +151,23 @@ export function AttributionWrap({
const attribution = useNodeAttribution(node as { s?: number });
if (!attribution) return <>{children}</>;
const sid = (node as { s?: number }).s;
const style = { color: attribution.color };
if (as === 'div') {
return (
<div className="q2-attr-wrap" data-sid={sid} style={style}>
<div
className="q2-attr-wrap"
data-sid={sid}
data-attr-actor={attribution.actor}
>
{children}
</div>
);
}
return (
<span className="q2-attr-wrap" data-sid={sid} style={style}>
<span
className="q2-attr-wrap"
data-sid={sid}
data-attr-actor={attribution.actor}
>
{children}
</span>
);
Expand Down Expand Up @@ -172,7 +231,15 @@ export function useAttributionHover(): {
const hostProps = enabled
? { onMouseOver: handleMouseOver, onMouseOut: handleMouseOut }
: {};
const stylesheet = enabled ? <style>{attributionStyles}</style> : null;
// Per-actor rules derive from the same `attributionActors` table
// the lookup context was built from, so identity flows from one
// source — the wire — through React to CSS. No drift surface.
// Memoised so toggling Authorship off then on doesn't churn the
// stylesheet across unrelated re-renders.
const actorStyles = useMemo(() => buildActorStyles(lookup), [lookup]);
const stylesheet = enabled ? (
<style>{attributionStyles + actorStyles}</style>
) : null;
const overlay = hovered ? (
<AttributionBadge
record={hovered.record}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ const noopSetAst = () => {};
* Phase 5c — when `astContext.attribution` / `astContext.attributionActors`
* are absent, the q2-debug renderer wraps no nodes and paints no colour.
* When they're present, each annotated node gets a `q2-attr-wrap`
* wrapper carrying `color: <identity.color>` and `data-sid=<s>`.
* Hovering surfaces a single floating badge with the author's name
* and a relative-time string.
* wrapper carrying `data-sid=<s>` (used for badge lookup) and
* `data-attr-actor=<actor>` (matched by the per-actor CSS rule the
* framework injects); the colour reaches the wrap via the cascade,
* not via an inline `style`. Hovering surfaces a single floating
* badge with the author's name and a relative-time string.
*
* Mounted via the framework `Ast` with the `q2DebugRegistry` so we
* exercise the same wiring the iframe uses at runtime.
Expand All @@ -45,7 +47,7 @@ describe('q2-debug attribution wiring', () => {
expect(container.textContent).toMatch(/hello/);
});

it('on path: each annotated node gets a colour-only wrapper', () => {
it('on path: each annotated node gets a wrap with actor + sid', () => {
const ast = {
'pandoc-api-version': [1, 23, 1],
meta: {},
Expand Down Expand Up @@ -75,11 +77,23 @@ describe('q2-debug attribution wiring', () => {

for (const wrap of Array.from(wraps)) {
const el = wrap as HTMLElement;
// Colour is applied as an inline style — JSDOM normalises rgb().
expect(el.style.color).toBe('rgb(255, 0, 0)');
// Identity is render-time CSS now: the wrap carries the
// per-actor selector key, not an inline colour. The cascade
// resolves `color: var(--attr-color)` from the injected rule.
expect(el.getAttribute('data-attr-actor')).toBe('alice');
expect(el.getAttribute('data-sid')).toMatch(/^[12]$/);
expect(el.style.color).toBe('');
}

// The framework injects a single per-render <style> carrying the
// static viewer.css plus the per-actor rule for "alice".
const styles = Array.from(container.querySelectorAll('style')).map(
(s) => s.textContent ?? '',
);
expect(
styles.some((s) => s.includes('[data-attr-actor="alice"]') && s.includes('--attr-color: #ff0000') && s.includes('--attr-name: "Alice"')),
).toBe(true);

// No badge yet — hover hasn't fired.
expect(container.querySelector('.q2-attr-badge')).toBeNull();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('q2-preview attribution wiring', () => {
expect(container.textContent).toMatch(/hello/);
});

it('on path: each annotated node gets a colour-only wrapper', () => {
it('on path: each annotated node gets a wrap with actor + sid', () => {
const ast = {
'pandoc-api-version': [1, 23, 1],
meta: {},
Expand Down Expand Up @@ -76,11 +76,28 @@ describe('q2-preview attribution wiring', () => {

for (const wrap of Array.from(wraps)) {
const el = wrap as HTMLElement;
// Colour is applied as an inline style — JSDOM normalises rgb().
expect(el.style.color).toBe('rgb(255, 0, 0)');
// Identity is render-time CSS now: the wrap carries the
// per-actor selector key, not an inline colour. The cascade
// resolves `color: var(--attr-color)` from the injected rule.
expect(el.getAttribute('data-attr-actor')).toBe('alice');
expect(el.getAttribute('data-sid')).toMatch(/^[12]$/);
expect(el.style.color).toBe('');
}

// The framework injects a single per-render <style> carrying the
// static viewer.css plus the per-actor rule for "alice".
const styles = Array.from(container.querySelectorAll('style')).map(
(s) => s.textContent ?? '',
);
expect(
styles.some(
(s) =>
s.includes('[data-attr-actor="alice"]') &&
s.includes('--attr-color: #ff0000') &&
s.includes('--attr-name: "Alice"'),
),
).toBe(true);

// No badge yet — hover hasn't fired.
expect(container.querySelector('.q2-attr-badge')).toBeNull();
});
Expand Down