gitmaps 1.1.5 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/analytics.db CHANGED
Binary file
package/app/globals.css CHANGED
@@ -1145,10 +1145,64 @@ body {
1145
1145
 
1146
1146
  /* ── Sticky Zoom Controls ── */
1147
1147
  body.repo-loading .sticky-zoom-pill,
1148
- body.landing-placeholder-visible .sticky-zoom-pill {
1148
+ body.repo-loading .detail-mode-switch,
1149
+ body.landing-placeholder-visible .sticky-zoom-pill,
1150
+ body.landing-placeholder-visible .detail-mode-switch {
1149
1151
  display: none;
1150
1152
  }
1151
1153
 
1154
+ .detail-mode-switch {
1155
+ position: fixed;
1156
+ top: 12px;
1157
+ right: 144px;
1158
+ z-index: 10001;
1159
+ }
1160
+
1161
+ .detail-mode-btn {
1162
+ display: inline-flex;
1163
+ align-items: center;
1164
+ gap: 8px;
1165
+ padding: 6px 10px;
1166
+ background: rgba(15, 23, 42, 0.88);
1167
+ border: 1px solid rgba(124, 58, 237, 0.24);
1168
+ border-radius: 10px;
1169
+ color: #d8ccff;
1170
+ font-family: 'JetBrains Mono', monospace;
1171
+ font-size: 10px;
1172
+ cursor: pointer;
1173
+ backdrop-filter: blur(8px);
1174
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
1175
+ transition:
1176
+ background 0.2s ease,
1177
+ border-color 0.2s ease,
1178
+ transform 0.12s ease;
1179
+ }
1180
+
1181
+ .detail-mode-btn:hover {
1182
+ background: rgba(15, 23, 42, 0.96);
1183
+ border-color: rgba(124, 58, 237, 0.45);
1184
+ }
1185
+
1186
+ .detail-mode-btn:active {
1187
+ transform: scale(0.98);
1188
+ }
1189
+
1190
+ .detail-mode-btn.active {
1191
+ border-color: rgba(96, 165, 250, 0.6);
1192
+ box-shadow: 0 10px 28px rgba(59, 130, 246, 0.16);
1193
+ }
1194
+
1195
+ .detail-mode-label {
1196
+ opacity: 0.72;
1197
+ text-transform: uppercase;
1198
+ letter-spacing: 0.06em;
1199
+ }
1200
+
1201
+ .detail-mode-state {
1202
+ color: #f8fafc;
1203
+ font-weight: 700;
1204
+ }
1205
+
1152
1206
  .sticky-zoom-pill {
1153
1207
  position: absolute;
1154
1208
  bottom: 16px;
package/app/layout.tsx CHANGED
@@ -875,6 +875,13 @@ export default function RootLayout({ children }: { children: any }) {
875
875
  </div>
876
876
 
877
877
  {/* Sticky Zoom Controls — floating pill, bottom-right */}
878
+ <div id="detailModeSwitch" className="detail-mode-switch" title="Toggle preview-focused detail mode">
879
+ <button id="toggleDetailMode" type="button" className="detail-mode-btn">
880
+ <span className="detail-mode-label">Preview mode</span>
881
+ <span id="detailModeState" className="detail-mode-state">Auto</span>
882
+ </button>
883
+ </div>
884
+
878
885
  <div id="stickyZoomControls" className="sticky-zoom-pill">
879
886
  <button id="stickyZoomOut" className="sz-btn" title="Zoom out">
880
887
  <svg
@@ -27,7 +27,7 @@ import { createLayer, getActiveLayer, addSectionToLayer } from './layers';
27
27
  import { updateCanvasTransform, updateZoomUI, updateMinimap, fitAllFiles, setupMinimapClick } from './canvas';
28
28
  import { zoomTowardScreen, panByDelta, screenToWorld, getCardManager } from './xydraw-bridge';
29
29
  import { hideSelectedFiles, showHiddenFilesModal as showHiddenModal } from './hidden-files';
30
- import { updatePillSelectionHighlights } from './viewport-culling';
30
+ import { getDetailMode, toggleDetailMode, updatePillSelectionHighlights } from './viewport-culling';
31
31
  import { clearSelectionHighlights, updateSelectionHighlights, updateArrangeToolbar, arrangeRow, arrangeColumn, arrangeGrid, toggleCardExpand, fitScreenSize, changeCardsFontSize } from './cards';
32
32
  import { loadRepository, rerenderCurrentView, selectCommit } from './repo';
33
33
  import { handoffRepoLoad, syncRepoSelection } from './repo-handoff';
@@ -451,6 +451,28 @@ export function setupEventListeners(ctx: CanvasContext) {
451
451
  });
452
452
  }
453
453
 
454
+ const detailModeToggle = document.getElementById('toggleDetailMode');
455
+ if (detailModeToggle) {
456
+ const stateEl = document.getElementById('detailModeState');
457
+ const updateDetailModeUi = () => {
458
+ const mode = getDetailMode();
459
+ detailModeToggle.classList.toggle('active', mode === 'preview');
460
+ detailModeToggle.setAttribute('title', mode === 'preview'
461
+ ? 'Preview mode forced on — click to restore auto detail switching'
462
+ : 'Auto detail switching — click to keep preview mode on at every zoom');
463
+ if (stateEl) stateEl.textContent = mode === 'preview' ? 'Preview' : 'Auto';
464
+ };
465
+ updateDetailModeUi();
466
+ detailModeToggle.addEventListener('click', () => {
467
+ const next = toggleDetailMode();
468
+ updateDetailModeUi();
469
+ rerenderCurrentView(ctx);
470
+ showToast(next === 'preview'
471
+ ? 'Preview mode forced on'
472
+ : 'Auto detail switching restored', 'info');
473
+ });
474
+ }
475
+
454
476
  // Control mode toggle (Simple vs Advanced)
455
477
  const modeToggle = document.getElementById('toggleControlMode');
456
478
  if (modeToggle) {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { getLowZoomPreviewText, getLowZoomScale } from './low-zoom-preview';
2
+ import { estimatePreviewCharsPerLine, estimatePreviewLineCapacity, estimateTitleCharsPerLine, getLowZoomPreviewText, getLowZoomScale, wrapPreviewText } from './low-zoom-preview';
3
3
 
4
4
  describe('low zoom preview helpers', () => {
5
5
  test('anchors preview text to approximate saved scroll position', () => {
@@ -24,4 +24,22 @@ describe('low zoom preview helpers', () => {
24
24
  expect(far.titleFont).toBeGreaterThan(near.titleFont);
25
25
  expect(far.bodyFont).toBeGreaterThan(near.bodyFont);
26
26
  });
27
+
28
+ test('wraps preview text into bounded lines with ellipsis', () => {
29
+ const lines = wrapPreviewText('alpha beta gamma delta epsilon zeta eta theta', 10, 3);
30
+ expect(lines.length).toBeLessThanOrEqual(3);
31
+ expect(lines[lines.length - 1]?.endsWith('…')).toBe(true);
32
+ });
33
+
34
+ test('preview capacity estimates stay positive', () => {
35
+ expect(estimatePreviewCharsPerLine(580, 0.25)).toBeGreaterThan(8);
36
+ expect(estimateTitleCharsPerLine(580, 0.25)).toBeGreaterThan(8);
37
+ expect(estimatePreviewLineCapacity(700, 0.25)).toBeGreaterThanOrEqual(2);
38
+ });
39
+
40
+ test('title typography is larger than body typography for readability', () => {
41
+ const scale = getLowZoomScale(0.18);
42
+ expect(scale.titleFont).toBeGreaterThan(scale.bodyFont);
43
+ expect(scale.titleLineHeight).toBeGreaterThan(scale.bodyLineHeight * 0.7);
44
+ });
27
45
  });
@@ -5,15 +5,16 @@ const PREVIEWABLE_EXTS = new Set([
5
5
  export function getLowZoomScale(zoom: number) {
6
6
  const clampedZoom = Math.max(0.08, Math.min(0.25, zoom));
7
7
  const progress = (0.25 - clampedZoom) / (0.25 - 0.08);
8
- const desiredScreenTitle = 10 + progress * 4;
9
- const desiredScreenBody = 8 + progress * 4;
8
+ const desiredScreenTitle = 14 + progress * 6;
9
+ const desiredScreenBody = 9 + progress * 4;
10
10
  return {
11
11
  titleFont: desiredScreenTitle / clampedZoom,
12
+ titleLineHeight: (desiredScreenTitle * 1.08) / clampedZoom,
12
13
  bodyFont: desiredScreenBody / clampedZoom,
13
14
  bodyLineHeight: (desiredScreenBody * 1.45) / clampedZoom,
14
- padding: (10 + progress * 4) / clampedZoom,
15
- gap: (6 + progress * 3) / clampedZoom,
16
- radius: 8 / clampedZoom,
15
+ padding: (12 + progress * 6) / clampedZoom,
16
+ gap: (7 + progress * 3) / clampedZoom,
17
+ radius: 10 / clampedZoom,
17
18
  };
18
19
  }
19
20
 
@@ -29,3 +30,176 @@ export function getLowZoomPreviewText(file: any, scrollTop: number): string {
29
30
  const startLine = Math.max(0, Math.floor(scrollTop / approxLineHeight));
30
31
  return lines.slice(startLine, startLine + 60).join('\n').trim();
31
32
  }
33
+
34
+ export function wrapPreviewText(text: string, maxCharsPerLine: number, maxLines: number): string[] {
35
+ const safeMaxChars = Math.max(8, Math.floor(maxCharsPerLine));
36
+ const safeMaxLines = Math.max(1, Math.floor(maxLines));
37
+ const sourceLines = String(text || '').split('\n');
38
+ const out: string[] = [];
39
+
40
+ for (const sourceLine of sourceLines) {
41
+ const words = sourceLine.length === 0 ? [''] : sourceLine.split(/(\s+)/).filter(Boolean);
42
+ let current = '';
43
+
44
+ for (const part of words) {
45
+ if (part.length > safeMaxChars) {
46
+ if (current.trim().length > 0) {
47
+ out.push(current.trimEnd());
48
+ if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
49
+ current = '';
50
+ }
51
+ for (let i = 0; i < part.length; i += safeMaxChars) {
52
+ out.push(part.slice(i, i + safeMaxChars));
53
+ if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
54
+ }
55
+ continue;
56
+ }
57
+
58
+ if ((current + part).length > safeMaxChars && current.length > 0) {
59
+ out.push(current.trimEnd());
60
+ if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
61
+ current = part.trimStart();
62
+ } else {
63
+ current += part;
64
+ }
65
+ }
66
+
67
+ if (current.length > 0 || sourceLine.length === 0) {
68
+ out.push(current.trimEnd());
69
+ if (out.length >= safeMaxLines) return ellipsizeWrappedLines(out, safeMaxLines);
70
+ }
71
+ }
72
+
73
+ return out.slice(0, safeMaxLines);
74
+ }
75
+
76
+ function ellipsizeWrappedLines(lines: string[], maxLines: number) {
77
+ const sliced = lines.slice(0, maxLines);
78
+ if (sliced.length === 0) return sliced;
79
+ const last = sliced[sliced.length - 1].replace(/[\s.…]+$/g, '');
80
+ sliced[sliced.length - 1] = `${last}…`;
81
+ return sliced;
82
+ }
83
+
84
+ export function estimatePreviewLineCapacity(height: number, zoom: number): number {
85
+ const scale = getLowZoomScale(zoom);
86
+ const available = Math.max(scale.bodyLineHeight, height - scale.padding * 2 - scale.titleLineHeight * 2 - scale.bodyFont - scale.gap * 4);
87
+ return Math.max(2, Math.floor(available / scale.bodyLineHeight));
88
+ }
89
+
90
+ export function estimateTitleCharsPerLine(width: number, zoom: number): number {
91
+ const scale = getLowZoomScale(zoom);
92
+ const available = Math.max(80, width - scale.padding * 2 - Math.max(14, width * 0.02));
93
+ const avgCharWidth = Math.max(7, scale.titleFont * 0.58);
94
+ return Math.max(8, Math.floor(available / avgCharWidth));
95
+ }
96
+
97
+ export function estimatePreviewCharsPerLine(width: number, zoom: number): number {
98
+ const scale = getLowZoomScale(zoom);
99
+ const available = Math.max(60, width - scale.padding * 2 - Math.max(14, width * 0.02));
100
+ const avgCharWidth = Math.max(6, scale.bodyFont * 0.6);
101
+ return Math.max(8, Math.floor(available / avgCharWidth));
102
+ }
103
+
104
+ export function renderLowZoomPreviewCanvas(
105
+ canvas: HTMLCanvasElement,
106
+ params: {
107
+ path: string;
108
+ file: any;
109
+ width: number;
110
+ height: number;
111
+ zoom: number;
112
+ scrollTop: number;
113
+ accentColor: string;
114
+ isChanged: boolean;
115
+ },
116
+ ) {
117
+ const { path, file, width, height, zoom, scrollTop, accentColor } = params;
118
+ const dpr = (globalThis.devicePixelRatio || 1);
119
+ const scale = getLowZoomScale(zoom);
120
+ const ctx = canvas.getContext('2d');
121
+ if (!ctx) return;
122
+
123
+ canvas.width = Math.max(1, Math.floor(width * dpr));
124
+ canvas.height = Math.max(1, Math.floor(height * dpr));
125
+ canvas.style.width = `${width}px`;
126
+ canvas.style.height = `${height}px`;
127
+
128
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
129
+ ctx.clearRect(0, 0, width, height);
130
+
131
+ const gradient = ctx.createLinearGradient(0, 0, 0, height);
132
+ gradient.addColorStop(0, 'rgba(15,23,42,0.96)');
133
+ gradient.addColorStop(1, 'rgba(2,6,23,0.96)');
134
+ ctx.fillStyle = gradient;
135
+ roundRect(ctx, 0, 0, width, height, Math.max(6, scale.radius));
136
+ ctx.fill();
137
+
138
+ ctx.fillStyle = accentColor;
139
+ ctx.fillRect(0, 0, Math.max(10, width * 0.02), height);
140
+
141
+ const leftInset = scale.padding + Math.max(14, width * 0.02);
142
+ const topInset = scale.padding;
143
+ const maxTextWidth = Math.max(40, width - leftInset - scale.padding);
144
+
145
+ ctx.textBaseline = 'top';
146
+ ctx.font = `700 ${scale.titleFont}px "JetBrains Mono", monospace`;
147
+ ctx.fillStyle = '#f8fafc';
148
+ const title = path.split('/').pop() || path;
149
+ const titleLines = wrapPreviewText(title, estimateTitleCharsPerLine(width, zoom), 2);
150
+ titleLines.forEach((line, index) => {
151
+ ctx.fillText(trimToWidth(ctx, line, maxTextWidth), leftInset, topInset + index * scale.titleLineHeight);
152
+ });
153
+
154
+ const subtitleY = topInset + titleLines.length * scale.titleLineHeight + scale.gap * 0.8;
155
+ ctx.font = `${Math.max(scale.bodyFont * 0.8, 9 / Math.max(zoom, 0.08))}px "JetBrains Mono", monospace`;
156
+ ctx.fillStyle = 'rgba(226,232,240,0.72)';
157
+ const pathParts = path.split('/');
158
+ const subtitle = pathParts.length > 1 ? pathParts.slice(Math.max(0, pathParts.length - 3), -1).join(' / ') : 'root';
159
+ ctx.fillText(trimToWidth(ctx, subtitle, maxTextWidth), leftInset, subtitleY);
160
+
161
+ const previewY = subtitleY + Math.max(scale.bodyFont * 0.8, 9 / Math.max(zoom, 0.08)) + scale.gap * 1.35;
162
+ const rawPreview = getLowZoomPreviewText(file, scrollTop) || 'Preview unavailable';
163
+ const wrapped = wrapPreviewText(
164
+ rawPreview,
165
+ estimatePreviewCharsPerLine(width, zoom),
166
+ estimatePreviewLineCapacity(height, zoom),
167
+ );
168
+
169
+ ctx.font = `${scale.bodyFont}px "JetBrains Mono", monospace`;
170
+ ctx.fillStyle = 'rgba(226,232,240,0.92)';
171
+
172
+ const fadeStart = Math.max(previewY, height - scale.bodyLineHeight * 2.2);
173
+ const bodyHeight = Math.max(scale.bodyLineHeight * 2, height - previewY - scale.padding);
174
+ const mask = ctx.createLinearGradient(0, previewY, 0, previewY + bodyHeight);
175
+ mask.addColorStop(0, 'rgba(226,232,240,0.92)');
176
+ mask.addColorStop(Math.max(0, (fadeStart - previewY) / Math.max(1, bodyHeight)), 'rgba(226,232,240,0.92)');
177
+ mask.addColorStop(1, 'rgba(226,232,240,0)');
178
+ ctx.fillStyle = mask;
179
+
180
+ wrapped.forEach((line, index) => {
181
+ const y = previewY + index * scale.bodyLineHeight;
182
+ if (y > height - scale.padding) return;
183
+ ctx.fillText(line, leftInset, y);
184
+ });
185
+ }
186
+
187
+ function trimToWidth(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
188
+ if (ctx.measureText(text).width <= maxWidth) return text;
189
+ let out = text;
190
+ while (out.length > 1 && ctx.measureText(`${out}…`).width > maxWidth) {
191
+ out = out.slice(0, -1);
192
+ }
193
+ return `${out}…`;
194
+ }
195
+
196
+ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
197
+ const r = Math.min(radius, width / 2, height / 2);
198
+ ctx.beginPath();
199
+ ctx.moveTo(x + r, y);
200
+ ctx.arcTo(x + width, y, x + width, y + height, r);
201
+ ctx.arcTo(x + width, y + height, x, y + height, r);
202
+ ctx.arcTo(x, y + height, x, y, r);
203
+ ctx.arcTo(x, y, x + width, y, r);
204
+ ctx.closePath();
205
+ }
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import { measure } from 'measure-fn';
25
25
  import type { CanvasContext } from './context';
26
- import { getLowZoomPreviewText, getLowZoomScale } from './low-zoom-preview';
26
+ import { getLowZoomScale, renderLowZoomPreviewCanvas } from './low-zoom-preview';
27
27
  import { materializeViewport } from './xydraw-bridge';
28
28
 
29
29
  // ── Culling state ──────────────────────────────────────────
@@ -36,6 +36,7 @@ const VIEWPORT_MARGIN = 500;
36
36
 
37
37
  // LOD threshold: below this zoom level, use lightweight pill placeholders
38
38
  const LOD_ZOOM_THRESHOLD = 0.25;
39
+ const LOW_ZOOM_MODE_STORAGE_KEY = 'gitmaps:detailMode';
39
40
 
40
41
  // Maximum deferred cards to materialize per animation frame
41
42
  // Prevents frame drops when zooming out then back in on huge repos
@@ -52,6 +53,14 @@ export function markTransformActive() {
52
53
 
53
54
  // Track current LOD mode so we can detect transitions
54
55
  let _currentLodMode: 'full' | 'pill' = 'full';
56
+ let _detailMode: 'auto' | 'preview' = (() => {
57
+ try {
58
+ const stored = localStorage.getItem(LOW_ZOOM_MODE_STORAGE_KEY);
59
+ return stored === 'preview' ? 'preview' : 'auto';
60
+ } catch {
61
+ return 'auto';
62
+ }
63
+ })();
55
64
 
56
65
  // Track pill elements for cleanup
57
66
  const pillCards = new Map<string, HTMLElement>();
@@ -90,6 +99,27 @@ export function getPinnedCards(): Set<string> {
90
99
  return _pinnedCards;
91
100
  }
92
101
 
102
+ export function getDetailMode(): 'auto' | 'preview' {
103
+ return _detailMode;
104
+ }
105
+
106
+ export function setDetailMode(mode: 'auto' | 'preview') {
107
+ _detailMode = mode;
108
+ try {
109
+ localStorage.setItem(LOW_ZOOM_MODE_STORAGE_KEY, mode);
110
+ } catch { }
111
+ }
112
+
113
+ export function toggleDetailMode(): 'auto' | 'preview' {
114
+ const next = _detailMode === 'preview' ? 'auto' : 'preview';
115
+ setDetailMode(next);
116
+ return next;
117
+ }
118
+
119
+ export function isPreviewModeForced() {
120
+ return _detailMode === 'preview';
121
+ }
122
+
93
123
  // ── Status colors for low-zoom cards
94
124
  const PILL_COLORS: Record<string, string> = {
95
125
  'ts': '#3178c6',
@@ -133,14 +163,13 @@ function createPillCard(ctx: CanvasContext, file: any, path: string, x: number,
133
163
  const pill = document.createElement('div');
134
164
  pill.className = 'file-pill';
135
165
  pill.dataset.path = path;
136
- pill.dataset.previewMode = 'content';
166
+ pill.dataset.previewMode = 'canvas';
137
167
  pill.style.cssText = `
138
168
  position: absolute;
139
169
  left: ${x}px;
140
170
  top: ${y}px;
141
171
  width: ${w}px;
142
172
  height: ${h}px;
143
- background: linear-gradient(180deg, rgba(15,23,42,0.96) 0%, rgba(2,6,23,0.96) 100%);
144
173
  border-radius: 6px;
145
174
  opacity: ${animate ? '0' : '0.94'};
146
175
  contain: layout style paint;
@@ -151,45 +180,17 @@ function createPillCard(ctx: CanvasContext, file: any, path: string, x: number,
151
180
  user-select: none;
152
181
  transition: opacity 0.25s ease, box-shadow 0.2s ease, transform 0.25s ease;
153
182
  transform: ${animate ? 'scale(0.92)' : 'scale(1)'};
183
+ background: transparent;
154
184
  `;
155
185
 
156
- const accent = document.createElement('div');
157
- accent.className = 'file-pill-accent';
158
- accent.style.cssText = `
159
- position:absolute;
160
- top:0;
161
- left:0;
162
- width:${Math.max(10, w * 0.02)}px;
163
- height:100%;
164
- background:${getPillColor(path, isChanged)};
165
- opacity:0.95;
166
- pointer-events:none;
167
- `;
168
- pill.appendChild(accent);
169
-
170
- const inner = document.createElement('div');
171
- inner.className = 'file-pill-inner';
172
- inner.style.cssText = 'position:absolute; inset:0; transform-origin:top left; pointer-events:none;';
173
- pill.appendChild(inner);
186
+ const canvas = document.createElement('canvas');
187
+ canvas.className = 'file-pill-canvas';
188
+ canvas.style.cssText = 'display:block;width:100%;height:100%;pointer-events:none;';
189
+ pill.appendChild(canvas);
174
190
 
175
- const title = document.createElement('div');
176
- title.className = 'file-pill-title';
177
- title.textContent = path.split('/').pop() || path;
178
- inner.appendChild(title);
191
+ (pill as any)._fileData = file;
192
+ updatePillCardLayout(ctx, pill, zoom, isChanged);
179
193
 
180
- const sub = document.createElement('div');
181
- sub.className = 'file-pill-subtitle';
182
- sub.textContent = path.includes('/') ? path.split('/').slice(0, -1).join('/') : 'root';
183
- inner.appendChild(sub);
184
-
185
- const preview = document.createElement('div');
186
- preview.className = 'file-pill-preview';
187
- preview.textContent = getLowZoomPreviewText(file, getSavedScrollTop(ctx, path)) || 'Preview unavailable';
188
- inner.appendChild(preview);
189
-
190
- updatePillCardLayout(pill, zoom);
191
-
192
- // Animate pill entrance
193
194
  if (animate) {
194
195
  requestAnimationFrame(() => {
195
196
  pill.style.opacity = '0.94';
@@ -200,62 +201,28 @@ function createPillCard(ctx: CanvasContext, file: any, path: string, x: number,
200
201
  return pill;
201
202
  }
202
203
 
203
- function updatePillCardLayout(pill: HTMLElement, zoom: number) {
204
- const x = parseFloat(pill.style.left) || 0;
205
- const y = parseFloat(pill.style.top) || 0;
204
+ function updatePillCardLayout(ctx: CanvasContext, pill: HTMLElement, zoom: number, isChanged?: boolean) {
206
205
  const w = parseFloat(pill.style.width) || 580;
207
206
  const h = parseFloat(pill.style.height) || 700;
208
207
  const scale = getLowZoomScale(zoom);
208
+ const path = pill.dataset.path || '';
209
+ const canvas = pill.querySelector('.file-pill-canvas') as HTMLCanvasElement | null;
210
+ const file = (pill as any)._fileData || ctx.allFilesData?.find(f => f.path === path) || ctx.commitFilesData?.find(f => f.path === path) || null;
211
+ const changed = isChanged ?? pill.dataset.changed === 'true';
209
212
  pill.dataset.zoomBucket = zoom.toFixed(3);
210
213
  pill.style.borderRadius = `${Math.max(6, scale.radius)}px`;
211
-
212
- const inner = pill.querySelector('.file-pill-inner') as HTMLElement | null;
213
- const title = pill.querySelector('.file-pill-title') as HTMLElement | null;
214
- const sub = pill.querySelector('.file-pill-subtitle') as HTMLElement | null;
215
- const preview = pill.querySelector('.file-pill-preview') as HTMLElement | null;
216
- if (!inner || !title || !sub || !preview) return;
217
-
218
- inner.style.padding = `${scale.padding}px ${scale.padding}px ${scale.padding}px ${scale.padding + Math.max(14, w * 0.02)}px`;
219
- inner.style.display = 'flex';
220
- inner.style.flexDirection = 'column';
221
- inner.style.gap = `${scale.gap}px`;
222
-
223
- title.style.cssText = `
224
- color:#f8fafc;
225
- font-family:'JetBrains Mono', monospace;
226
- font-size:${scale.titleFont}px;
227
- font-weight:700;
228
- line-height:1.05;
229
- white-space:nowrap;
230
- overflow:hidden;
231
- text-overflow:ellipsis;
232
- text-shadow:0 2px 8px rgba(0,0,0,0.45);
233
- `;
234
-
235
- sub.style.cssText = `
236
- color:rgba(226,232,240,0.72);
237
- font-family:'JetBrains Mono', monospace;
238
- font-size:${Math.max(scale.bodyFont * 0.78, 8 / Math.max(zoom, 0.08))}px;
239
- line-height:1.05;
240
- white-space:nowrap;
241
- overflow:hidden;
242
- text-overflow:ellipsis;
243
- `;
244
-
245
- preview.style.cssText = `
246
- color:rgba(226,232,240,0.92);
247
- font-family:'JetBrains Mono', monospace;
248
- font-size:${scale.bodyFont}px;
249
- line-height:${scale.bodyLineHeight}px;
250
- white-space:pre-wrap;
251
- overflow-wrap:anywhere;
252
- word-break:break-word;
253
- overflow:hidden;
254
- flex:1 1 auto;
255
- text-shadow:0 1px 4px rgba(0,0,0,0.3);
256
- mask-image:linear-gradient(to bottom, black 0%, black 84%, transparent 100%);
257
- -webkit-mask-image:linear-gradient(to bottom, black 0%, black 84%, transparent 100%);
258
- `;
214
+ if (!canvas) return;
215
+
216
+ renderLowZoomPreviewCanvas(canvas, {
217
+ path,
218
+ file,
219
+ width: w,
220
+ height: h,
221
+ zoom,
222
+ scrollTop: getSavedScrollTop(ctx, path),
223
+ accentColor: getPillColor(path, changed),
224
+ isChanged: changed,
225
+ });
259
226
  }
260
227
 
261
228
  /**
@@ -356,7 +323,7 @@ export function performViewportCulling(ctx: CanvasContext) {
356
323
  // Phase 4c: also materialize deferred CardManager cards
357
324
  // Reuse zoom from worldRect (already snapped) — avoids redundant ctx.snap()
358
325
  const zoom = worldRect.zoom;
359
- const isLowZoom = zoom <= LOD_ZOOM_THRESHOLD;
326
+ const isLowZoom = _detailMode === 'preview' || zoom <= LOD_ZOOM_THRESHOLD;
360
327
 
361
328
  // Important: never materialize full cards while in low-zoom pill mode.
362
329
  // Otherwise CardManager keeps mounting heavyweight cards right when the
@@ -471,10 +438,11 @@ export function performViewportCulling(ctx: CanvasContext) {
471
438
 
472
439
  if (inView && !pillCards.has(path)) {
473
440
  const pill = createPillCard(ctx, file, path, x, y, cardW, cardH, !!isChanged, zoom, true);
441
+ if (isChanged) pill.dataset.changed = 'true';
474
442
  ctx.canvas.appendChild(pill);
475
443
  pillCards.set(path, pill);
476
444
  } else if (inView && pillCards.has(path)) {
477
- updatePillCardLayout(pillCards.get(path)!, zoom);
445
+ updatePillCardLayout(ctx, pillCards.get(path)!, zoom, !!isChanged);
478
446
  } else if (!inView && pillCards.has(path)) {
479
447
  removePillForPath(path);
480
448
  }
@@ -499,11 +467,12 @@ export function performViewportCulling(ctx: CanvasContext) {
499
467
  if (inView) {
500
468
  const file = ctx.allFilesData?.find(f => f.path === path) || ctx.commitFilesData?.find(f => f.path === path) || null;
501
469
  const pill = createPillCard(ctx, file, path, x, y, w, h, isChanged, zoom, true);
470
+ if (isChanged) pill.dataset.changed = 'true';
502
471
  ctx.canvas.appendChild(pill);
503
472
  pillCards.set(path, pill);
504
473
  }
505
474
  } else {
506
- updatePillCardLayout(pillCards.get(path)!, zoom);
475
+ updatePillCardLayout(ctx, pillCards.get(path)!, zoom, card.dataset.changed === 'true');
507
476
  }
508
477
  }
509
478
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitmaps",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "gitmaps": "cli.ts"
@@ -20,10 +20,12 @@
20
20
  "smoke:browser": "bun scripts/browser-smoke-local.ts",
21
21
  "smoke:browser-tools": "bash scripts/browser-smoke-local.sh",
22
22
  "smoke:browser-tools:load": "bash scripts/browser-repo-load-smoke.sh",
23
+ "smoke:browser-tools:low-zoom": "bash scripts/browser-low-zoom-perf-smoke.sh",
23
24
  "smoke:browser-tools:guard": "bash scripts/browser-smoke-guard.sh",
24
25
  "smoke:browser-tools:self-check": "bash scripts/browser-smoke-self-check.sh",
25
26
  "smoke:browser-tools:check": "bash scripts/browser-smoke-check.sh",
26
27
  "smoke:docker-image": "bash scripts/docker-image-smoke.sh",
28
+ "bench:low-zoom": "bun scripts/low-zoom-preview-bench.ts",
27
29
  "prepublishOnly": "echo 'Publishing gitmaps to npm'"
28
30
  },
29
31
  "keywords": [