diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index ef46121..c3f05b3 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -694,6 +694,40 @@ button.lchip-add[aria-expanded='true'] { .body { font-size: 13.5px; line-height: 1.62; max-width: 66ch; margin: 0 0 14px; color: var(--ink); } .body p { margin: 0 0 0.9em; } .body p:last-child { margin-bottom: 0; } +.artifact-review { position: relative; max-width: 66ch; } +.artifact-prose { outline: none; } +.artifact-prose ::selection { background: color-mix(in srgb, var(--accent) 24%, transparent); } +.inline-review { + position: absolute; + z-index: 8; + width: min(272px, calc(100vw - 32px)); + padding: 11px; + transform: translateX(-50%); + border: 1px solid var(--line); + border-radius: 10px; + background: var(--raised); + box-shadow: 0 10px 28px rgba(20, 26, 31, 0.16); +} +.inline-review-head, .inline-review-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.inline-review-head { margin-bottom: 8px; font-size: 12.5px; } +.inline-review .iconbtn { width: 24px; height: 24px; } +.inline-review blockquote { + max-height: 72px; + margin: 0 0 9px; + padding: 6px 8px; + overflow: auto; + border-left: 1px solid var(--accent); + color: var(--ink-2); + font-size: 11.5px; + line-height: 1.45; +} +.inline-review .ta { width: 100%; min-height: 64px; margin-bottom: 9px; resize: vertical; } +.inline-review-actions .sl { width: auto; } .body .bh { font-size: 13px; font-weight: 700; letter-spacing: 0.001em; margin: 21px 0 7px; color: var(--ink); } /* A body's own `#` title sits above its sections; below `##` the levels stop growing apart and only the weight carries the step, so a deep outline never shouts. */ diff --git a/packages/ui/src/lib/components/UnitDetail.svelte b/packages/ui/src/lib/components/UnitDetail.svelte index be29ae8..a513a8a 100644 --- a/packages/ui/src/lib/components/UnitDetail.svelte +++ b/packages/ui/src/lib/components/UnitDetail.svelte @@ -1,4 +1,5 @@ + +
{#if requestFor && requester} requested by {requester.name} · {stamp(requestFor.value.createdAt)} @@ -424,7 +520,54 @@ {/if} {#if version} - +
+
+ +
+ {#if passage} + + {/if} +
{/if} {#if version?.artifact.value.criteria?.length} @@ -645,7 +788,14 @@ {#if judging && version} {#key version.artifact.cid}
- +
{/key} {:else if asking && version} diff --git a/packages/ui/src/lib/components/UnitDetail.svelte.test.ts b/packages/ui/src/lib/components/UnitDetail.svelte.test.ts index 38c466c..5332c0b 100644 --- a/packages/ui/src/lib/components/UnitDetail.svelte.test.ts +++ b/packages/ui/src/lib/components/UnitDetail.svelte.test.ts @@ -7,7 +7,7 @@ import { type ReviewRecord, type UnitView, } from '@radial/core' -import { fixtureSpace } from '@radial/core/fixture' +import { FIXTURE_DIDS, fixtureSpace } from '@radial/core/fixture' import { flushSync, mount, unmount } from 'svelte' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { buildDirectory } from '$lib/directory.js' @@ -24,9 +24,10 @@ import UnitDetail from './UnitDetail.svelte' // and `timeline()` rather than through a shape invented for this test. const resolveImage = vi.hoisted(() => vi.fn()) +const mockAccount = vi.hoisted(() => ({ status: 'signed-out', did: '' })) vi.mock('$lib/image-resolve.js', () => ({ resolveImage })) vi.mock('$lib/auth.svelte.js', () => ({ - account: { status: 'signed-out', did: '' }, + account: mockAccount, })) const IMAGE = 'at://did:plc:reviewer/com.disnetdev.radial.image/3mrvxfdae4l2m' @@ -88,8 +89,8 @@ let host: HTMLDivElement let component: Record | undefined /** Open the drawer on v1 — the version the fixture's `request_changes` review pins. */ -function open(bodies: string[]): void { - const space = spaceWithFindings(bodies) +function open(bodies: string[], live = false): void { + const space = { ...spaceWithFindings(bodies), ...(live ? { fixture: false } : {}) } const view = goalOf(space) component = mount(UnitDetail, { target: host, @@ -109,6 +110,8 @@ const findingWith = (severity: string): HTMLElement => { beforeEach(() => { resolveImage.mockReset() + mockAccount.status = 'signed-out' + mockAccount.did = '' host = document.createElement('div') document.body.append(host) }) @@ -192,3 +195,56 @@ describe('a review finding on screen', () => { expect(error.querySelector('.im')?.textContent).toContain('the wrapped bar') }) }) + +describe('reviewing selected artifact prose', () => { + it('opens beside the selection and carries the quote into a normal review finding', async () => { + mockAccount.status = 'signed-in' + mockAccount.did = FIXTURE_DIDS.tim + open([], true) + + const prose = host.querySelector('.artifact-prose .body') + const walker = prose + ? document.createTreeWalker(prose, NodeFilter.SHOW_TEXT, { + acceptNode: (node) => + node.textContent?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP, + }) + : undefined + const text = walker?.nextNode() + if (!text?.textContent) throw new Error('artifact prose missing') + const stop = Math.min(28, text.textContent.length) + const quote = text.textContent.slice(0, stop) + const range = document.createRange() + range.setStart(text, 0) + range.setEnd(text, stop) + Object.defineProperty(range, 'getBoundingClientRect', { + value: () => ({ left: 40, right: 180, top: 20, bottom: 38, width: 140, height: 18 }), + }) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + flushSync() + + const popover = host.querySelector('.inline-review') + expect(popover?.querySelector('blockquote')?.textContent).toBe(quote) + + const suggestion = popover?.querySelector('textarea') as HTMLTextAreaElement + suggestion.value = 'State the observable outcome.' + suggestion.dispatchEvent(new InputEvent('input', { bubbles: true })) + flushSync() + const add = popover?.querySelector('button.btn-bad') as HTMLButtonElement + expect(add.disabled).toBe(false) + add.click() + flushSync() + + await vi.waitFor(() => { + flushSync() + const editor = host.querySelector('.fedit .cm-content, .fedit textarea') + const value = + editor instanceof HTMLTextAreaElement ? editor.value : editor?.textContent + expect(value).toContain(`> ${quote}`) + expect(value).toContain('State the observable outcome.') + }) + expect((host.querySelector('.fedit select') as HTMLSelectElement).value).toBe('warning') + }) +}) diff --git a/packages/ui/src/lib/components/Verdict.svelte b/packages/ui/src/lib/components/Verdict.svelte index eb8d237..37a00bb 100644 --- a/packages/ui/src/lib/components/Verdict.svelte +++ b/packages/ui/src/lib/components/Verdict.svelte @@ -29,15 +29,23 @@ space: Space /** Which button opened it. It decides what the card opens *holding*, never what it will write. */ initial: Verdict + /** A passage-level suggestion captured beside the artifact before this full review opened. */ + initialFinding?: FindingRow | undefined done: () => void } - const { unit, version, space, initial, done }: Props = $props() + const { unit, version, space, initial, initialFinding, done }: Props = $props() // Request-changes opens with a row waiting: a review that sends work back and says nothing about // why is the one shape of review that is worse than no review. Approve opens with none, and an // approval with a note on it is a click away. let rows = $state( - untrack(() => (initial === 'request_changes' ? [blankFinding()] : [])), + untrack(() => + initialFinding + ? [initialFinding] + : initial === 'request_changes' + ? [blankFinding()] + : [], + ), ) // Which verdict is being written, or null. Not a chosen-verdict flag: nothing is chosen until a // button is pressed, and the button that is pressed is the one that writes. diff --git a/packages/ui/src/lib/verdicts.test.ts b/packages/ui/src/lib/verdicts.test.ts index 29f3b1e..6f83c4b 100644 --- a/packages/ui/src/lib/verdicts.test.ts +++ b/packages/ui/src/lib/verdicts.test.ts @@ -9,6 +9,7 @@ import { askReviewArgs, blankFinding, findingsOf, + inlineFinding, judgeable, mayJudge, owedBy, @@ -66,6 +67,15 @@ describe('what the editor produces', () => { { severity: 'info', path: 'src/a.ts', line: 3, body: 'Fix.' }, ]) }) + + it('keeps a selected passage and its suggestion together as portable markdown', () => { + expect(inlineFinding('First line\nSecond line', 'Make this measurable.', 'info')).toEqual({ + severity: 'info', + path: '', + line: '', + body: '> First line\n> Second line\n\nMake this measurable.', + }) + }) }) describe('what a review pins', () => { diff --git a/packages/ui/src/lib/verdicts.ts b/packages/ui/src/lib/verdicts.ts index 91b88f9..3c77b89 100644 --- a/packages/ui/src/lib/verdicts.ts +++ b/packages/ui/src/lib/verdicts.ts @@ -66,6 +66,32 @@ export interface FindingDraft { body: string } +/** + * Turn a passage selected in rendered artifact prose into an ordinary review finding draft. + * + * The quote lives in the finding body rather than in observer-local UI state, so it survives the + * write, reaches revision-turn bundles, and remains pinned by the review's subject CID. Prefix every + * line separately: a multi-paragraph selection is one markdown blockquote, not a first quoted line + * followed by reviewer prose that merely looks adjacent. + */ +export function inlineFinding( + quote: string, + suggestion: string, + severity: Severity = 'warning', +): FindingDraft { + const quoted = quote + .trim() + .split('\n') + .map((line) => `> ${line}`) + .join('\n') + return { + severity, + path: '', + line: '', + body: `${quoted}\n\n${suggestion.trim()}`, + } +} + /** * One ROW of the findings editor: a draft, plus the two things that are true of the row rather than * of the finding.