Merge pull request #18 from NotAShelf/notashelf/push-swyypvuqxmyv

packages/web: add paste-from-clipboard in compare mode; fix overlaps
This commit is contained in:
raf 2026-04-16 08:31:55 +03:00 committed by GitHub
commit 06b78c6b0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 483 additions and 128 deletions

View file

@ -5,6 +5,12 @@ export default [
...tseslint.configs.recommended, ...tseslint.configs.recommended,
eslintConfigPrettier, eslintConfigPrettier,
{ {
files: ['**/*.{ts,tsx}'],
ignores: ['dist/', 'node_modules/', '.DS_Store', '*.md'], ignores: ['dist/', 'node_modules/', '.DS_Store', '*.md'],
settings: {
react: {
version: 'detect',
},
},
}, },
]; ];

View file

@ -6,10 +6,11 @@ export function formatBytes(bytes: number, precision = 2): string {
} }
export function formatNumber(num: number, precision = 2): string { export function formatNumber(num: number, precision = 2): string {
if (num >= 1e9) return (num / 1e9).toFixed(precision) + 'B'; if (num >= 1e9) return parseFloat((num / 1e9).toFixed(precision)).toString() + 'B';
if (num >= 1e6) return (num / 1e6).toFixed(precision) + 'M'; if (num >= 1e6) return parseFloat((num / 1e6).toFixed(precision)).toString() + 'M';
if (num >= 1e3) return (num / 1e3).toFixed(precision) + 'K'; if (num >= 1e3) return parseFloat((num / 1e3).toFixed(precision)).toString() + 'K';
return num.toString(); const factor = Math.pow(10, precision);
return (Math.round(num * factor) / factor).toString();
} }
export function formatTime(seconds: number, precision = 2): string { export function formatTime(seconds: number, precision = 2): string {

View file

@ -1,22 +1,75 @@
import { Component, For, createSignal, createMemo, Show } from 'solid-js'; import { Component, For, createSignal, createMemo, Show, onMount, onCleanup } from 'solid-js';
import { ComparisonEntry, calculateChange } from '@ns/core'; import { ComparisonEntry, calculateChange, StatsData } from '@ns/core';
import { formatBytes, formatNumber, formatTime, formatPercent } from '@ns/ui-utils'; import { formatBytes, formatNumber, formatTime, formatPercent } from '@ns/ui-utils';
import ArrowRightIcon from 'lucide-solid/icons/arrow-right'; import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
import ArrowDownIcon from 'lucide-solid/icons/arrow-down'; import ArrowDownIcon from 'lucide-solid/icons/arrow-down';
import ArrowUpIcon from 'lucide-solid/icons/arrow-up'; import ArrowUpIcon from 'lucide-solid/icons/arrow-up';
import XIcon from 'lucide-solid/icons/x'; import XIcon from 'lucide-solid/icons/x';
import FileUpload from './FileUpload';
interface ComparisonViewProps { interface ComparisonViewProps {
entries: ComparisonEntry[]; entries: ComparisonEntry[];
onSelect: (entry: ComparisonEntry) => void; onSelect: (entry: ComparisonEntry) => void;
onDelete: (id: number) => void; onDelete: (id: number) => void;
precision?: number; precision?: number;
pasteMode: 'advance' | 'replace';
onPasteModeChange: (mode: 'advance' | 'replace') => void;
onPasteStats: (text: string, name: string) => ComparisonEntry | null;
onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void;
onTextLoad: (text: string) => void;
} }
const ComparisonView: Component<ComparisonViewProps> = props => { const ComparisonView: Component<ComparisonViewProps> = props => {
const prec = () => props.precision ?? 2; const prec = () => props.precision ?? 2;
const [leftEntry, setLeftEntry] = createSignal<ComparisonEntry | null>(null); const [leftEntry, setLeftEntry] = createSignal<ComparisonEntry | null>(null);
const [rightEntry, setRightEntry] = createSignal<ComparisonEntry | null>(null); const [rightEntry, setRightEntry] = createSignal<ComparisonEntry | null>(null);
const [showPasteModal, setShowPasteModal] = createSignal(false);
const [pasteError, setPasteError] = createSignal('');
const [pasteName, setPasteName] = createSignal('');
const [pendingPasteText, setPendingPasteText] = createSignal('');
const handlePaste = (e: ClipboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
const text = e.clipboardData?.getData('text');
if (!text) return;
try {
JSON.parse(text);
setPendingPasteText(text);
setPasteName(`Snapshot ${props.entries.length + 1}`);
setShowPasteModal(true);
} catch {
// Silently ignore invalid JSON on paste
}
};
onMount(() => {
document.addEventListener('paste', handlePaste);
});
onCleanup(() => {
document.removeEventListener('paste', handlePaste);
});
const confirmPaste = () => {
const entry = props.onPasteStats(pendingPasteText(), pasteName());
if (!entry) {
setPasteError('Failed to process pasted statistics');
return;
}
if (props.pasteMode === 'advance') {
if (rightEntry()) {
setLeftEntry(rightEntry());
}
setRightEntry(entry);
} else {
setRightEntry(entry);
}
setShowPasteModal(false);
setPasteError('');
};
const comparison = createMemo(() => { const comparison = createMemo(() => {
const left = leftEntry(); const left = leftEntry();
@ -100,29 +153,47 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
return ( return (
<div class="comparison-view"> <div class="comparison-view">
<div class="comparison-controls"> <Show when={props.entries.length >= 2}>
<div class="compare-selector"> <div class="comparison-controls">
<label>Baseline</label> <div class="compare-selector">
<select onChange={selectLeft} value={leftEntry()?.id || ''}> <label>Baseline</label>
<option value="">Select snapshot...</option> <select onChange={selectLeft} value={leftEntry()?.id || ''}>
<For each={props.entries}> <option value="">Select snapshot...</option>
{entry => <option value={entry.id}>{entry.name}</option>} <For each={props.entries}>
</For> {entry => <option value={entry.id}>{entry.name}</option>}
</select> </For>
</select>
</div>
<div class="compare-arrow">
<ArrowRightIcon size={20} />
</div>
<div class="compare-selector">
<label>Current</label>
<select onChange={selectRight} value={rightEntry()?.id || ''}>
<option value="">Select snapshot...</option>
<For each={props.entries}>
{entry => <option value={entry.id}>{entry.name}</option>}
</For>
</select>
</div>
<div class="compare-paste-toggle">
<button
class={props.pasteMode === 'advance' ? 'active' : ''}
onClick={() => props.onPasteModeChange('advance')}
title="Paste shifts current to baseline"
>
Auto
</button>
<button
class={props.pasteMode === 'replace' ? 'active' : ''}
onClick={() => props.onPasteModeChange('replace')}
title="Paste replaces current only"
>
Replace
</button>
</div>
</div> </div>
<div class="compare-arrow"> </Show>
<ArrowRightIcon size={20} />
</div>
<div class="compare-selector">
<label>Current</label>
<select onChange={selectRight} value={rightEntry()?.id || ''}>
<option value="">Select snapshot...</option>
<For each={props.entries}>
{entry => <option value={entry.id}>{entry.name}</option>}
</For>
</select>
</div>
</div>
<Show when={props.entries.length > 0}> <Show when={props.entries.length > 0}>
<div class="snapshots-list"> <div class="snapshots-list">
@ -141,101 +212,186 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
</Show> </Show>
<Show <Show
when={leftEntry() && rightEntry()} when={props.entries.length >= 2}
fallback={<div class="compare-placeholder">Select two snapshots to compare metrics</div>} fallback={
> <div class="compare-upload-section">
<div class="comparison-table"> <FileUpload
<div class="compare-header"> onFileLoad={props.onFileLoad}
<div class="col-label">Metric</div> onTextLoad={props.onTextLoad}
<div class="col-value">{leftEntry()?.name}</div> snapshots={props.entries}
<div class="col-value">{rightEntry()?.name}</div> onLoadSnapshot={props.onSelect}
<div class="col-change">Change</div> />
</div> <Show when={props.entries.length === 1}>
<For each={comparison()}> <div class="compare-more-needed">
{row => ( <div class="compare-placeholder-hint">
<div Upload or paste one more snapshot to start comparing
class={`compare-row ${row.isDifferent ? 'diff' : ''} ${row.isMissing ? 'missing' : ''}`}
>
<div class="col-label" title={row.label}>
{row.label}
</div>
<div class="col-value">
<Show
when={row.isMissing}
fallback={
row.format === 'bytes'
? formatBytes(row.leftValue)
: row.format === 'time'
? formatTime(row.leftValue)
: row.format === 'percent'
? formatPercent(row.leftValue)
: formatNumber(row.leftValue)
}
>
<span class="missing-value">N/A</span>
</Show>
</div>
<div class="col-value">
<Show
when={row.isMissing}
fallback={
row.format === 'bytes'
? formatBytes(row.rightValue)
: row.format === 'time'
? formatTime(row.rightValue)
: row.format === 'percent'
? formatPercent(row.rightValue)
: formatNumber(row.rightValue)
}
>
<span class="missing-value">N/A</span>
</Show>
</div>
<div
class={`col-change ${row.isReduction ? 'good' : row.isDifferent && !row.isMissing ? 'bad' : ''}`}
>
<Show
when={row.isMissing}
fallback={
<Show when={row.isDifferent} fallback={<span class="neutral"></span>}>
<span class="change-value">
<Show when={row.isReduction}>
<ArrowDownIcon size={14} />
</Show>
<Show when={!row.isReduction}>
<ArrowUpIcon size={14} />
</Show>
{Math.abs(row.change).toFixed(prec())}%
</span>
</Show>
}
>
<span
class="missing-indicator"
title="Field not available in one or both implementations"
>
N/A
</span>
</Show>
</div> </div>
</div> </div>
)} </Show>
</For> </div>
</div> }
>
<Show
when={leftEntry() && rightEntry()}
fallback={
<div class="compare-placeholder">
<div>Select two snapshots to compare metrics</div>
<div class="compare-placeholder-hint">
Paste JSON stats here (Ctrl+V) while in compare mode
</div>
</div>
}
>
<div class="comparison-table">
<div class="compare-header">
<div class="col-label">Metric</div>
<div class="col-value">{leftEntry()?.name}</div>
<div class="col-value">{rightEntry()?.name}</div>
<div class="col-change">Change</div>
</div>
<For each={comparison()}>
{row => (
<div
class={`compare-row ${row.isDifferent ? 'diff' : ''} ${row.isMissing ? 'missing' : ''}`}
>
<div class="col-label" title={row.label}>
{row.label}
</div>
<div class="col-value">
<Show
when={row.isMissing}
fallback={
row.format === 'bytes'
? formatBytes(row.leftValue, prec())
: row.format === 'time'
? formatTime(row.leftValue, prec())
: row.format === 'percent'
? formatPercent(row.leftValue, prec())
: formatNumber(row.leftValue, prec())
}
>
<span class="missing-value">N/A</span>
</Show>
</div>
<div class="col-value">
<Show
when={row.isMissing}
fallback={
row.format === 'bytes'
? formatBytes(row.rightValue, prec())
: row.format === 'time'
? formatTime(row.rightValue, prec())
: row.format === 'percent'
? formatPercent(row.rightValue, prec())
: formatNumber(row.rightValue, prec())
}
>
<span class="missing-value">N/A</span>
</Show>
</div>
<div
class={`col-change ${row.isReduction ? 'good' : row.isDifferent && !row.isMissing ? 'bad' : ''}`}
>
<Show
when={row.isMissing}
fallback={
<Show when={row.isDifferent} fallback={<span class="neutral"></span>}>
<span class="change-value">
<Show when={row.isReduction}>
<ArrowDownIcon size={14} />
</Show>
<Show when={!row.isReduction}>
<ArrowUpIcon size={14} />
</Show>
{parseFloat(
(
Math.round(Math.abs(row.change) * Math.pow(10, prec())) /
Math.pow(10, prec())
).toFixed(prec()),
)}
%
</span>
</Show>
}
>
<span
class="missing-indicator"
title="Field not available in one or both implementations"
>
N/A
</span>
</Show>
</div>
</div>
)}
</For>
</div>
<div class="comparison-summary"> <div class="comparison-summary">
<Show when={comparison()?.some(r => r.isReduction)}> <Show when={comparison()?.some(r => r.isReduction)}>
<div class="summary-good"> <div class="summary-good">
<ArrowDownIcon size={16} />{' '} <ArrowDownIcon size={16} />{' '}
{comparison()?.filter(r => r.isReduction && r.isDifferent).length} improved {comparison()?.filter(r => r.isReduction && r.isDifferent).length} improved
</div>
</Show>
<Show when={comparison()?.some(r => !r.isReduction && r.isDifferent)}>
<div class="summary-bad">
<ArrowUpIcon size={16} />{' '}
{comparison()?.filter(r => !r.isReduction && r.isDifferent).length} regressed
</div>
</Show>
</div>
</Show>
</Show>
<Show when={showPasteModal()}>
<div
class="modal-overlay"
onClick={() => {
setShowPasteModal(false);
setPasteError('');
}}
>
<div class="modal" onClick={e => e.stopPropagation()}>
<div class="modal-header">
<h3>Paste Statistics</h3>
<button
class="close-btn"
onClick={() => {
setShowPasteModal(false);
setPasteError('');
}}
>
<XIcon size={20} />
</button>
</div> </div>
</Show> <input
<Show when={comparison()?.some(r => !r.isReduction && r.isDifferent)}> type="text"
<div class="summary-bad"> class="snapshot-name-input"
<ArrowUpIcon size={16} />{' '} placeholder="Enter snapshot name..."
{comparison()?.filter(r => !r.isReduction && r.isDifferent).length} regressed value={pasteName()}
onInput={e => setPasteName(e.currentTarget.value)}
onKeyDown={e => e.key === 'Enter' && confirmPaste()}
autofocus
/>
<Show when={pasteError()}>
<div class="error">{pasteError()}</div>
</Show>
<div class="modal-actions">
<button
class="cancel-btn"
onClick={() => {
setShowPasteModal(false);
setPasteError('');
}}
>
Cancel
</button>
<button class="confirm-btn" onClick={confirmPaste}>
{props.entries.length >= 2 ? 'Save & Compare' : 'Save Snapshot'}
</button>
</div> </div>
</Show> </div>
</div> </div>
</Show> </Show>
</div> </div>

View file

@ -6,16 +6,15 @@ import ClockIcon from 'lucide-solid/icons/clock';
interface FileUploadProps { interface FileUploadProps {
onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void; onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void;
onTextLoad: (text: string) => void; onTextLoad: (text: string) => void;
showHelp: boolean;
onToggleHelp: () => void;
snapshots?: ComparisonEntry[]; snapshots?: ComparisonEntry[];
onLoadSnapshot?: (entry: ComparisonEntry) => void; onLoadSnapshot?: (entry: ComparisonEntry) => void;
} }
export default function FileUpload(props: FileUploadProps) { export default function FileUpload(props: FileUploadProps) {
const [textInput, setTextInput] = createSignal(''); const [textInput, setTextInput] = createSignal('');
const [isTextMode, setIsTextMode] = createSignal(false); const [isTextMode, setIsTextMode] = createSignal(true);
const [error, setError] = createSignal(''); const [error, setError] = createSignal('');
const [showHelp, setShowHelp] = createSignal(false);
const handleFile = async (e: Event) => { const handleFile = async (e: Event) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
@ -49,11 +48,11 @@ export default function FileUpload(props: FileUploadProps) {
</div> </div>
<h2>Load Statistics</h2> <h2>Load Statistics</h2>
<div class="help-link" onClick={props.onToggleHelp}> <div class="help-link" onClick={() => setShowHelp(!showHelp())}>
How do I use this? How do I use this?
</div> </div>
<Show when={props.showHelp}> <Show when={showHelp()}>
<div class="help-panel"> <div class="help-panel">
<h4>Generating Stats</h4> <h4>Generating Stats</h4>
<code>NIX_SHOW_STATS=1 nix-instantiate expr.nix &gt; stats.json</code> <code>NIX_SHOW_STATS=1 nix-instantiate expr.nix &gt; stats.json</code>
@ -85,6 +84,18 @@ export default function FileUpload(props: FileUploadProps) {
class="json-input" class="json-input"
value={textInput()} value={textInput()}
onInput={e => setTextInput(e.currentTarget.value)} onInput={e => setTextInput(e.currentTarget.value)}
onPaste={e => {
const text = e.clipboardData?.getData('text');
if (!text) return;
try {
JSON.parse(text);
e.preventDefault();
props.onTextLoad(text);
} catch {
setTextInput(text);
setError('Invalid JSON in clipboard');
}
}}
/> />
<button class="load-btn" onClick={handleTextLoad}> <button class="load-btn" onClick={handleTextLoad}>
Load Load

View file

@ -49,7 +49,10 @@ const ThunkChart: Component<ThunkChartProps> = props => {
<div class="ratio-bar"> <div class="ratio-bar">
<div class="ratio-fill" style={{ width: `${avoidedRatio() * 100}%` }} /> <div class="ratio-fill" style={{ width: `${avoidedRatio() * 100}%` }} />
</div> </div>
<span class="ratio-label">Avoidance rate: {(avoidedRatio() * 100).toFixed(prec())}%</span> <span class="ratio-label">
Avoidance rate:{' '}
{Math.round(avoidedRatio() * 100 * Math.pow(10, prec())) / Math.pow(10, prec())}%
</span>
</div> </div>
</div> </div>
); );

View file

@ -29,10 +29,12 @@ function App() {
const [view, setView] = createSignal<'analysis' | 'compare'>('analysis'); const [view, setView] = createSignal<'analysis' | 'compare'>('analysis');
const [snapshotName, setSnapshotName] = createSignal(''); const [snapshotName, setSnapshotName] = createSignal('');
const [showSaveDialog, setShowSaveDialog] = createSignal(false); const [showSaveDialog, setShowSaveDialog] = createSignal(false);
const [showHelp, setShowHelp] = createSignal(false);
const [showManageSnapshots, setShowManageSnapshots] = createSignal(false); const [showManageSnapshots, setShowManageSnapshots] = createSignal(false);
const [precision, setPrecision] = createSignal(2); const [precision, setPrecision] = createSignal(2);
const [pasteMode, setPasteMode] = createSignal<'advance' | 'replace'>('advance');
const [isLoading, setIsLoading] = createSignal(true); const [isLoading, setIsLoading] = createSignal(true);
const [showOverrideModal, setShowOverrideModal] = createSignal(false);
const [pendingOverrideText, setPendingOverrideText] = createSignal('');
const STORAGE_KEY = 'ns-data'; const STORAGE_KEY = 'ns-data';
@ -62,6 +64,9 @@ function App() {
if (typeof parsed.precision === 'number' && parsed.precision >= 0) { if (typeof parsed.precision === 'number' && parsed.precision >= 0) {
setPrecision(parsed.precision); setPrecision(parsed.precision);
} }
if (parsed.pasteMode === 'advance' || parsed.pasteMode === 'replace') {
setPasteMode(parsed.pasteMode);
}
} }
} catch (e) { } catch (e) {
console.warn('Failed to load saved data:', e); console.warn('Failed to load saved data:', e);
@ -76,8 +81,28 @@ function App() {
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
const handleAnalysisPaste = (e: ClipboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
const text = e.clipboardData?.getData('text');
if (!text) return;
try {
JSON.parse(text);
} catch {
return;
}
if (view() === 'analysis' && currentStats()) {
setPendingOverrideText(text);
setShowOverrideModal(true);
}
};
document.addEventListener('paste', handleAnalysisPaste);
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('paste', handleAnalysisPaste);
}; };
}); });
@ -89,6 +114,7 @@ function App() {
snaps: ComparisonEntry[], snaps: ComparisonEntry[],
v: 'analysis' | 'compare', v: 'analysis' | 'compare',
prec: number, prec: number,
pm: 'advance' | 'replace',
) => { ) => {
try { try {
localStorage.setItem( localStorage.setItem(
@ -99,6 +125,7 @@ function App() {
currentRaw: raw, currentRaw: raw,
view: v, view: v,
precision: prec, precision: prec,
pasteMode: pm,
}), }),
); );
} catch (e) { } catch (e) {
@ -115,10 +142,11 @@ function App() {
const snaps = snapshots(); const snaps = snapshots();
const v = view(); const v = view();
const prec = precision(); const prec = precision();
const pm = pasteMode();
if (isLoading()) return; if (isLoading()) return;
saveToStorage(stats, raw, snaps, v, prec); saveToStorage(stats, raw, snaps, v, prec, pm);
}); });
const saveSnapshot = () => { const saveSnapshot = () => {
@ -171,6 +199,55 @@ function App() {
} }
}; };
const handlePasteStats = (text: string, name: string): ComparisonEntry | null => {
let raw: Record<string, unknown>;
let data: StatsData;
try {
raw = JSON.parse(text);
data = parseStats(raw);
} catch (e) {
console.error('Failed to parse pasted stats:', e);
return null;
}
const entry: ComparisonEntry = {
id: Date.now(),
name: name.trim() || `Snapshot ${snapshots().length + 1}`,
data,
raw,
timestamp: new Date(),
};
setSnapshots(prev => [...prev, entry]);
return entry;
};
const handleCompareFileLoad = (data: StatsData, raw: Record<string, unknown>) => {
const entry: ComparisonEntry = {
id: Date.now(),
name: `Snapshot ${snapshots().length + 1}`,
data,
raw,
timestamp: new Date(),
};
setSnapshots(prev => [...prev, entry]);
};
const handleCompareTextLoad = (text: string) => {
try {
const raw = JSON.parse(text);
const data = parseStats(raw);
const entry: ComparisonEntry = {
id: Date.now(),
name: `Snapshot ${snapshots().length + 1}`,
data,
raw,
timestamp: new Date(),
};
setSnapshots(prev => [...prev, entry]);
} catch (e) {
console.error('Failed to parse stats:', e);
}
};
return ( return (
<div class="app"> <div class="app">
<header class="header"> <header class="header">
@ -181,11 +258,7 @@ function App() {
<button class={view() === 'analysis' ? 'active' : ''} onClick={() => setView('analysis')}> <button class={view() === 'analysis' ? 'active' : ''} onClick={() => setView('analysis')}>
Analysis Analysis
</button> </button>
<button <button class={view() === 'compare' ? 'active' : ''} onClick={() => setView('compare')}>
class={view() === 'compare' ? 'active' : ''}
onClick={() => setView('compare')}
disabled={snapshots().length < 2}
>
Compare ({snapshots().length}) Compare ({snapshots().length})
</button> </button>
<Show when={snapshots().length > 0}> <Show when={snapshots().length > 0}>
@ -223,8 +296,6 @@ function App() {
<FileUpload <FileUpload
onFileLoad={handleFileLoad} onFileLoad={handleFileLoad}
onTextLoad={loadFromText} onTextLoad={loadFromText}
showHelp={showHelp()}
onToggleHelp={() => setShowHelp(!showHelp())}
snapshots={snapshots()} snapshots={snapshots()}
onLoadSnapshot={loadSnapshot} onLoadSnapshot={loadSnapshot}
/> />
@ -255,6 +326,30 @@ function App() {
</div> </div>
</div> </div>
</Show> </Show>
<Show when={showOverrideModal()}>
<div class="modal-overlay" onClick={() => setShowOverrideModal(false)}>
<div class="modal" onClick={e => e.stopPropagation()}>
<h3>Override Analysis?</h3>
<p style={{ color: 'var(--text-secondary)', 'margin-bottom': '1rem' }}>
This will replace the current analysis with pasted data.
</p>
<div class="modal-actions">
<button class="cancel-btn" onClick={() => setShowOverrideModal(false)}>
Cancel
</button>
<button
class="confirm-btn"
onClick={() => {
loadFromText(pendingOverrideText());
setShowOverrideModal(false);
}}
>
Override
</button>
</div>
</div>
</div>
</Show>
</Show> </Show>
</Show> </Show>
@ -264,6 +359,11 @@ function App() {
onSelect={entry => setCurrentStats(entry.data)} onSelect={entry => setCurrentStats(entry.data)}
onDelete={deleteSnapshot} onDelete={deleteSnapshot}
precision={precision()} precision={precision()}
pasteMode={pasteMode()}
onPasteModeChange={setPasteMode}
onPasteStats={handlePasteStats}
onFileLoad={handleCompareFileLoad}
onTextLoad={handleCompareTextLoad}
/> />
</Show> </Show>
</Show> </Show>

View file

@ -183,6 +183,7 @@ body {
align-items: center; align-items: center;
gap: 2rem; gap: 2rem;
padding-top: 4rem; padding-top: 4rem;
width: 100%;
} }
.upload-card { .upload-card {
@ -824,6 +825,9 @@ body {
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-nums); font-family: var(--font-nums);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
word-break: break-word;
overflow-wrap: break-word;
line-height: 1.2;
} }
.metric-label { .metric-label {
@ -983,6 +987,8 @@ body {
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-nums); font-family: var(--font-nums);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
word-break: break-word;
overflow-wrap: break-word;
} }
.chart-legend { .chart-legend {
@ -1029,6 +1035,8 @@ body {
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-nums); font-family: var(--font-nums);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
word-break: break-word;
overflow-wrap: break-word;
} }
.legend-percent { .legend-percent {
@ -1036,6 +1044,8 @@ body {
font-size: 0.75rem; font-size: 0.75rem;
text-align: right; text-align: right;
min-width: 40px; min-width: 40px;
word-break: break-word;
overflow-wrap: break-word;
} }
.donut-chart svg { .donut-chart svg {
@ -1130,6 +1140,8 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
min-width: 80px; min-width: 80px;
text-align: right; text-align: right;
word-break: break-word;
overflow-wrap: break-word;
} }
.gc-stats { .gc-stats {
@ -1157,6 +1169,8 @@ body {
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-nums); font-family: var(--font-nums);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
word-break: break-word;
overflow-wrap: break-word;
} }
/* Operations Chart */ /* Operations Chart */
@ -1213,6 +1227,8 @@ body {
font-family: var(--font-nums); font-family: var(--font-nums);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-align: right; text-align: right;
word-break: break-word;
overflow-wrap: break-word;
} }
/* Thunk Chart */ /* Thunk Chart */
@ -1267,6 +1283,8 @@ body {
font-family: var(--font-nums); font-family: var(--font-nums);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-align: right; text-align: right;
word-break: break-word;
overflow-wrap: break-word;
} }
.thunk-ratio { .thunk-ratio {
@ -1294,6 +1312,8 @@ body {
.ratio-label { .ratio-label {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
word-break: break-word;
overflow-wrap: break-word;
} }
.dashboard-grid { .dashboard-grid {
@ -1341,6 +1361,8 @@ body {
font-family: var(--font-nums); font-family: var(--font-nums);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-align: right; text-align: right;
word-break: break-word;
overflow-wrap: break-word;
} }
/* Top Lists */ /* Top Lists */
@ -1384,6 +1406,9 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
font-family: var(--font-nums); font-family: var(--font-nums);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
word-break: break-word;
overflow-wrap: break-word;
text-align: right;
} }
.top-item .location { .top-item .location {
@ -1441,6 +1466,36 @@ body {
justify-content: center; justify-content: center;
} }
.compare-paste-toggle {
display: flex;
gap: 0.25rem;
align-items: flex-end;
padding: 0 0.5rem;
}
.compare-paste-toggle button {
height: calc(0.75rem * 2 + 1rem + 2px);
padding: 0 0.625rem;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-muted);
border-radius: 0.375rem;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.compare-paste-toggle button:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.compare-paste-toggle button.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.compare-placeholder { .compare-placeholder {
text-align: center; text-align: center;
padding: 4rem; padding: 4rem;
@ -1450,6 +1505,25 @@ body {
border-radius: 0.75rem; border-radius: 0.75rem;
} }
.compare-placeholder-hint {
margin-top: 0.75rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.compare-upload-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 100%;
}
.compare-more-needed {
text-align: center;
padding: 1rem;
}
.comparison-table { .comparison-table {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@ -1510,10 +1584,14 @@ body {
color: var(--text-secondary); color: var(--text-secondary);
font-family: var(--font-nums); font-family: var(--font-nums);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
word-break: break-word;
overflow-wrap: break-word;
} }
.compare-row .col-change { .compare-row .col-change {
text-align: right; text-align: right;
word-break: break-word;
overflow-wrap: break-word;
} }
.change-value { .change-value {