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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,12 @@ jobs:
packages/ui/markdownEditorFidelity.test.tsx
packages/ui/annotationDraftPersistence.test.tsx
packages/ui/codeAnnotationDraftPersistence.test.tsx
packages/ui/components/AnnotationPanel.props.test.tsx
packages/ui/components/Viewer.consumer.test.tsx
packages/ui/components/InlineMarkdown.seam.test.tsx
packages/ui/components/ImageThumbnail.seam.test.tsx
packages/ui/hooks/useAnnotationHighlighter.test.tsx
packages/ui/hooks/annotationRestoreVerify.test.tsx
packages/ui/hooks/useAnnotationDraft.seam.test.tsx
packages/ui/hooks/useExternalAnnotations.seam.test.tsx
packages/ui/hooks/useAIChat.seam.test.tsx
Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 19 additions & 1 deletion packages/ui/HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
| `components/AttachmentsButton` | Routes through `uploadTransport`. |
| Seam-backed hooks: `useAnnotationHighlighter`, `useAnnotationDraft`, `useCodeAnnotationDraft`, `useExternalAnnotations`, `useFileBrowser` | Their network access goes through the seams in the catalog above. |
| `config` (`ConfigStore`) | Persists through `storageBackend`. |
| `components/TableOfContents` | Pure — renders from `blocks`; pair with `useActiveSection` for scroll-spy. *(Blessed in 0.24.0.)* |
| `components/ResizeHandle` + `hooks/useResizablePanel` | Layout pair for draggable panel widths; persists the width through the `storageBackend` seam. *(Blessed in 0.24.0.)* |
| `hooks/useActiveSection` | Scroll-spy over rendered headings; no backend. *(Blessed in 0.24.0.)* |
| `hooks/useScrollViewport` | Resolves the scrolling element for viewport-aware UI; no backend. *(Blessed in 0.24.0.)* |
| `utils/annotationHelpers` | Pure annotation utilities (`getAnnotationCountBySection`, `buildTocHierarchy` + `TocItem`). *(Blessed in 0.24.0.)* |

**AI is fully avoidable** — with one precision worth knowing. No AI *UI* is reachable from the supported components: `useAIChat` is imported only by `components/ai/DocumentAIChatPanel` and `useAIProviderConfig`, neither of which any supported component imports, and `CommentPopover`'s Ask-AI affordance exists only behind the optional `onAskAI` prop. `configure.ts` does statically import the `useAIChat` module (it needs `setAITransport`), but if you never use AI the hook is dead code and bundlers eliminate it — verified empirically: a standalone consumer's production bundle importing the full supported surface contains zero `/api/ai` strings. Don't import `components/ai/*` and don't pass `aiTransport`, and you ship no AI code.

Expand Down Expand Up @@ -313,9 +318,22 @@ Re-verify your seam contract against `0.23.0` before adopting; the list above is

---

## Consumer enablement (0.24.0)

Six items accumulated through Workspaces' first three integration slices. All are additive; every default reproduces 0.23.0 behavior.

1. **`AnnotationPanel` host props.** `renderCardFooter?: (annotation) => ReactNode` — a per-card slot at each plan-annotation card's foot (plug reply/resolve UI in; clicks inside the slot don't select the card). `readOnly?: boolean` — hides every mutation affordance (delete/edit on all card kinds); selection and scrolling still work.
2. **Six more supported imports** (already in the table above, tagged *Blessed in 0.24.0*): `TableOfContents`, `ResizeHandle` + `useResizablePanel`, `useActiveSection`, `useScrollViewport`, `utils/annotationHelpers`. All verified under the strict-consumer gate.
3. **`Viewer`/`CommentPopover` `allowImages?: boolean`.** Pass `false` when you have no `uploadTransport` — the attach-image affordance disappears instead of dead-ending. (CommentPopover already had the prop; Viewer now exposes and threads it.)
4. **`Viewer` `readOnly?: boolean`.** View-only users: suppresses every composer entry point (selection toolbar, comment popovers, quick labels, pinpoint, global comment, attachments, checkbox toggles) while existing annotations still render and select.
5. **Stricter consumer gate.** `tsconfig.strict-consumer.json` now also enforces `verbatimModuleSyntax`, `noUnusedLocals`, `noUnusedParameters` — the shipped source passes them, so you no longer have to relax those flags in your own tsconfig.
6. **Content-verifying restore (opt-in).** `useAnnotationHighlighter({ verifyRestoredContent: true, onRestoreMismatch })`: a position-based restore that resolves onto the wrong text (document drift) is removed and re-anchored by text search; if the original text is gone entirely, `onRestoreMismatch(annotation, restoredText)` fires and nothing is painted. Default off. If you built a host-side guard for this, you can delete it.

---

## Publishing & versioning

- `@plannotator/core` and `@plannotator/ui` are versioned **in lockstep with the repo** (`@plannotator/ui` is now `0.23.0`; `@plannotator/core` is untouched by the Base UI migration and remains `0.22.0` until its next change — the ui→core dependency still resolves exactly at pack time).
- `@plannotator/core` and `@plannotator/ui` are versioned **in lockstep with the repo** (`@plannotator/ui` is now `0.24.0`; `@plannotator/core` remains `0.22.0` until its next change — the ui→core dependency still resolves exactly at pack time).
- They depend on each other via `workspace:*`. At publish time that must resolve to the **exact** version in the tarball, so publish with a tool that does that resolution (the repo's existing flow uses `bun pm pack` to build the tarball, then `npm publish *.tgz --provenance --access public`). Publish **`core` first, then `ui`**.
- `styles.css` is built by the `prepack` script (`bun run build:css`) so the published tarball always carries fresh precompiled CSS.
- There is **no CI publish job for these two packages yet** — first publish is manual from `main` after merge. (Wiring a CI publish job is a follow-up.)
Expand Down
113 changes: 113 additions & 0 deletions packages/ui/components/AnnotationPanel.props.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Consumer-surface contract for AnnotationPanel's host props:
* - readOnly hides every mutation affordance (delete/edit on all card kinds)
* - renderCardFooter renders a per-card slot whose interactions do NOT
* select the card
* Both default to today's behavior (mutable, no footer).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import React from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { act } from 'react';

import { AnnotationPanel } from './AnnotationPanel';
import { AnnotationType, type Annotation } from '../types';

const hasDom = typeof document !== 'undefined';

const annotation: Annotation = {
id: 'a1',
blockId: 'b1',
startOffset: 0,
endOffset: 5,
type: AnnotationType.COMMENT,
text: 'a note',
originalText: 'hello',
createdA: 1,
};

let root: Root | null = null;
let host: HTMLElement | null = null;

async function mount(ui: React.ReactElement): Promise<void> {
host = document.createElement('div');
document.body.appendChild(host);
await act(async () => {
root = createRoot(host!);
root.render(ui);
});
}

afterEach(async () => {
if (root) {
await act(async () => {
root!.unmount();
});
root = null;
}
host?.remove();
host = null;
if (hasDom) document.body.innerHTML = '';
});

const baseProps = {
isOpen: true,
annotations: [annotation],
blocks: [],
onSelect: () => {},
onDelete: () => {},
selectedId: null,
};

describe('AnnotationPanel consumer props', () => {
test.skipIf(!hasDom)('default renders the delete affordance (today’s behavior)', async () => {
await mount(<AnnotationPanel {...baseProps} />);
expect(document.querySelector('button[title="Delete annotation"]')).not.toBeNull();
});

test.skipIf(!hasDom)('readOnly hides delete and edit affordances', async () => {
await mount(
<AnnotationPanel
{...baseProps}
readOnly
onEdit={() => {}}
/>,
);
expect(document.querySelector('button[title="Delete annotation"]')).toBeNull();
expect(document.querySelector('button[title="Edit annotation"]')).toBeNull();
});

test.skipIf(!hasDom)('renderCardFooter renders per-card and does not select the card', async () => {
const selected: string[] = [];
let footerClicks = 0;
await mount(
<AnnotationPanel
{...baseProps}
onSelect={(id) => selected.push(id)}
renderCardFooter={(a) => (
<button type="button" data-testid="reply" onClick={() => { footerClicks++; }}>
reply to {a.id}
</button>
)}
/>,
);

const slot = document.querySelector('[data-annotation-card-footer="true"]');
expect(slot).not.toBeNull();
expect(slot!.textContent).toContain('reply to a1');

const replyBtn = document.querySelector('[data-testid="reply"]') as HTMLButtonElement;
await act(async () => {
replyBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(footerClicks).toBe(1);
// The click bubbled only to the slot wrapper, which stops propagation —
// the card's onSelect must not fire.
expect(selected).toHaveLength(0);
});

test.skipIf(!hasDom)('no footer prop → no slot rendered', async () => {
await mount(<AnnotationPanel {...baseProps} />);
expect(document.querySelector('[data-annotation-card-footer="true"]')).toBeNull();
});
});
105 changes: 68 additions & 37 deletions packages/ui/components/AnnotationPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { Annotation, AnnotationType, Block, type CodeAnnotation, type EditorAnnotation } from '../types';
import { AnnotationType, type Annotation, type Block, type CodeAnnotation, type EditorAnnotation } from '../types';
import { isCurrentUser } from '../utils/identity';
import { ImageThumbnail } from './ImageThumbnail';
import { EditorAnnotationCard } from './EditorAnnotationCard';
Expand Down Expand Up @@ -73,12 +73,18 @@ interface PanelProps {
/** Committed direct edits to one or more documents. Rendered as pinned cards
* above the annotation timeline with expandable unified diffs. */
directEdits?: DirectEditsPanelItem[] | null;
/** Host slot rendered at the foot of each plan-annotation card (e.g. reply/
* resolve UI). The panel stays presentation-only; clicks inside the slot
* do not select the card. Default: nothing rendered. */
renderCardFooter?: (annotation: Annotation) => React.ReactNode;
/** Hide every mutation affordance (delete/edit buttons on all card kinds).
* Selection and scrolling still work. Default false — today's behavior. */
readOnly?: boolean;
}

export const AnnotationPanel: React.FC<PanelProps> = ({
isOpen,
annotations,
blocks,
onSelect,
onDelete,
onEdit,
Expand All @@ -97,6 +103,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
otherFileAnnotations,
onOtherFileAnnotationsClick,
directEdits = null,
renderCardFooter,
readOnly = false,
}) => {
const isMobile = useIsMobile();
const [copiedText, setCopiedText] = useState(false);
Expand Down Expand Up @@ -195,6 +203,8 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
onSelect={() => onSelect(entry.annotation.id)}
onDelete={() => onDelete(entry.annotation.id)}
onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
readOnly={readOnly}
footer={renderCardFooter?.(entry.annotation)}
/>
) : (
<CodeAnnotationCard
Expand All @@ -205,6 +215,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
onSelect={() => onSelectCodeAnnotation?.(entry.annotation.id)}
onDelete={() => onDeleteCodeAnnotation?.(entry.annotation.id)}
onEdit={onEditCodeAnnotation ? (updates: Partial<CodeAnnotation>) => onEditCodeAnnotation(entry.annotation.id, updates) : undefined}
readOnly={readOnly}
/>
)
))}
Expand All @@ -221,7 +232,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
<EditorAnnotationCard
key={ann.id}
annotation={ann}
onDelete={() => onDeleteEditorAnnotation?.(ann.id)}
onDelete={readOnly ? undefined : () => onDeleteEditorAnnotation?.(ann.id)}
/>
))}
</>
Expand Down Expand Up @@ -418,7 +429,9 @@ const AnnotationCard: React.FC<{
onSelect: () => void;
onDelete: () => void;
onEdit?: (updates: Partial<Annotation>) => void;
}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit }) => {
readOnly?: boolean;
footer?: React.ReactNode;
}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer }) => {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(annotation.text || '');
const textareaRef = useRef<HTMLTextAreaElement>(null);
Expand Down Expand Up @@ -517,26 +530,28 @@ const AnnotationCard: React.FC<{
<span className="text-[10px] text-muted-foreground/50 truncate">
{annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)}
</span>
<div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
{onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
{!readOnly && (
<div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
{onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
<button
type="button"
onClick={handleStartEdit}
className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
title="Edit annotation"
>
<PencilIcon />
</button>
)}
<button
type="button"
onClick={handleStartEdit}
className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
title="Edit annotation"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onDelete(); }}
className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
title="Delete annotation"
>
<PencilIcon />
<TrashCardIcon />
</button>
)}
<button
type="button"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onDelete(); }}
className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
title="Delete annotation"
>
<TrashCardIcon />
</button>
</div>
</div>
)}
</div>

{/* Global Comment - show text directly */}
Expand Down Expand Up @@ -585,6 +600,19 @@ const AnnotationCard: React.FC<{
))}
</div>
)}

{/* Host footer slot (reply/resolve UI etc.) — interactions inside it
must not toggle card selection. */}
{footer != null && footer !== false && (
<div
data-annotation-card-footer="true"
className="mt-2"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
onKeyDown={(e: React.KeyboardEvent) => e.stopPropagation()}
>
{footer}
</div>
)}
</div>
);
};
Expand All @@ -596,7 +624,8 @@ const CodeAnnotationCard: React.FC<{
onSelect: () => void;
onDelete: () => void;
onEdit?: (updates: Partial<CodeAnnotation>) => void;
}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit }) => {
readOnly?: boolean;
}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false }) => {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(annotation.text || '');
const textareaRef = useRef<HTMLTextAreaElement>(null);
Expand Down Expand Up @@ -650,26 +679,28 @@ const CodeAnnotationCard: React.FC<{
<span className="text-[10px] text-muted-foreground/50 truncate">
{annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdAt)}
</span>
<div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
{onEdit && !isEditing && (
{!readOnly && (
<div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100">
{onEdit && !isEditing && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
title="Edit annotation"
>
<PencilIcon />
</button>
)}
<button
type="button"
onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground"
title="Edit annotation"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
title="Delete annotation"
>
<PencilIcon />
<TrashCardIcon />
</button>
)}
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-destructive"
title="Delete annotation"
>
<TrashCardIcon />
</button>
</div>
</div>
)}
</div>

{/* File / line meta */}
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/components/AnnotationToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import React, { useState, useEffect, useRef, useMemo } from "react";
import { AnnotationType } from "../types";
import { createPortal } from "react-dom";
import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/components/BlockRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { Block } from "../types";
import type { Block } from "../types";
import { InlineMarkdown } from "./InlineMarkdown";
import { ListItemBody } from "./ListItemBody";
import { CodeBlock } from "./blocks/CodeBlock";
Expand Down
Loading