Skip to content
Open
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
7 changes: 2 additions & 5 deletions packages/review-editor/components/DiffHunkPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useTheme } from '@plannotator/ui/components/ThemeProvider';
import { useConfigValue } from '@plannotator/ui/config';
import { useReviewState } from '../dock/ReviewStateContext';
import { resolveSyntaxTheme, buildLineBgOverrides } from '../hooks/usePierreTheme';
import { buildPierreFontCSS } from '../utils/pierreFontCss';

interface DiffHunkPreviewProps {
/** Raw diff hunk string (unified diff format). */
Expand All @@ -31,11 +32,7 @@ function buildPierreCSS(
const fg = styles.getPropertyValue('--foreground').trim();
if (!bg || !fg) return '';

const fontCSS = (fontFamily || fontSize) ? `
pre, code, [data-line-content], [data-column-number] {
${fontFamily ? `font-family: '${fontFamily}', monospace !important;` : ''}
${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''}
}` : '';
const fontCSS = buildPierreFontCSS(fontFamily, fontSize);

return `
:host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] {
Expand Down
7 changes: 2 additions & 5 deletions packages/review-editor/hooks/usePierreTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import type { DiffLineBgIntensity } from '@plannotator/shared/config';
import { useTheme } from '@plannotator/ui/components/ThemeProvider';
import { useConfigValue } from '@plannotator/ui/config';
import { buildPierreFontCSS } from '../utils/pierreFontCss';

export const SHIKI_THEME_MAP: Record<string, { dark: string | null; light: string | null }> = {
'andromeeda': { dark: 'andromeeda', light: null },
Expand Down Expand Up @@ -185,11 +186,7 @@ export function usePierreTheme(options?: { fontFamily?: string; fontSize?: strin
const primary = styles.getPropertyValue('--primary').trim();
if (!bg || !fg) return;

const fontCSS = fontFamily || fontSize ? `
pre, code, [data-line-content], [data-column-number] {
${fontFamily ? `font-family: '${fontFamily}', monospace !important;` : ''}
${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''}
}` : '';
const fontCSS = buildPierreFontCSS(fontFamily, fontSize);

setPierreTheme({
type: resolvedMode,
Expand Down
17 changes: 17 additions & 0 deletions packages/review-editor/utils/pierreFontCss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Font override CSS for Pierre's shadow DOM.
*
* Single home for the font-family/font-size injection block shared by
* usePierreTheme (live diff theme) and DiffHunkPreview (synchronous tooltip
* theme). fontFamily is free text since custom fonts (#851) — escape so a
* name containing quotes/backslashes can't break out of the CSS string.
*/
export function buildPierreFontCSS(fontFamily?: string, fontSize?: string): string {
const safeFontFamily = fontFamily?.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
if (!safeFontFamily && !fontSize) return '';
return `
pre, code, [data-line-content], [data-column-number] {
${safeFontFamily ? `font-family: '${safeFontFamily}', monospace !important;` : ''}
${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''}
}`;
}
116 changes: 111 additions & 5 deletions packages/ui/components/Settings.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import type { Origin } from '@plannotator/shared/agents';
import type { DiffLineBgIntensity } from '@plannotator/shared/config';
import { configStore, useConfigValue } from '../config';
import { loadDiffFont } from '../utils/diffFonts';
import { isFontInstalled } from '../utils/fontDetect';
import { TaterSpritePullup } from './TaterSpritePullup';
import { getIdentity, regenerateIdentity, setCustomIdentity } from '../utils/identity';
import { GitUser } from '../icons/GitUser';
Expand Down Expand Up @@ -228,6 +229,18 @@ const GitTab: React.FC = () => {
);
};

/** Sentinel for the "Custom…" select option — never persisted as a font name. */
const CUSTOM_FONT_SENTINEL = '__custom__';

function isBuiltInFont(value: string): boolean {
return DIFF_FONT_OPTIONS.some((opt) => opt.value === value);
}

/** Inline style rendering UI chrome in the chosen font (sanitized — free text). */
function fontPreviewStyle(family: string): React.CSSProperties {
return { fontFamily: `'${family.replace(/['"\\]/g, '')}', monospace` };
}

const ReviewDisplayTab: React.FC = () => {
const diffStyle = useConfigValue('diffStyle');
const diffOverflow = useConfigValue('diffOverflow');
Expand All @@ -240,11 +253,69 @@ const ReviewDisplayTab: React.FC = () => {
const diffFontFamily = useConfigValue('diffFontFamily');
const diffFontSize = useConfigValue('diffFontSize');

// Custom font: progressive disclosure. customFontMode keeps the input open
// while the user picks "Custom…" or has a non-built-in font saved.
const [customFontMode, setCustomFontMode] = useState(
() => !!diffFontFamily && !isBuiltInFont(diffFontFamily),
);
const [customFontDraft, setCustomFontDraft] = useState(
() => (diffFontFamily && !isBuiltInFont(diffFontFamily) ? diffFontFamily : ''),
);
const customFontSaveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Pending debounced value. Non-null means "typed but not yet saved" — it is
// flushed (not discarded) on unmount, and cancelled when a built-in is
// picked so a stale timer can't overwrite the new selection.
const pendingFontSave = useRef<string | null>(null);
const customActive = customFontMode || (!!diffFontFamily && !isBuiltInFont(diffFontFamily));
// Width-measurement check — deterministic per machine, no permissions.
const customFontInstalled = useMemo(
() => (customFontDraft.trim() ? isFontInstalled(customFontDraft) : null),
[customFontDraft],
);

// Load font for the preview swatch
useEffect(() => {
if (diffFontFamily) loadDiffFont(diffFontFamily);
}, [diffFontFamily]);

// Adopt external changes (late server hydration, edits from elsewhere) into
// the draft — but never clobber typing that hasn't been saved yet.
useEffect(() => {
if (pendingFontSave.current !== null) return;
if (diffFontFamily && !isBuiltInFont(diffFontFamily) && diffFontFamily !== customFontDraft.trim()) {
setCustomFontDraft(diffFontFamily);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [diffFontFamily]);

const cancelPendingFontSave = () => {
clearTimeout(customFontSaveTimer.current);
pendingFontSave.current = null;
};

// Flush a pending save on unmount so closing the panel mid-debounce
// doesn't lose the typed font.
useEffect(() => () => {
clearTimeout(customFontSaveTimer.current);
if (pendingFontSave.current !== null) {
configStore.set('diffFontFamily', pendingFontSave.current);
}
}, []);

const handleCustomFontInput = (value: string) => {
setCustomFontDraft(value);
// Debounce the save — configStore.set writes the cookie and POSTs
// /api/config; per-keystroke writes would spam both.
clearTimeout(customFontSaveTimer.current);
pendingFontSave.current = value.trim();
customFontSaveTimer.current = setTimeout(() => {
if (pendingFontSave.current !== null) {
configStore.set('diffFontFamily', pendingFontSave.current);
pendingFontSave.current = null;
}
}, 400);
};

return (
<>
{/* Font Family */}
Expand All @@ -254,19 +325,54 @@ const ReviewDisplayTab: React.FC = () => {
<div className="text-xs text-muted-foreground">Font family for diff code lines</div>
</div>
<select
value={diffFontFamily}
onChange={(e) => configStore.set('diffFontFamily', e.target.value)}
value={customActive ? CUSTOM_FONT_SENTINEL : diffFontFamily}
onChange={(e) => {
if (e.target.value === CUSTOM_FONT_SENTINEL) {
setCustomFontMode(true);
} else {
// Cancel any in-flight debounced custom save so a stale timer
// can't overwrite this selection.
cancelPendingFontSave();
setCustomFontMode(false);
setCustomFontDraft('');
configStore.set('diffFontFamily', e.target.value);
}
}}
className="w-full px-3 py-1.5 text-sm rounded-md bg-muted/50 border border-border text-foreground"
style={diffFontFamily ? { fontFamily: `'${diffFontFamily}', monospace` } : undefined}
style={diffFontFamily && !customActive ? fontPreviewStyle(diffFontFamily) : undefined}
>
{DIFF_FONT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
<option value={CUSTOM_FONT_SENTINEL}>Custom…</option>
</select>
{customActive && (
<div className="space-y-1">
<input
type="text"
value={customFontDraft}
onChange={(e) => handleCustomFontInput(e.target.value)}
placeholder="Font name as installed, e.g. Iosevka"
autoFocus={!customFontDraft}
spellCheck={false}
className="w-full px-3 py-1.5 text-sm rounded-md bg-muted/50 border border-border text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring"
style={customFontDraft ? fontPreviewStyle(customFontDraft) : undefined}
/>
<div className="text-[11px]">
{!customFontDraft.trim() ? (
<span className="text-muted-foreground">Any font installed on this machine works — type its family name.</span>
) : customFontInstalled === true ? (
<span className="text-success">✓ Found on this machine</span>
) : customFontInstalled === false ? (
<span className="text-warning">Not found — diffs will fall back to monospace</span>
) : null}
</div>
</div>
)}
{diffFontFamily && (
<div
className="text-xs text-muted-foreground px-1 py-1 rounded bg-muted/30 font-mono"
style={{ fontFamily: `'${diffFontFamily}', monospace` }}
style={fontPreviewStyle(diffFontFamily)}
>
Preview: const x = fn(42);
</div>
Expand Down
58 changes: 58 additions & 0 deletions packages/ui/utils/fontDetect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Local font detection via width measurement.
*
* Renders a glyph-diverse sample string with the candidate family in front of
* each generic fallback (monospace, serif, sans-serif) and compares measured
* widths against the fallback alone. If any pair differs, the browser resolved
* the candidate font — it is installed (or already loaded as a webfont).
*
* This observes actual rendering rather than querying font-availability APIs,
* so it is deterministic for a given machine + browser + installed-font set.
* `document.fonts.check()` is NOT used: its spec answers "would this render
* without a font load," which browsers answer inconsistently for families the
* page never registered.
*
* Known limits:
* - The name must be the family name as the OS reports it ("Iosevka Custom",
* not a file name).
* - A font installed mid-session is picked up on the next call (no event).
*/

const SAMPLE = "mmmmmmmmmmlli0O1Il@#WQ";
const SIZE = "72px";
const GENERICS = ["monospace", "serif", "sans-serif"] as const;

let ctx: CanvasRenderingContext2D | null | undefined;

function getContext(): CanvasRenderingContext2D | null {
if (ctx !== undefined) return ctx;
try {
ctx = document.createElement("canvas").getContext("2d");
} catch {
ctx = null;
}
return ctx;
}

/**
* Check whether a font family resolves on this machine.
*
* Returns `true` / `false` from width measurement, or `null` when measurement
* is unavailable (no canvas) — callers should treat `null` as "unknown" and
* skip any installed/missing hint.
*/
export function isFontInstalled(family: string): boolean | null {
const name = family.trim().replace(/["']/g, "");
if (!name) return false;

const c = getContext();
if (!c) return null;

for (const generic of GENERICS) {
c.font = `${SIZE} ${generic}`;
const base = c.measureText(SAMPLE).width;
c.font = `${SIZE} "${name}", ${generic}`;
if (c.measureText(SAMPLE).width !== base) return true;
}
return false;
}
Loading