@@ -166,7 +166,6 @@ const GitSequencer = () => {
166166 const scale = 3 ;
167167 const CELL_SIZE = 10 * scale ;
168168 const GAP = 3 * scale ;
169- const PADDING = 8 * scale ; // 0.5rem on mobile
170169
171170 // Full HD portrait (1080x1920)
172171 const canvasWidth = 1080 ;
@@ -175,29 +174,41 @@ const GitSequencer = () => {
175174 if ( canvas . width !== canvasWidth ) canvas . width = canvasWidth ;
176175 if ( canvas . height !== canvasHeight ) canvas . height = canvasHeight ;
177176
178- // Exact mobile CSS spacing (base 16px, scaled 3x)
179- // .header-fieldset: padding 0.75rem 1rem, margin-bottom 1rem
180- // .header-content: gap 1rem
181- // .command-section: margin-bottom 1.5rem
182- // .status-msg: margin-top 0.5rem
183- // .graph-section: margin-bottom 2rem
177+ // Mobile CSS layout (consistent spacing throughout)
178+ // All content uses same horizontal padding from canvas edge
179+ const contentPadding = 16 * scale ; // 1rem - consistent for all elements
180+ const contentWidth = canvasWidth - contentPadding * 2 ;
184181
182+ // Fieldset internal padding (matches mobile .header-fieldset padding)
185183 const fieldsetPaddingX = 16 * scale ; // 1rem
186- const fieldsetPaddingY = 12 * scale ; // 0.75rem
187- const fieldsetMarginBottom = 16 * scale ; // 1rem
188- const headerContentGap = 16 * scale ; // 1rem
184+ const fieldsetPaddingY = 16 * scale ; // 0.75rem -> increased for breathing room
185+
186+ // Vertical spacing (matches mobile CSS)
187+ const headerContentGap = 16 * scale ; // 1rem gap between ASCII and subtitle
188+ const fieldsetMarginBottom = 32 * scale ; // spacing after header
189189 const statusMarginTop = 8 * scale ; // 0.5rem
190- const graphMarginTop = 24 * scale ; // 1.5rem (command-section margin-bottom)
190+ const graphMarginTop = 24 * scale ; // 1.5rem
191+
192+ // Font sizes
193+ const asciiFontSize = 9 * scale ;
194+ const subtitleFontSize = 14 * scale ;
195+ const commandFontSize = 15 * scale ;
196+ const statusFontSize = 14 * scale ;
197+ const legendTitleSize = 16 * scale ;
198+ const legendVersionSize = 12 * scale ;
191199
192200 // Calculate fieldset content height
193- const asciiHeight = 4 * 9 * scale ; // 4 lines at ~9px each
194- const subtitleHeight = 14 * scale ;
201+ const asciiLineHeight = asciiFontSize * 1.2 ;
202+ const asciiHeight = 4 * asciiLineHeight ;
203+ const subtitleLineHeight = subtitleFontSize * 1.3 ;
204+ const subtitleLines = 2 ; // "Turn your GitHub contributions" + "into music"
205+ const subtitleHeight = subtitleLines * subtitleLineHeight ;
195206 const fieldsetContentHeight = asciiHeight + headerContentGap + subtitleHeight ;
196207 const fieldsetHeight = fieldsetPaddingY * 2 + fieldsetContentHeight ;
197208
198209 // Calculate total content height for vertical centering
199- const commandLineHeight = 15 * scale ;
200- const statusLineHeight = 14 * scale ;
210+ const commandLineHeight = commandFontSize ;
211+ const statusLineHeight = statusFontSize ;
201212 const gridHeight = 7 * ( CELL_SIZE + GAP ) ;
202213
203214 const totalContentHeight =
@@ -219,79 +230,107 @@ const GitSequencer = () => {
219230 let currentY = offsetY ;
220231
221232 // ===== FIELDSET HEADER =====
222- const fieldsetWidth = canvasWidth - PADDING * 2 ;
233+ const fieldsetX = contentPadding ;
234+ const fieldsetWidth = contentWidth ;
223235
224236 // Fieldset border
225237 ctx . strokeStyle = colors . accent ;
226238 ctx . lineWidth = scale ;
227239 ctx . beginPath ( ) ;
228- ctx . roundRect ( PADDING , currentY , fieldsetWidth , fieldsetHeight , 4 * scale ) ;
240+ ctx . roundRect ( fieldsetX , currentY , fieldsetWidth , fieldsetHeight , 4 * scale ) ;
229241 ctx . stroke ( ) ;
230242
231- // Legend background
243+ // Measure legend text widths dynamically
244+ const titleFont = `bold ${ legendTitleSize } px monospace` ;
245+ const versionFont = `${ legendVersionSize } px monospace` ;
246+ ctx . font = titleFont ;
247+ const titleText = 'GitHub Music' ;
248+ const titleWidth = ctx . measureText ( titleText ) . width ;
249+ ctx . font = versionFont ;
250+ const versionText = 'v1.0.0' ;
251+ const versionWidth = ctx . measureText ( versionText ) . width ;
252+ const legendGap = 8 * scale ;
253+ const legendPaddingH = 8 * scale ;
254+ const totalLegendWidth = titleWidth + legendGap + versionWidth + legendPaddingH * 2 ;
255+
256+ // Legend position (inside fieldset border, with margin from left edge)
257+ const legendX = fieldsetX + fieldsetPaddingX ;
258+ const legendY = currentY - legendVersionSize / 2 ;
259+
260+ // Legend background (covers border)
232261 ctx . fillStyle = colors . bg ;
233- ctx . fillRect ( PADDING + 8 * scale , currentY - 8 * scale , 145 * scale , 16 * scale ) ;
262+ ctx . fillRect ( legendX - legendPaddingH , legendY - legendVersionSize / 2 , totalLegendWidth , legendTitleSize + 4 * scale ) ;
234263
235264 // Legend text: "GitHub Music v1.0.0"
236265 ctx . textAlign = 'left' ;
266+ ctx . textBaseline = 'middle' ;
237267 ctx . fillStyle = colors . accent ;
238- ctx . font = `bold ${ 16 * scale } px monospace` ; // 1rem on mobile
239- ctx . fillText ( 'GitHub Music' , PADDING + 12 * scale , currentY + 4 * scale ) ;
268+ ctx . font = titleFont ;
269+ ctx . fillText ( titleText , legendX , currentY ) ;
240270 ctx . fillStyle = colors . textDim ;
241- ctx . font = `${ 12 * scale } px monospace` ;
242- ctx . fillText ( 'v1.0.0' , PADDING + 130 * scale , currentY + 4 * scale ) ;
271+ ctx . font = versionFont ;
272+ ctx . fillText ( versionText , legendX + titleWidth + legendGap , currentY ) ;
273+ ctx . textBaseline = 'alphabetic' ;
243274
244- // ASCII art (centered in fieldset) - font-size: 0.55rem = ~9px
245- const asciiStartY = currentY + fieldsetPaddingY + 10 * scale ;
275+ // ASCII art (centered within fieldset content area)
276+ const fieldsetContentTop = currentY + fieldsetPaddingY ;
246277 ctx . fillStyle = colors . accent ;
247- ctx . font = `${ 9 * scale } px monospace` ;
278+ ctx . font = `${ asciiFontSize } px monospace` ;
248279 ctx . textAlign = 'center' ;
280+ ctx . textBaseline = 'top' ;
249281 ctx . globalAlpha = 0.8 ;
250282 const asciiLines = [
251283 ' ♫ ♪' ,
252284 ' ▄ █ ▄ █ ▄ █' ,
253285 ' █ █ █ █ █ █' ,
254286 ' ▀ ▀ ▀ ▀ ▀ ▀'
255287 ] ;
288+ const fieldsetCenterX = fieldsetX + fieldsetWidth / 2 ;
256289 asciiLines . forEach ( ( line , i ) => {
257- ctx . fillText ( line , canvasWidth / 2 , asciiStartY + i * 9 * scale ) ;
290+ ctx . fillText ( line , fieldsetCenterX , fieldsetContentTop + i * asciiLineHeight ) ;
258291 } ) ;
259292 ctx . globalAlpha = 1 ;
260293
261- // Subtitle (centered) - font-size: 0.85rem = ~14px
262- const subtitleY = asciiStartY + asciiHeight + headerContentGap ;
294+ // Subtitle (centered within fieldset, wrapped to two lines)
295+ const subtitleY = fieldsetContentTop + asciiHeight + headerContentGap ;
263296 ctx . fillStyle = colors . accent ;
264- ctx . font = `bold ${ 14 * scale } px monospace` ;
297+ ctx . font = `bold ${ subtitleFontSize } px monospace` ;
265298 ctx . textAlign = 'center' ;
266- ctx . fillText ( 'Turn your GitHub contributions into music' , canvasWidth / 2 , subtitleY ) ;
299+ ctx . textBaseline = 'top' ;
300+ ctx . fillText ( 'Turn your GitHub contributions' , fieldsetCenterX , subtitleY ) ;
301+ ctx . fillText ( 'into music' , fieldsetCenterX , subtitleY + subtitleLineHeight ) ;
302+ ctx . textBaseline = 'alphabetic' ;
267303
268304 currentY += fieldsetHeight + fieldsetMarginBottom ;
269305
270- // ===== COMMAND LINE (left-aligned) - font-size: 15px =====
306+ // ===== COMMAND LINE (left-aligned with content padding) =====
271307 ctx . textAlign = 'left' ;
272308 ctx . fillStyle = colors . accentCyan ;
273- ctx . font = `${ 15 * scale } px monospace` ;
274- ctx . fillText ( '$' , PADDING + fieldsetPaddingX , currentY ) ;
309+ ctx . font = `${ commandFontSize } px monospace` ;
310+ const cmdX = contentPadding ;
311+ ctx . fillText ( '$' , cmdX , currentY ) ;
275312
276313 ctx . fillStyle = colors . accentYellow ;
277- ctx . fillText ( 'git-music fetch' , PADDING + fieldsetPaddingX + 18 * scale , currentY ) ;
314+ const promptWidth = ctx . measureText ( '$ ' ) . width ;
315+ ctx . fillText ( 'git-music fetch' , cmdX + promptWidth , currentY ) ;
278316
279317 ctx . fillStyle = colors . textBright ;
280- ctx . fillText ( username , PADDING + fieldsetPaddingX + 170 * scale , currentY ) ;
318+ const cmdWidth = ctx . measureText ( 'git-music fetch ' ) . width ;
319+ ctx . fillText ( username , cmdX + promptWidth + cmdWidth , currentY ) ;
281320
282321 currentY += commandLineHeight + statusMarginTop ;
283322
284- // ===== STATUS MESSAGE (left-aligned) - font-size: 14px, margin-top: 0.5rem =====
323+ // ===== STATUS MESSAGE (left-aligned with content padding) =====
285324 ctx . fillStyle = colors . success ;
286- ctx . font = `${ 14 * scale } px monospace` ;
325+ ctx . font = `${ statusFontSize } px monospace` ;
287326 ctx . textAlign = 'left' ;
288- ctx . fillText ( `✓ loaded ${ data . weeks . length } weeks` , PADDING + fieldsetPaddingX , currentY ) ;
327+ ctx . fillText ( `✓ loaded ${ data . weeks . length } weeks` , contentPadding , currentY ) ;
289328
290329 currentY += statusLineHeight + graphMarginTop ;
291330
292- // ===== CONTRIBUTION GRID WITH SCROLL =====
331+ // ===== CONTRIBUTION GRID =====
293332 const gridTotalWidth = data . weeks . length * ( CELL_SIZE + GAP ) ;
294- const visibleWidth = canvasWidth - ( PADDING + fieldsetPaddingX ) * 2 ;
333+ const visibleWidth = contentWidth ;
295334
296335 // Calculate scroll offset to center active column
297336 let scrollOffset = 0 ;
@@ -304,15 +343,15 @@ const GitSequencer = () => {
304343 // Clip region for grid
305344 ctx . save ( ) ;
306345 ctx . beginPath ( ) ;
307- ctx . rect ( PADDING + fieldsetPaddingX , currentY , visibleWidth , gridHeight + 5 * scale ) ;
346+ ctx . rect ( contentPadding , currentY , visibleWidth , gridHeight + 5 * scale ) ;
308347 ctx . clip ( ) ;
309348
310349 // Draw Grid with scroll offset
311350 data . weeks . forEach ( ( week , wIndex ) => {
312- const x = PADDING + fieldsetPaddingX + wIndex * ( CELL_SIZE + GAP ) - scrollOffset ;
351+ const x = contentPadding + wIndex * ( CELL_SIZE + GAP ) - scrollOffset ;
313352
314353 // Skip if outside visible area
315- if ( x + CELL_SIZE < PADDING + fieldsetPaddingX || x > canvasWidth - PADDING - fieldsetPaddingX ) return ;
354+ if ( x + CELL_SIZE < contentPadding || x > canvasWidth - contentPadding ) return ;
316355
317356 week . days . forEach ( ( day , dIndex ) => {
318357 const y = currentY + dIndex * ( CELL_SIZE + GAP ) ;
@@ -453,6 +492,16 @@ const GitSequencer = () => {
453492 } ) ;
454493 } ;
455494
495+ // Screenshot export for testing layout
496+ const handleScreenshot = ( ) => {
497+ const canvas = canvasRef . current ;
498+ if ( ! canvas ) return ;
499+ const link = document . createElement ( 'a' ) ;
500+ link . download = `git-music-preview-${ username || 'test' } .png` ;
501+ link . href = canvas . toDataURL ( 'image/png' ) ;
502+ link . click ( ) ;
503+ } ;
504+
456505 // Load user from URL on mount
457506 useEffect ( ( ) => {
458507 const params = new URLSearchParams ( window . location . search ) ;
@@ -480,6 +529,9 @@ const GitSequencer = () => {
480529 case 'KeyS' :
481530 if ( data ) handleShare ( ) ;
482531 break ;
532+ case 'KeyP' :
533+ if ( data ) handleScreenshot ( ) ;
534+ break ;
483535 case 'Escape' :
484536 if ( isPlaying ) stop ( ) ;
485537 if ( isRecording ) handleExport ( ) ;
@@ -488,7 +540,7 @@ const GitSequencer = () => {
488540 } ;
489541 window . addEventListener ( 'keydown' , handleKeyDown ) ;
490542 return ( ) => window . removeEventListener ( 'keydown' , handleKeyDown ) ;
491- } , [ handleTogglePlay , data , isPlaying , isRecording , stop , handleExport , handleShare ] ) ;
543+ } , [ handleTogglePlay , data , isPlaying , isRecording , stop , handleExport , handleShare , handleScreenshot ] ) ;
492544
493545 return (
494546 < div className = "terminal-window" >
0 commit comments