@@ -161,13 +161,22 @@ function sleep(ms: number): Promise<void> {
161161const CANVAS_WIDTH = 2380 ;
162162const CANVAS_HEIGHT = 838 ;
163163
164+ // Drop shadow parameters — declared here so WINDOW_POSITIONS can reference them.
165+ const SHADOW_OFFSET_Y = 10 ; // downward shift in px
166+ const SHADOW_BLUR = 10 ; // gaussian sigma
167+ const SHADOW_OPACITY = 0.15 ; // 0–1
168+
164169// Three-window fan layout on the @2x canvas (2380x838).
165170// Center window is in front (composited last), side windows partially off-screen.
166171// Draw order: left → right → center (so center appears on top).
172+ // Positions refer to the visual window corner (shadow padding is added internally).
173+ // Side windows are inset by SHADOW_BLUR * 2 so the outer shadow fades to the canvas edge
174+ // rather than clipping. Center window remains horizontally centred on the canvas.
175+ const SHADOW_EDGE_PAD = SHADOW_BLUR * 2 ;
167176const WINDOW_POSITIONS = [
168- { left : - 50 , top : 160 } , // left (index 0 in manifest.web)
169- { left : 710 , top : 80 } , // center (index 1)
170- { left : 1470 , top : 160 } , // right (index 2)
177+ { left : SHADOW_EDGE_PAD , top : 160 } , // left
178+ { left : 710 , top : 80 } , // center — centred on 2380px canvas
179+ { left : CANVAS_WIDTH - 960 - SHADOW_EDGE_PAD , top : 160 } , // right
171180] ;
172181const COMPOSITE_ORDER = [ 0 , 2 , 1 ] ; // draw left and right first, center last
173182
@@ -176,27 +185,100 @@ const BACKGROUNDS: Record<"dark" | "light", { r: number; g: number; b: number }>
176185 light : { r : 255 , g : 255 , b : 255 } ,
177186} ;
178187
188+ // Add a consistent programmatic drop shadow to a window image.
189+ // Returns the image buffer with shadow included, plus the uniform padding added on each side.
190+ //
191+ // Why grayscale for the blur: Sharp uses premultiplied alpha internally for gaussblur.
192+ // Blurring pure black (0,0,0) on transparent (0,0,0,0) gives identical premultiplied
193+ // values (0,0,0) in both regions — the Gaussian has nothing to spread, producing a
194+ // hard edge. Extracting the alpha channel as single-channel greyscale and blurring that
195+ // directly avoids premultiplied alpha entirely, giving a proper Gaussian gradient.
196+ async function withDropShadow ( buf : Buffer ) : Promise < { image : Buffer ; padLeft : number ; padTop : number } > {
197+
198+ const meta = await sharp ( buf ) . metadata ( ) ;
199+ const w = meta . width ?? 960 ;
200+ const h = meta . height ?? 600 ;
201+
202+ // All sides need 2× SHADOW_BLUR so the Gaussian (σ=SHADOW_BLUR) doesn't clip at the canvas edge
203+ // (at 1σ the shadow is still ~61% intensity; at 2σ it's ~14%, negligible).
204+ // Bottom gets an extra SHADOW_OFFSET_Y since the shadow is shifted down.
205+ const padLeft = SHADOW_BLUR * 2 ;
206+ const padTop = SHADOW_BLUR * 2 ;
207+ const padRight = SHADOW_BLUR * 2 ;
208+ const padBottom = SHADOW_BLUR * 2 + SHADOW_OFFSET_Y ;
209+ const totalW = w + padLeft + padRight ;
210+ const totalH = h + padTop + padBottom ;
211+
212+ // 1. Extract the window's alpha channel as single-channel greyscale.
213+ const windowAlpha = await sharp ( buf ) . extractChannel ( 3 ) . toBuffer ( ) ;
214+
215+ // 2. Extend the alpha mask with black padding (shadow shifted down), then blur.
216+ // Using extend() on the greyscale image directly and blurring in a second step
217+ // avoids RGB canvas compositing quirks and pipeline ordering issues.
218+ const alphaPadded = await sharp ( windowAlpha )
219+ . extend ( {
220+ top : padTop + SHADOW_OFFSET_Y , // extra offset pushes shadow down in the blur canvas
221+ bottom : SHADOW_BLUR * 2 ,
222+ left : padLeft ,
223+ right : padRight ,
224+ background : { r : 0 , g : 0 , b : 0 } ,
225+ } )
226+ . png ( )
227+ . toBuffer ( ) ;
228+
229+ const { data : blurredGrey , info : blurInfo } = await sharp ( alphaPadded )
230+ . blur ( SHADOW_BLUR )
231+ . raw ( )
232+ . toBuffer ( { resolveWithObject : true } ) ;
233+
234+ // 3. Build RGBA shadow: R=G=B=0 (black), A = blurred grey × SHADOW_OPACITY.
235+ // Use blurInfo.channels to stride correctly — Sharp may output 1, 2, or 3 channels
236+ // depending on how it encoded the padded PNG; always take the first channel.
237+ const ch = blurInfo . channels ;
238+ const shadowData = Buffer . alloc ( totalW * totalH * 4 , 0 ) ;
239+ for ( let i = 0 ; i < totalW * totalH ; i ++ ) {
240+ shadowData [ i * 4 + 3 ] = Math . round ( ( blurredGrey [ i * ch ] as number ) * SHADOW_OPACITY ) ;
241+ }
242+ const shadow = await sharp ( shadowData , { raw : { width : totalW , height : totalH , channels : 4 } } )
243+ . png ( )
244+ . toBuffer ( ) ;
245+
246+ // 4. Composite the clean window over the shadow at the padded position.
247+ const result = await sharp ( shadow )
248+ . composite ( [ { input : buf , left : padLeft , top : padTop } ] )
249+ . png ( )
250+ . toBuffer ( ) ;
251+
252+ return { image : result , padLeft, padTop } ;
253+ }
254+
179255async function compositeWeb ( mode : "dark" | "light" ) : Promise < void > {
180256 const files = manifest . web [ mode ] ;
181257 const bg = BACKGROUNDS [ mode ] ;
182258
183259 // Build composites in COMPOSITE_ORDER so center window renders on top.
184260 const composites : Parameters < ReturnType < typeof sharp > [ "composite" ] > [ 0 ] = [ ] ;
185261 for ( const idx of COMPOSITE_ORDER ) {
186- const file = join ( SOURCE_DIR , files [ idx ] ) ;
187- let left = WINDOW_POSITIONS [ idx ] . left ;
188- const top = WINDOW_POSITIONS [ idx ] . top ;
262+ const src = join ( SOURCE_DIR , files [ idx ] ) ;
263+
264+ // Remove macOS-captured shadow, then apply a consistent programmatic shadow.
265+ const cleaned = await removeShadow ( src ) ;
266+ const { image : shadowed , padLeft, padTop } = await withDropShadow ( cleaned ) ;
267+
268+ // Adjust position so the window chrome lands at WINDOW_POSITIONS[idx].
269+ let left = WINDOW_POSITIONS [ idx ] . left - padLeft ;
270+ const top = WINDOW_POSITIONS [ idx ] . top - padTop ;
189271
190272 let input : Buffer ;
191273 if ( left < 0 ) {
192- // Crop off the portion that would be off-screen to the left .
193- const meta = await sharp ( file ) . metadata ( ) ;
194- input = await sharp ( file )
195- . extract ( { left : - left , top : 0 , width : ( meta . width ?? 960 ) + left , height : meta . height ?? 600 } )
274+ // Crop the hidden left portion (shadow padding + off-screen shift) .
275+ const meta = await sharp ( shadowed ) . metadata ( ) ;
276+ input = await sharp ( shadowed )
277+ . extract ( { left : - left , top : 0 , width : ( meta . width ?? 0 ) + left , height : meta . height ?? 0 } )
196278 . toBuffer ( ) ;
197279 left = 0 ;
198280 } else {
199- input = await sharp ( file ) . toBuffer ( ) ;
281+ input = shadowed ;
200282 }
201283
202284 composites . push ( { input, left, top } ) ;
0 commit comments