From f7457fb9a4d668733eaa2c813c41203dd678583f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 14 Apr 2026 09:30:50 +0300 Subject: [PATCH 1/5] meta: detect react version and fine-grain files for eslint Signed-off-by: NotAShelf Change-Id: Iad7f3ee0e5e826f00c4c95874371bfcd6a6a6964 --- eslint.config.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 7816ea6..bf54789 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,12 @@ export default [ ...tseslint.configs.recommended, eslintConfigPrettier, { + files: ['**/*.{ts,tsx}'], ignores: ['dist/', 'node_modules/', '.DS_Store', '*.md'], + settings: { + react: { + version: 'detect', + }, + }, }, ]; From 697f1f1c7349ed6a1691d212cc3ed7084672dcbf Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 14 Apr 2026 09:46:42 +0300 Subject: [PATCH 2/5] packages/web: add paste-from-clipboard in compare mode; fix overlaps Signed-off-by: NotAShelf Change-Id: I7311a4592d148ebbe8ab72b5091b82a46a6a6964 --- .../web/src/components/ComparisonView.tsx | 135 ++++++++++++++++-- packages/web/src/index.tsx | 33 ++++- packages/web/src/styles.css | 58 ++++++++ 3 files changed, 216 insertions(+), 10 deletions(-) diff --git a/packages/web/src/components/ComparisonView.tsx b/packages/web/src/components/ComparisonView.tsx index 1c7941e..02e8fbc 100644 --- a/packages/web/src/components/ComparisonView.tsx +++ b/packages/web/src/components/ComparisonView.tsx @@ -1,4 +1,4 @@ -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 { formatBytes, formatNumber, formatTime, formatPercent } from '@ns/ui-utils'; import ArrowRightIcon from 'lucide-solid/icons/arrow-right'; @@ -11,12 +11,62 @@ interface ComparisonViewProps { onSelect: (entry: ComparisonEntry) => void; onDelete: (id: number) => void; precision?: number; + pasteMode: 'advance' | 'replace'; + onPasteModeChange: (mode: 'advance' | 'replace') => void; + onPasteStats: (text: string, name: string) => ComparisonEntry | null; } const ComparisonView: Component = props => { const prec = () => props.precision ?? 2; const [leftEntry, setLeftEntry] = createSignal(null); const [rightEntry, setRightEntry] = createSignal(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 left = leftEntry(); @@ -122,6 +172,22 @@ const ComparisonView: Component = props => { +
+ + +
0}> @@ -164,12 +230,12 @@ const ComparisonView: Component = props => { when={row.isMissing} fallback={ row.format === 'bytes' - ? formatBytes(row.leftValue) + ? formatBytes(row.leftValue, prec()) : row.format === 'time' - ? formatTime(row.leftValue) + ? formatTime(row.leftValue, prec()) : row.format === 'percent' - ? formatPercent(row.leftValue) - : formatNumber(row.leftValue) + ? formatPercent(row.leftValue, prec()) + : formatNumber(row.leftValue, prec()) } > N/A @@ -180,12 +246,12 @@ const ComparisonView: Component = props => { when={row.isMissing} fallback={ row.format === 'bytes' - ? formatBytes(row.rightValue) + ? formatBytes(row.rightValue, prec()) : row.format === 'time' - ? formatTime(row.rightValue) + ? formatTime(row.rightValue, prec()) : row.format === 'percent' - ? formatPercent(row.rightValue) - : formatNumber(row.rightValue) + ? formatPercent(row.rightValue, prec()) + : formatNumber(row.rightValue, prec()) } > N/A @@ -238,6 +304,57 @@ const ComparisonView: Component = props => { + + + + ); }; diff --git a/packages/web/src/index.tsx b/packages/web/src/index.tsx index 578bb8a..31290b8 100644 --- a/packages/web/src/index.tsx +++ b/packages/web/src/index.tsx @@ -32,6 +32,7 @@ function App() { const [showHelp, setShowHelp] = createSignal(false); const [showManageSnapshots, setShowManageSnapshots] = createSignal(false); const [precision, setPrecision] = createSignal(2); + const [pasteMode, setPasteMode] = createSignal<'advance' | 'replace'>('advance'); const [isLoading, setIsLoading] = createSignal(true); const STORAGE_KEY = 'ns-data'; @@ -62,6 +63,9 @@ function App() { if (typeof parsed.precision === 'number' && parsed.precision >= 0) { setPrecision(parsed.precision); } + if (parsed.pasteMode === 'advance' || parsed.pasteMode === 'replace') { + setPasteMode(parsed.pasteMode); + } } } catch (e) { console.warn('Failed to load saved data:', e); @@ -89,6 +93,7 @@ function App() { snaps: ComparisonEntry[], v: 'analysis' | 'compare', prec: number, + pm: 'advance' | 'replace', ) => { try { localStorage.setItem( @@ -99,6 +104,7 @@ function App() { currentRaw: raw, view: v, precision: prec, + pasteMode: pm, }), ); } catch (e) { @@ -115,10 +121,11 @@ function App() { const snaps = snapshots(); const v = view(); const prec = precision(); + const pm = pasteMode(); if (isLoading()) return; - saveToStorage(stats, raw, snaps, v, prec); + saveToStorage(stats, raw, snaps, v, prec, pm); }); const saveSnapshot = () => { @@ -171,6 +178,27 @@ function App() { } }; + const handlePasteStats = (text: string, name: string): ComparisonEntry | null => { + let raw: Record; + 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; + }; + return (
@@ -264,6 +292,9 @@ function App() { onSelect={entry => setCurrentStats(entry.data)} onDelete={deleteSnapshot} precision={precision()} + pasteMode={pasteMode()} + onPasteModeChange={setPasteMode} + onPasteStats={handlePasteStats} /> diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 95e9806..17b5920 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -824,6 +824,9 @@ body { color: var(--text-primary); font-family: var(--font-nums); font-variant-numeric: tabular-nums; + word-break: break-word; + overflow-wrap: break-word; + line-height: 1.2; } .metric-label { @@ -983,6 +986,8 @@ body { color: var(--text-primary); font-family: var(--font-nums); font-variant-numeric: tabular-nums; + word-break: break-word; + overflow-wrap: break-word; } .chart-legend { @@ -1029,6 +1034,8 @@ body { color: var(--text-primary); font-family: var(--font-nums); font-variant-numeric: tabular-nums; + word-break: break-word; + overflow-wrap: break-word; } .legend-percent { @@ -1036,6 +1043,8 @@ body { font-size: 0.75rem; text-align: right; min-width: 40px; + word-break: break-word; + overflow-wrap: break-word; } .donut-chart svg { @@ -1130,6 +1139,8 @@ body { color: var(--text-secondary); min-width: 80px; text-align: right; + word-break: break-word; + overflow-wrap: break-word; } .gc-stats { @@ -1157,6 +1168,8 @@ body { color: var(--text-primary); font-family: var(--font-nums); font-variant-numeric: tabular-nums; + word-break: break-word; + overflow-wrap: break-word; } /* Operations Chart */ @@ -1213,6 +1226,8 @@ body { font-family: var(--font-nums); font-variant-numeric: tabular-nums; text-align: right; + word-break: break-word; + overflow-wrap: break-word; } /* Thunk Chart */ @@ -1267,6 +1282,8 @@ body { font-family: var(--font-nums); font-variant-numeric: tabular-nums; text-align: right; + word-break: break-word; + overflow-wrap: break-word; } .thunk-ratio { @@ -1294,6 +1311,8 @@ body { .ratio-label { font-size: 0.75rem; color: var(--text-muted); + word-break: break-word; + overflow-wrap: break-word; } .dashboard-grid { @@ -1341,6 +1360,8 @@ body { font-family: var(--font-nums); font-variant-numeric: tabular-nums; text-align: right; + word-break: break-word; + overflow-wrap: break-word; } /* Top Lists */ @@ -1384,6 +1405,9 @@ body { color: var(--text-secondary); font-family: var(--font-nums); font-variant-numeric: tabular-nums; + word-break: break-word; + overflow-wrap: break-word; + text-align: right; } .top-item .location { @@ -1441,6 +1465,36 @@ body { 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 { text-align: center; padding: 4rem; @@ -1510,10 +1564,14 @@ body { color: var(--text-secondary); font-family: var(--font-nums); font-variant-numeric: tabular-nums; + word-break: break-word; + overflow-wrap: break-word; } .compare-row .col-change { text-align: right; + word-break: break-word; + overflow-wrap: break-word; } .change-value { From 1541046669b4af94f9ccda05ab1a513e56bdac84 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 14 Apr 2026 11:26:48 +0300 Subject: [PATCH 3/5] jj commit -m "packages/web: add paste/upload snapshot flows; unify inputs" Signed-off-by: NotAShelf Change-Id: I7cb4e2ee6962e5d343cd52da6524b3596a6a6964 --- .../web/src/components/ComparisonView.tsx | 325 ++++++++++-------- packages/web/src/components/FileUpload.tsx | 20 +- packages/web/src/index.tsx | 85 ++++- packages/web/src/styles.css | 20 ++ 4 files changed, 291 insertions(+), 159 deletions(-) diff --git a/packages/web/src/components/ComparisonView.tsx b/packages/web/src/components/ComparisonView.tsx index 02e8fbc..8b58ad4 100644 --- a/packages/web/src/components/ComparisonView.tsx +++ b/packages/web/src/components/ComparisonView.tsx @@ -1,10 +1,11 @@ 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 ArrowRightIcon from 'lucide-solid/icons/arrow-right'; import ArrowDownIcon from 'lucide-solid/icons/arrow-down'; import ArrowUpIcon from 'lucide-solid/icons/arrow-up'; import XIcon from 'lucide-solid/icons/x'; +import FileUpload from './FileUpload'; interface ComparisonViewProps { entries: ComparisonEntry[]; @@ -14,6 +15,8 @@ interface ComparisonViewProps { pasteMode: 'advance' | 'replace'; onPasteModeChange: (mode: 'advance' | 'replace') => void; onPasteStats: (text: string, name: string) => ComparisonEntry | null; + onFileLoad: (data: StatsData, raw: Record) => void; + onTextLoad: (text: string) => void; } const ComparisonView: Component = props => { @@ -150,159 +153,189 @@ const ComparisonView: Component = props => { return (
-
-
- - -
-
- -
-
- - -
-
- - -
-
- - 0}> -
-

Saved Snapshots

- - {entry => ( -
- {entry.name} - -
- )} -
+ = 2}> +
+
+ + +
+
+ +
+
+ + +
+
+ + +
Select two snapshots to compare metrics
} - > -
-
-
Metric
-
{leftEntry()?.name}
-
{rightEntry()?.name}
-
Change
-
- - {row => ( -
-
- {row.label} -
-
- - N/A - -
-
- - N/A - -
-
- —}> - - - - - - - - {Math.abs(row.change).toFixed(prec())}% - - - } - > - - N/A - - + when={props.entries.length >= 2} + fallback={ +
+ + +
+
+ Upload or paste one more snapshot to start comparing
- )} - -
+ +
+ } + > + 0}> +
+

Saved Snapshots

+ + {entry => ( +
+ {entry.name} + +
+ )} +
+
+
-
- r.isReduction)}> -
- {' '} - {comparison()?.filter(r => r.isReduction && r.isDifferent).length} improved + +
Select two snapshots to compare metrics
+
+ Paste JSON stats here (Ctrl+V) while in compare mode +
-
- !r.isReduction && r.isDifferent)}> -
- {' '} - {comparison()?.filter(r => !r.isReduction && r.isDifferent).length} regressed + } + > +
+
+
Metric
+
{leftEntry()?.name}
+
{rightEntry()?.name}
+
Change
- -
+ + {row => ( +
+
+ {row.label} +
+
+ + N/A + +
+
+ + N/A + +
+
+ —}> + + + + + + + + {Math.abs(row.change).toFixed(prec())}% + + + } + > + + N/A + + +
+
+ )} +
+
+ +
+ r.isReduction)}> +
+ {' '} + {comparison()?.filter(r => r.isReduction && r.isDifferent).length} improved +
+
+ !r.isReduction && r.isDifferent)}> +
+ {' '} + {comparison()?.filter(r => !r.isReduction && r.isDifferent).length} regressed +
+
+
+
@@ -349,7 +382,7 @@ const ComparisonView: Component = props => { Cancel
diff --git a/packages/web/src/components/FileUpload.tsx b/packages/web/src/components/FileUpload.tsx index 95e5c20..1bb8c7f 100644 --- a/packages/web/src/components/FileUpload.tsx +++ b/packages/web/src/components/FileUpload.tsx @@ -6,16 +6,15 @@ import ClockIcon from 'lucide-solid/icons/clock'; interface FileUploadProps { onFileLoad: (data: StatsData, raw: Record) => void; onTextLoad: (text: string) => void; - showHelp: boolean; - onToggleHelp: () => void; snapshots?: ComparisonEntry[]; onLoadSnapshot?: (entry: ComparisonEntry) => void; } export default function FileUpload(props: FileUploadProps) { const [textInput, setTextInput] = createSignal(''); - const [isTextMode, setIsTextMode] = createSignal(false); + const [isTextMode, setIsTextMode] = createSignal(true); const [error, setError] = createSignal(''); + const [showHelp, setShowHelp] = createSignal(false); const handleFile = async (e: Event) => { const target = e.target as HTMLInputElement; @@ -49,11 +48,11 @@ export default function FileUpload(props: FileUploadProps) {

Load Statistics

-
+ + + @@ -295,6 +362,8 @@ function App() { pasteMode={pasteMode()} onPasteModeChange={setPasteMode} onPasteStats={handlePasteStats} + onFileLoad={handleCompareFileLoad} + onTextLoad={handleCompareTextLoad} /> diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 17b5920..86e387d 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -183,6 +183,7 @@ body { align-items: center; gap: 2rem; padding-top: 4rem; + width: 100%; } .upload-card { @@ -1504,6 +1505,25 @@ body { 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 { background: var(--card-bg); border: 1px solid var(--border-color); From 6102e75a9ed7590b2fafcd009d653eac71e61f93 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 14 Apr 2026 13:02:13 +0300 Subject: [PATCH 4/5] packages/web: allow editing invalid JSON paste; show snapshots list with 1+ entries Signed-off-by: NotAShelf Change-Id: I627907cd02575fb6e84c7837cf1712746a6a6964 --- .../web/src/components/ComparisonView.tsx | 32 +++++++++---------- packages/web/src/components/FileUpload.tsx | 3 +- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/web/src/components/ComparisonView.tsx b/packages/web/src/components/ComparisonView.tsx index 8b58ad4..e67bb8b 100644 --- a/packages/web/src/components/ComparisonView.tsx +++ b/packages/web/src/components/ComparisonView.tsx @@ -195,6 +195,22 @@ const ComparisonView: Component = props => {
+ 0}> +
+

Saved Snapshots

+ + {entry => ( +
+ {entry.name} + +
+ )} +
+
+
+ = 2} fallback={ @@ -215,22 +231,6 @@ const ComparisonView: Component = props => {
} > - 0}> -
-

Saved Snapshots

- - {entry => ( -
- {entry.name} - -
- )} -
-
-
- { const text = e.clipboardData?.getData('text'); if (!text) return; - e.preventDefault(); try { JSON.parse(text); + e.preventDefault(); props.onTextLoad(text); } catch { + setTextInput(text); setError('Invalid JSON in clipboard'); } }} From d26a70dacd8f487970f56e5f301caa9a2b5d1c9a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 14 Apr 2026 14:59:46 +0300 Subject: [PATCH 5/5] various: eliminate floating-point noise before displaying Signed-off-by: NotAShelf Change-Id: Ic14f4c0a9e0bcbe3460bbecc670f713d6a6a6964 --- packages/ui-utils/src/formatters.ts | 9 +++++---- packages/web/src/components/ComparisonView.tsx | 8 +++++++- packages/web/src/components/ThunkChart.tsx | 5 ++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/ui-utils/src/formatters.ts b/packages/ui-utils/src/formatters.ts index f55309a..7764c4c 100644 --- a/packages/ui-utils/src/formatters.ts +++ b/packages/ui-utils/src/formatters.ts @@ -6,10 +6,11 @@ export function formatBytes(bytes: number, precision = 2): string { } export function formatNumber(num: number, precision = 2): string { - if (num >= 1e9) return (num / 1e9).toFixed(precision) + 'B'; - if (num >= 1e6) return (num / 1e6).toFixed(precision) + 'M'; - if (num >= 1e3) return (num / 1e3).toFixed(precision) + 'K'; - return num.toString(); + if (num >= 1e9) return parseFloat((num / 1e9).toFixed(precision)).toString() + 'B'; + if (num >= 1e6) return parseFloat((num / 1e6).toFixed(precision)).toString() + 'M'; + if (num >= 1e3) return parseFloat((num / 1e3).toFixed(precision)).toString() + 'K'; + const factor = Math.pow(10, precision); + return (Math.round(num * factor) / factor).toString(); } export function formatTime(seconds: number, precision = 2): string { diff --git a/packages/web/src/components/ComparisonView.tsx b/packages/web/src/components/ComparisonView.tsx index e67bb8b..7bc516c 100644 --- a/packages/web/src/components/ComparisonView.tsx +++ b/packages/web/src/components/ComparisonView.tsx @@ -303,7 +303,13 @@ const ComparisonView: Component = props => { - {Math.abs(row.change).toFixed(prec())}% + {parseFloat( + ( + Math.round(Math.abs(row.change) * Math.pow(10, prec())) / + Math.pow(10, prec()) + ).toFixed(prec()), + )} + % } diff --git a/packages/web/src/components/ThunkChart.tsx b/packages/web/src/components/ThunkChart.tsx index 7a78938..e5cbc5a 100644 --- a/packages/web/src/components/ThunkChart.tsx +++ b/packages/web/src/components/ThunkChart.tsx @@ -49,7 +49,10 @@ const ThunkChart: Component = props => {
- Avoidance rate: {(avoidedRatio() * 100).toFixed(prec())}% + + Avoidance rate:{' '} + {Math.round(avoidedRatio() * 100 * Math.pow(10, prec())) / Math.pow(10, prec())}% +
);