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
75 changes: 59 additions & 16 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/annotation/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Demo-only code lives under `src/demo/` (TerminalWindow, DemoStage, terminalScrip

Consumes `@contextbridge/ui` — `styles.css` import in the entry, `cn()` helper, shared components under `@contextbridge/ui/components/*` (e.g. `Header`, `BrandMark`), and shadcn primitives under `@contextbridge/ui/components/ui/*`. See `packages/ui/AGENTS.md` for the wiring steps and the "do not remove" notes on the `@source` directive.

The annotation experience owns the curated Shiki theme catalog in `src/themes.ts`. `ThemeController` keeps localStorage, system-color-scheme observation, and root-element mutation behind the injected app context; components consume only semantic CSS properties. Shiki themes provide both the application palette and fenced-code token colors. Preserve the `System` default and keep explicit selections persistent across review sessions.

## Design language: utilitarian, not SaaS

This UI is a developer tool, not a marketing surface. Before adding visual weight (cards, shadows, large radii, tinted fills, backdrop-blur), read `.claude/rules/plan-review-design.md` — it owns the full set of rules. That file is auto-loaded when editing files in this package.
7 changes: 4 additions & 3 deletions packages/annotation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,18 @@
"@contextbridge/instrumentation": "workspace:*",
"@contextbridge/shared": "workspace:*",
"@contextbridge/ui": "workspace:*",
"@shikijs/langs": "^4.3.1",
"@shikijs/themes": "^4.3.1",
"hast-util-to-string": "^3.0.1",
"highlight.js": "^11.11.1",
"lucide-react": "catalog:",
"mermaid": "11.15.0",
"neverthrow": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hotkeys-hook": "^5.3.2",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1"
"remark-gfm": "^4.0.1",
"shiki": "^4.3.1"
},
"devDependencies": {
"@storybook/react-vite": "catalog:",
Expand Down
34 changes: 33 additions & 1 deletion packages/annotation/src/AnnotatedMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { asset } from '@contextbridge/shared/testFactories';
import { render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it } from 'vitest';
import { AnnotatedMarkdown } from './AnnotatedMarkdown.tsx';
import { AnnotatedMarkdown, annotatedMarkdownTestIds } from './AnnotatedMarkdown.tsx';

describe('AnnotatedMarkdown image rendering', () => {
const fixtureAsset = asset.build({
Expand Down Expand Up @@ -40,3 +40,35 @@ describe('AnnotatedMarkdown image rendering', () => {
expect(img.getAttribute('src')).toBe('/tmp/missing.png');
});
});

describe('AnnotatedMarkdown syntax highlighting', () => {
it('renders fenced code with Shiki token spans while preserving the annotatable pre', () => {
const { container: rendered } = render(
<AnnotatedMarkdown
content={'```typescript\nconst answer: number = 42;\n```'}
containerRef={createRef<HTMLDivElement>()}
themeId="dracula"
/>,
);

const container = rendered.querySelector<HTMLElement>(`[data-testid="${annotatedMarkdownTestIds.container}"]`)!;
const pre = container.querySelector('pre');
const tokens = container.querySelectorAll('.shiki-token');

expect(pre).toHaveAttribute('data-target-kind', 'code-block');
expect(pre).toHaveAttribute('data-src-start-line', '1');
expect(pre).toHaveAttribute('data-src-end-line', '3');
expect(pre).toHaveClass('bg-[var(--code-background)]');
expect(tokens.length).toBeGreaterThan(0);
expect(Array.from(tokens).every((token) => token.textContent === token.textContent?.trim())).toBe(true);
expect((tokens[0] as HTMLElement).style.color).not.toBe('');
});

it('leaves adapter-owned code blocks un-tokenized', () => {
const { container: rendered } = render(
<AnnotatedMarkdown content={'```mermaid\ngraph TD\n A --> B\n```'} containerRef={createRef<HTMLDivElement>()} />,
);

expect(rendered.querySelector('.shiki-token')).toBeNull();
});
});
22 changes: 16 additions & 6 deletions packages/annotation/src/AnnotatedMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { toString as hastToString } from 'hast-util-to-string';
import { createElement } from 'react';
import type { ComponentPropsWithoutRef, ComponentType, JSX, Ref } from 'react';
import ReactMarkdown, { type ExtraProps } from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import remarkGfm from 'remark-gfm';
import { type ElementAdapter, elementAdapterForLanguage, elementAdapterLanguages } from './element/ElementAdapter.ts';
import { rehypeShiki } from './rehypeShiki.ts';
import { shikiHighlighter } from './shikiHighlighter.ts';
import type { ThemeId } from './themes.ts';

export const annotatedMarkdownTestIds = {
container: 'plan-review-markdown-plan',
Expand All @@ -18,9 +20,16 @@ export interface AnnotatedMarkdownProps {
containerRef: Ref<HTMLDivElement>;
onMouseUp?: () => void;
assets?: Asset[];
themeId?: ThemeId;
}

export function AnnotatedMarkdown({ content, containerRef, onMouseUp, assets }: AnnotatedMarkdownProps) {
export function AnnotatedMarkdown({
content,
containerRef,
onMouseUp,
assets,
themeId = 'github-light-default',
}: AnnotatedMarkdownProps) {
const assetsByPath = new Map<string, Asset>();
for (const asset of assets ?? []) {
assetsByPath.set(asset.originalPath, asset);
Expand All @@ -43,8 +52,9 @@ export function AnnotatedMarkdown({ content, containerRef, onMouseUp, assets }:
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
// plainText keeps adapter-claimed blocks as raw text instead of tokenized highlight spans.
rehypePlugins={[[rehypeHighlight, { detect: false, plainText: elementAdapterLanguages }]]}
rehypePlugins={[
[rehypeShiki, { highlighter: shikiHighlighter, skipLanguages: elementAdapterLanguages, theme: themeId }],
]}
components={components}
>
{content}
Expand Down Expand Up @@ -101,7 +111,7 @@ const AnnotatablePre = annotatable('pre', {
targetKey: 'code',
targetKind: 'code-block',
className:
'overflow-x-auto rounded-md border border-border bg-neutral-900 px-4 py-3 text-sm leading-6 text-neutral-50',
'overflow-x-auto rounded-md border border-border bg-[var(--code-background)] px-4 py-3 text-sm leading-6 text-[var(--code-foreground)]',
});

const markdownComponents = {
Expand Down Expand Up @@ -210,7 +220,7 @@ const markdownComponents = {
const LANGUAGE_PREFIX = 'language-';

// Reads the adapter + raw source of an adapter-claimed fenced code block. The children are
// still plain text nodes because rehype-highlight's plainText option skips these languages.
// still plain text nodes because adapter languages are not loaded into the Shiki highlighter.
// Returns undefined for ordinary code blocks, which render normally.
function elementBlockFromPre(node: Element | undefined): { adapter: ElementAdapter; source: string } | undefined {
const code = node?.children.find((child): child is Element => child.type === 'element' && child.tagName === 'code');
Expand Down
62 changes: 51 additions & 11 deletions packages/annotation/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { commentsSidebarTestIds } from './CommentsSidebar.tsx';
import { globalCommentComposerTestIds } from './GlobalCommentComposer.tsx';
import { submitBarTestIds } from './SubmitBar.tsx';
import { drag, pressSubmitShortcut, renderApp } from './testHelpers/index.tsx';
import { themePickerTestIds } from './ThemePicker.tsx';
import { updateNoticeCardTestIds } from './UpdateNoticeCard.tsx';

describe('App', () => {
Expand Down Expand Up @@ -493,19 +494,16 @@ describe('App', () => {
expect(screen.queryByTestId(submitBarTestIds.codexHandoffNotice)).not.toBeInTheDocument();
});

it('syntax-highlights fenced code blocks with hljs token spans', async () => {
it('syntax-highlights fenced code blocks with Shiki token spans', async () => {
renderApp({
initialPayload: { contentKind: 'plan', content: '# Plan\n\n```ts\nconst greeting = "hello";\n```\n' },
});

const pre = await waitForMarkdownElement('pre');

expect(pre.querySelector('code')?.classList.contains('hljs')).toBe(true);
const stringToken = pre.querySelector('.hljs-string');
expect(stringToken).not.toBeNull();
expect(stringToken!.textContent).toBe('"hello"');
const keyword = pre.querySelector('.hljs-keyword');
expect(keyword?.textContent).toBe('const');
expect(pre.querySelector('code')).toHaveClass('shiki');
expect(getCodeToken('"hello"')).toBeInTheDocument();
expect(getCodeToken('const')).toBeInTheDocument();
});

it('snaps a drag-selection inside a code token to the full token boundary', async () => {
Expand All @@ -515,10 +513,10 @@ describe('App', () => {
});

await waitFor(() => {
expect(getMarkdownElement('pre code.hljs .hljs-string')).not.toBeNull();
expect(getCodeToken('"helloWorld"')).toBeInTheDocument();
});

const stringToken = getMarkdownElement<HTMLElement>('pre code.hljs .hljs-string');
const stringToken = getCodeToken('"helloWorld"');
const text = stringToken.firstChild as Text;

drag({ target: text, from: 3, to: 5 });
Expand Down Expand Up @@ -573,10 +571,10 @@ describe('App', () => {
});

await waitFor(() => {
expect(getMarkdownElement('pre code.hljs .hljs-string')).not.toBeNull();
expect(getCodeToken('"hello"')).toBeInTheDocument();
});

const stringToken = getMarkdownElement<HTMLElement>('pre code.hljs .hljs-string');
const stringToken = getCodeToken('"hello"');
await user.click(stringToken);

await screen.findByTestId(annotationDraftCommentComposerTestIds.container);
Expand Down Expand Up @@ -999,8 +997,50 @@ Run \`${longCode}\` now.
expect(feedbackButton).toBeInTheDocument();
});
});

describe('theme picker', () => {
it('renders the curated theme grid and persists the selected theme', async () => {
const { themeController } = renderApp({ initialPayload: { contentKind: 'plan', content: '# Ready' } });
const user = userEvent.setup();

await user.click(screen.getByTestId(themePickerTestIds.trigger));

expect(await screen.findByTestId(themePickerTestIds.content)).toBeInTheDocument();
expect(screen.getByTestId(themePickerTestIds.option('system'))).toHaveAttribute('aria-pressed', 'true');

await user.click(screen.getByTestId(themePickerTestIds.option('dracula')));

expect(themeController.savedPreferences).toEqual(['dracula']);
expect(screen.getByTestId(themePickerTestIds.option('dracula'))).toHaveAttribute('aria-pressed', 'true');
await waitFor(() => {
expect(themeController.appliedThemes.at(-1)?.id).toBe('dracula');
});
});

it('tracks system color-scheme changes while System is selected', async () => {
const { themeController } = renderApp({ initialPayload: { contentKind: 'plan', content: '# Ready' } });

act(() => {
themeController.setSystemColorScheme('dark');
});

await waitFor(() => {
expect(themeController.appliedThemes.at(-1)?.id).toBe('github-dark-default');
});
});
});
});

function getCodeToken(text: string): HTMLElement {
const token = Array.from(
screen.getByTestId(annotatedMarkdownTestIds.container).querySelectorAll<HTMLElement>('.shiki-token'),
).find((candidate) => candidate.textContent === text);
if (!token) {
throw new Error(`Could not find Shiki token containing ${text}`);
}
return token;
}

/** Assert that `child`'s right edge does not extend beyond `parent`'s right border. */
function expectWithinRightBorder(child: Element, parent: Element): void {
const childRect = child.getBoundingClientRect();
Expand Down
16 changes: 12 additions & 4 deletions packages/annotation/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,19 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@contextbridge/ui/components/ui/alert-dialog';
import 'highlight.js/styles/github-dark.css';
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import './annotationStyles.css';
import './codeHighlightStyles.css';
import { AnnotatedMarkdown } from './AnnotatedMarkdown.tsx';
import { getAnnotationHighlightWarning } from './annotationHighlights.ts';
import { CommentsSidebar } from './CommentsSidebar.tsx';
import { ThemePicker } from './ThemePicker.tsx';
import { UpdateNoticeCard } from './UpdateNoticeCard.tsx';
import { useAnnotationInteractions } from './useAnnotationInteractions.ts';
import { useAnnotationState } from './useAnnotationState.ts';
import { useAnnotationAppContext } from './useAppContext.ts';
import { useTheme } from './useTheme.ts';

export const appTestIds = {
container: 'plan-review-app',
Expand All @@ -49,11 +51,12 @@ export interface AppProps {
}

export function App({ initialPayload, initialThreads, initialGlobalComment }: AppProps = {}) {
const { fetchPayload, fetchUpdateNotice, analytics, buildInfo } = useAnnotationAppContext();
const { fetchPayload, fetchUpdateNotice, analytics, buildInfo, themeController } = useAnnotationAppContext();
const [payload, setPayload] = useState<AnnotationPayload | null>(initialPayload ?? null);
const [updateNotice, setUpdateNotice] = useState<UpdateNotice | null>(null);
const [updateNoticeDismissed, setUpdateNoticeDismissed] = useState(false);
const reviewState = useAnnotationState({ initialThreads, initialGlobalComment });
const themeState = useTheme(themeController);
const annotationInteractions = useAnnotationInteractions({
threads: reviewState.threads,
submitted: reviewState.submission.submitted,
Expand All @@ -64,6 +67,7 @@ export function App({ initialPayload, initialThreads, initialGlobalComment }: Ap
const highlightWarning = getAnnotationHighlightWarning();
const showCommentNavigation = annotationInteractions.navigation.total > 0;
const commentNavigationDisabled = reviewState.draft.active !== null;
const themePicker = <ThemePicker preference={themeState.preference} onSelect={themeState.selectTheme} />;

useEffect(() => {
if (initialPayload) {
Expand Down Expand Up @@ -91,13 +95,14 @@ export function App({ initialPayload, initialThreads, initialGlobalComment }: Ap
<>
<title>{documentTitle}</title>
{!payload ? (
<Loading buildInfo={buildInfo} />
<Loading buildInfo={buildInfo} settings={themePicker} />
) : (
<main className="min-h-screen bg-background text-foreground" data-testid={appTestIds.container}>
<Header
docsHref={DOCS_URL}
feedbackHref={FEEDBACK_URL}
githubRepoHref={GITHUB_REPO_URL}
settings={themePicker}
slackHelpHref={SLACK_COMMUNITY_URL}
version={buildInfo.version}
/>
Expand All @@ -120,6 +125,7 @@ export function App({ initialPayload, initialThreads, initialGlobalComment }: Ap
content={payload.content}
assets={payload.assets}
onMouseUp={annotationInteractions.handleSelectionCapture}
themeId={themeState.theme.id}
/>
) : (
<div
Expand Down Expand Up @@ -214,15 +220,17 @@ function resolveDocumentTitle(payload: AnnotationPayload | null): string {

interface LoadingProps {
buildInfo: { readonly version: string };
settings: ReactNode;
}

function Loading({ buildInfo }: LoadingProps) {
function Loading({ buildInfo, settings }: LoadingProps) {
return (
<main className="min-h-screen bg-background text-foreground" data-testid={appTestIds.container}>
<Header
docsHref={DOCS_URL}
feedbackHref={FEEDBACK_URL}
githubRepoHref={GITHUB_REPO_URL}
settings={settings}
slackHelpHref={SLACK_COMMUNITY_URL}
version={buildInfo.version}
/>
Expand Down
35 changes: 35 additions & 0 deletions packages/annotation/src/ThemeController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it } from 'vitest';
import { ThemeControllerImpl } from './ThemeController.ts';
import { themeById } from './themes.ts';

describe('ThemeControllerImpl', () => {
afterEach(() => {
window.localStorage.clear();
});

it('persists and restores an explicit theme preference', () => {
const controller = new ThemeControllerImpl();

controller.savePreference('dracula');

expect(new ThemeControllerImpl().loadPreference()).toBe('dracula');
});

it('falls back to System when no valid preference is stored', () => {
expect(new ThemeControllerImpl().loadPreference()).toBe('system');
});

it('applies all semantic colors and dark-mode metadata to the root', () => {
const root = document.createElement('div');
const controller = new ThemeControllerImpl({ root });
const theme = themeById.get('dracula')!;

controller.applyTheme(theme);

expect(root).toHaveAttribute('data-theme', 'dracula');
expect(root).toHaveClass('dark');
expect(root.style.colorScheme).toBe('dark');
expect(root.style.getPropertyValue('--background')).toBe(theme.styles['--background']);
expect(root.style.getPropertyValue('--annotation-background')).toBe(theme.styles['--annotation-background']);
});
});
Loading
Loading