your personal website on atproto - mirror blento.app

Merge pull request #163 from flo-bit/improve-fluid-text

Improve fluid text

authored by Florian and committed by GitHub b7c28f6e f817bf7e

+93 -12
+9 -1
src/lib/cards/social/BigSocialCard/BigSocialCard.svelte
··· 7 7 8 8 const platform = $derived(item.cardData.platform as string); 9 9 const platformData = $derived(platformsData[platform]); 10 + 11 + // Color logic: 12 + // - base/transparent/undefined: background = brand color, icon = white 13 + // - other: background = that color (from BaseCard), icon = white 14 + const useBrandBackground = $derived( 15 + !item.color || item.color === 'base' || item.color === 'transparent' 16 + ); 17 + const brandColor = $derived(`#${item.cardData.color}`); 10 18 </script> 11 19 12 20 <div 13 21 class="flex h-full w-full items-center justify-center p-10" 14 - style={`background-color: #${item.cardData.color}`} 22 + style={useBrandBackground ? `background-color: ${brandColor}` : ''} 15 23 > 16 24 <div 17 25 class="flex aspect-square max-h-full max-w-full items-center justify-center [&_svg]:size-full [&_svg]:max-w-60 [&_svg]:fill-white"
+2 -2
src/lib/cards/social/BigSocialCard/index.ts
··· 36 36 return item; 37 37 }, 38 38 name: 'Social Icon', 39 - allowSetColor: false, 40 - defaultColor: 'transparent', 39 + allowSetColor: true, 40 + defaultColor: 'base', 41 41 minW: 2, 42 42 minH: 2, 43 43 onUrlHandler: (url, item) => {
+82 -9
src/lib/cards/visual/FluidTextCard/FluidTextCard.svelte
··· 1 1 <script lang="ts"> 2 - import { colorToHue, getCSSVar, getHexOfCardColor } from '../../helper'; 2 + import { colorToHue, getHexCSSVar, getHexOfCardColor } from '../../helper'; 3 3 import type { ContentComponentProps } from '../../types'; 4 4 import { onMount, onDestroy, tick } from 'svelte'; 5 5 let { item }: ContentComponentProps = $props(); ··· 7 7 let container: HTMLDivElement; 8 8 let fluidCanvas: HTMLCanvasElement; 9 9 let maskCanvas: HTMLCanvasElement; 10 + let shadowCanvas: HTMLCanvasElement; 10 11 let animationId: number; 11 12 let splatIntervalId: ReturnType<typeof setInterval>; 12 13 let maskDrawRaf = 0; 13 14 let maskReady = false; 14 15 let isInitialized = $state(false); 15 16 let resizeObserver: ResizeObserver | null = null; 17 + let themeObserver: MutationObserver | null = null; 16 18 17 19 // Pure hash function for shader keyword caching 18 20 function hashCode(s: string) { ··· 122 124 if (width === 0 || height === 0) return; 123 125 124 126 const dpr = window.devicePixelRatio || 1; 127 + const isDark = document.documentElement.classList.contains('dark'); 128 + 129 + // Draw shadow behind fluid (light mode only, transparent only) 130 + if (shadowCanvas && item.color === 'transparent') { 131 + shadowCanvas.width = width * dpr; 132 + shadowCanvas.height = height * dpr; 133 + const shadowCtx = shadowCanvas.getContext('2d')!; 134 + shadowCtx.setTransform(1, 0, 0, 1, 0, 0); 135 + shadowCtx.clearRect(0, 0, shadowCanvas.width, shadowCanvas.height); 136 + shadowCtx.scale(dpr, dpr); 137 + 138 + const textFontSize = Math.round(width * fontSize); 139 + shadowCtx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`; 140 + shadowCtx.textAlign = 'center'; 141 + 142 + const metrics = shadowCtx.measureText(text); 143 + let textY = height / 2; 144 + if ( 145 + metrics.actualBoundingBoxAscent !== undefined && 146 + metrics.actualBoundingBoxDescent !== undefined 147 + ) { 148 + shadowCtx.textBaseline = 'alphabetic'; 149 + textY = 150 + (height + metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2; 151 + } else { 152 + shadowCtx.textBaseline = 'middle'; 153 + } 154 + 155 + // Draw darkened text shape behind fluid 156 + shadowCtx.fillStyle = getHexCSSVar(isDark ? '--color-base-950' : '--color-base-200'); 157 + shadowCtx.fillText(text, width / 2, textY); 158 + } else if (shadowCanvas) { 159 + // Clear shadow canvas when not transparent 160 + shadowCanvas.width = 1; 161 + shadowCanvas.height = 1; 162 + } 125 163 126 164 maskCanvas.width = width * dpr; 127 165 maskCanvas.height = height * dpr; ··· 132 170 ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); 133 171 ctx.scale(dpr, dpr); 134 172 135 - //const color = getCSSVar('--color-base-900'); 136 - 137 - ctx.fillStyle = 'black'; 173 + const bgColor = 174 + item.color === 'transparent' 175 + ? getHexCSSVar(isDark ? '--color-base-900' : '--color-base-50') 176 + : 'black'; 177 + ctx.fillStyle = bgColor; 138 178 ctx.fillRect(0, 0, width, height); 139 179 140 180 // Font size as percentage of container width 141 181 const textFontSize = Math.round(width * fontSize); 142 182 ctx.font = `${fontWeight} ${textFontSize}px ${fontFamily}`; 143 183 144 - ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; 145 - ctx.lineWidth = 2; 184 + ctx.lineWidth = 3; 146 185 ctx.textAlign = 'center'; 147 186 148 187 const metrics = ctx.measureText(text); ··· 157 196 ctx.textBaseline = 'middle'; 158 197 } 159 198 160 - ctx.strokeText(text, width / 2, textY); 199 + if (item.color === 'transparent') { 200 + // Partially cut out the stroke area so fluid shows through 201 + ctx.globalCompositeOperation = 'destination-out'; 202 + ctx.globalAlpha = 0.7; 203 + ctx.strokeStyle = 'white'; 204 + ctx.strokeText(text, width / 2, textY); 205 + ctx.globalAlpha = 1; 206 + ctx.globalCompositeOperation = 'source-over'; 207 + 208 + // Add overlay: brighten in dark mode, darken in light mode 209 + ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.2)'; 210 + ctx.strokeText(text, width / 2, textY); 211 + } else { 212 + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; 213 + ctx.strokeText(text, width / 2, textY); 214 + } 215 + 161 216 ctx.globalCompositeOperation = 'destination-out'; 162 217 ctx.fillText(text, width / 2, textY); 163 218 ctx.globalCompositeOperation = 'source-over'; ··· 214 269 if (isInitialized) scheduleMaskDraw(); 215 270 }); 216 271 } 272 + 273 + // Watch for dark mode changes to redraw mask with correct background 274 + if (item.color === 'transparent') { 275 + themeObserver = new MutationObserver(() => { 276 + if (isInitialized) scheduleMaskDraw(); 277 + }); 278 + themeObserver.observe(document.documentElement, { 279 + attributes: true, 280 + attributeFilter: ['class'] 281 + }); 282 + } 217 283 }); 218 284 219 285 onDestroy(() => { ··· 221 287 if (splatIntervalId) clearInterval(splatIntervalId); 222 288 if (maskDrawRaf) cancelAnimationFrame(maskDrawRaf); 223 289 if (resizeObserver) resizeObserver.disconnect(); 290 + if (themeObserver) themeObserver.disconnect(); 224 291 }); 225 292 226 293 function initFluidSimulation(startHue: number, endHue: number) { ··· 246 313 COLOR_UPDATE_SPEED: 10, 247 314 PAUSED: false, 248 315 BACK_COLOR: { r: 0, g: 0, b: 0 }, 249 - TRANSPARENT: false, 316 + TRANSPARENT: item.color === 'transparent', 250 317 BLOOM: false, 251 318 BLOOM_ITERATIONS: 8, 252 319 BLOOM_RESOLUTION: 256, ··· 1701 1768 } 1702 1769 </script> 1703 1770 1704 - <div bind:this={container} class="relative h-full w-full overflow-hidden bg-black"> 1771 + <div 1772 + bind:this={container} 1773 + class="relative h-full w-full overflow-hidden {item.color === 'transparent' 1774 + ? 'bg-base-50 dark:bg-base-900' 1775 + : 'bg-black'}" 1776 + > 1777 + <canvas bind:this={shadowCanvas} class="absolute h-full w-full"></canvas> 1705 1778 <canvas bind:this={fluidCanvas} class="absolute h-full w-full"></canvas> 1706 1779 <canvas bind:this={maskCanvas} class="absolute h-full w-full"></canvas> 1707 1780 </div>