@autumnsgrove/gossamer 0.0.1 → 0.1.0

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.
Files changed (50) hide show
  1. package/dist/animation.d.ts +80 -0
  2. package/dist/animation.d.ts.map +1 -0
  3. package/dist/characters.d.ts +49 -0
  4. package/dist/characters.d.ts.map +1 -0
  5. package/dist/index.d.ts +37 -11
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1284 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/patterns.d.ts +100 -0
  10. package/dist/patterns.d.ts.map +1 -0
  11. package/dist/renderer.d.ts +113 -0
  12. package/dist/renderer.d.ts.map +1 -0
  13. package/dist/style.css +124 -0
  14. package/dist/svelte/GossamerBorder.svelte.d.ts +1 -0
  15. package/dist/svelte/GossamerClouds.svelte.d.ts +1 -0
  16. package/dist/svelte/GossamerImage.svelte.d.ts +1 -0
  17. package/dist/svelte/GossamerOverlay.svelte.d.ts +1 -0
  18. package/dist/svelte/GossamerText.svelte.d.ts +1 -0
  19. package/dist/svelte/index.d.ts +20 -0
  20. package/dist/svelte/index.d.ts.map +1 -0
  21. package/dist/svelte/index.js +3651 -0
  22. package/dist/svelte/index.js.map +1 -0
  23. package/dist/svelte/presets.d.ts +38 -0
  24. package/dist/svelte/presets.d.ts.map +1 -0
  25. package/dist/utils/canvas.d.ts +73 -0
  26. package/dist/utils/canvas.d.ts.map +1 -0
  27. package/dist/utils/image.d.ts +74 -0
  28. package/dist/utils/image.d.ts.map +1 -0
  29. package/dist/utils/performance.d.ts +86 -0
  30. package/dist/utils/performance.d.ts.map +1 -0
  31. package/package.json +23 -5
  32. package/src/animation.test.ts +254 -0
  33. package/src/animation.ts +243 -0
  34. package/src/characters.test.ts +148 -0
  35. package/src/characters.ts +164 -0
  36. package/src/index.test.ts +115 -0
  37. package/src/index.ts +133 -11
  38. package/src/patterns.test.ts +273 -0
  39. package/src/patterns.ts +316 -0
  40. package/src/renderer.ts +309 -0
  41. package/src/svelte/GossamerBorder.svelte +326 -0
  42. package/src/svelte/GossamerClouds.svelte +269 -0
  43. package/src/svelte/GossamerImage.svelte +266 -0
  44. package/src/svelte/GossamerOverlay.svelte +232 -0
  45. package/src/svelte/GossamerText.svelte +239 -0
  46. package/src/svelte/index.ts +75 -0
  47. package/src/svelte/presets.ts +174 -0
  48. package/src/utils/canvas.ts +210 -0
  49. package/src/utils/image.ts +275 -0
  50. package/src/utils/performance.ts +282 -0
@@ -0,0 +1,239 @@
1
+ <script lang="ts" module>
2
+ import type { PatternType } from '../index';
3
+
4
+ export interface GossamerTextProps {
5
+ /** Text content to display */
6
+ text: string;
7
+ /** Character set for effect (light to dark) */
8
+ characters?: string;
9
+ /** Text color */
10
+ color?: string;
11
+ /** Font size in pixels */
12
+ fontSize?: number;
13
+ /** Font family */
14
+ fontFamily?: string;
15
+ /** Enable animation effect */
16
+ animated?: boolean;
17
+ /** Animation pattern */
18
+ pattern?: PatternType;
19
+ /** Animation speed */
20
+ speed?: number;
21
+ /** Effect intensity */
22
+ intensity?: number;
23
+ /** Target FPS */
24
+ fps?: number;
25
+ /** Additional CSS class */
26
+ class?: string;
27
+ }
28
+ </script>
29
+
30
+ <script lang="ts">
31
+ import { onMount } from 'svelte';
32
+ import {
33
+ perlinNoise2D,
34
+ createVisibilityObserver,
35
+ onReducedMotionChange,
36
+ CHARACTER_SETS,
37
+ } from '../index';
38
+
39
+ // Props with defaults
40
+ let {
41
+ text,
42
+ characters = CHARACTER_SETS.minimal.characters,
43
+ color = 'currentColor',
44
+ fontSize = 48,
45
+ fontFamily = 'monospace',
46
+ animated = false,
47
+ pattern = 'perlin',
48
+ speed = 0.5,
49
+ intensity = 0.3,
50
+ fps = 30,
51
+ class: className = '',
52
+ }: GossamerTextProps = $props();
53
+
54
+ // State
55
+ let canvas: HTMLCanvasElement;
56
+ let container: HTMLDivElement;
57
+ let isVisible = true;
58
+ let reducedMotion = false;
59
+ let animationId: number | null = null;
60
+ let textMetrics: { width: number; height: number } = { width: 0, height: 0 };
61
+
62
+ const shouldAnimate = $derived(animated && isVisible && !reducedMotion);
63
+
64
+ function measureText(): void {
65
+ if (!canvas) return;
66
+
67
+ const ctx = canvas.getContext('2d');
68
+ if (!ctx) return;
69
+
70
+ ctx.font = `${fontSize}px ${fontFamily}`;
71
+ const metrics = ctx.measureText(text);
72
+
73
+ textMetrics = {
74
+ width: Math.ceil(metrics.width) + 20,
75
+ height: fontSize + 20,
76
+ };
77
+
78
+ canvas.width = textMetrics.width;
79
+ canvas.height = textMetrics.height;
80
+ }
81
+
82
+ function renderText(time: number = 0): void {
83
+ if (!canvas) return;
84
+
85
+ const ctx = canvas.getContext('2d');
86
+ if (!ctx) return;
87
+
88
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
89
+ ctx.font = `${fontSize}px ${fontFamily}`;
90
+ ctx.textBaseline = 'top';
91
+ ctx.fillStyle = color;
92
+
93
+ // Render each character with potential effect
94
+ let x = 10;
95
+ const y = 10;
96
+
97
+ for (let i = 0; i < text.length; i++) {
98
+ const char = text[i];
99
+ const charWidth = ctx.measureText(char).width;
100
+
101
+ if (animated && time > 0) {
102
+ // Apply noise-based effect to character
103
+ const noise = perlinNoise2D(
104
+ i * 0.5 + time * speed * 0.001,
105
+ time * speed * 0.0005
106
+ );
107
+
108
+ // Slight position offset based on noise
109
+ const offsetY = noise * intensity * 5;
110
+
111
+ // Slight opacity variation
112
+ ctx.globalAlpha = 0.7 + (noise + 1) * 0.15;
113
+
114
+ ctx.fillText(char, x, y + offsetY);
115
+ ctx.globalAlpha = 1;
116
+ } else {
117
+ ctx.fillText(char, x, y);
118
+ }
119
+
120
+ x += charWidth;
121
+ }
122
+ }
123
+
124
+ let startTime = 0;
125
+
126
+ function animate(currentTime: number): void {
127
+ if (!shouldAnimate) return;
128
+
129
+ const elapsed = currentTime - startTime;
130
+ renderText(elapsed);
131
+ animationId = requestAnimationFrame(animate);
132
+ }
133
+
134
+ function startAnimation(): void {
135
+ if (animationId !== null) return;
136
+ startTime = performance.now();
137
+ animationId = requestAnimationFrame(animate);
138
+ }
139
+
140
+ function stopAnimation(): void {
141
+ if (animationId !== null) {
142
+ cancelAnimationFrame(animationId);
143
+ animationId = null;
144
+ }
145
+ }
146
+
147
+ // Lifecycle
148
+ onMount(() => {
149
+ measureText();
150
+ renderText();
151
+
152
+ const cleanupMotion = onReducedMotionChange((prefers) => {
153
+ reducedMotion = prefers;
154
+ });
155
+
156
+ const cleanupVisibility = createVisibilityObserver(
157
+ container,
158
+ (visible) => {
159
+ isVisible = visible;
160
+ if (visible && shouldAnimate) {
161
+ startAnimation();
162
+ } else {
163
+ stopAnimation();
164
+ renderText();
165
+ }
166
+ },
167
+ 0.1
168
+ );
169
+
170
+ if (shouldAnimate) {
171
+ startAnimation();
172
+ }
173
+
174
+ return () => {
175
+ cleanupMotion();
176
+ cleanupVisibility();
177
+ stopAnimation();
178
+ };
179
+ });
180
+
181
+ // React to text changes
182
+ $effect(() => {
183
+ if (text) {
184
+ measureText();
185
+ if (shouldAnimate) {
186
+ // Animation will handle rendering
187
+ } else {
188
+ renderText();
189
+ }
190
+ }
191
+ });
192
+
193
+ // React to animation state changes
194
+ $effect(() => {
195
+ if (shouldAnimate) {
196
+ startAnimation();
197
+ } else {
198
+ stopAnimation();
199
+ renderText();
200
+ }
201
+ });
202
+ </script>
203
+
204
+ <div
205
+ bind:this={container}
206
+ class="gossamer-text {className}"
207
+ style:width={textMetrics.width ? `${textMetrics.width}px` : 'auto'}
208
+ style:height={textMetrics.height ? `${textMetrics.height}px` : 'auto'}
209
+ >
210
+ <canvas
211
+ bind:this={canvas}
212
+ aria-hidden="true"
213
+ class="gossamer-canvas"
214
+ ></canvas>
215
+ <span class="gossamer-text-sr">{text}</span>
216
+ </div>
217
+
218
+ <style>
219
+ .gossamer-text {
220
+ position: relative;
221
+ display: inline-block;
222
+ }
223
+
224
+ .gossamer-canvas {
225
+ display: block;
226
+ }
227
+
228
+ .gossamer-text-sr {
229
+ position: absolute;
230
+ width: 1px;
231
+ height: 1px;
232
+ padding: 0;
233
+ margin: -1px;
234
+ overflow: hidden;
235
+ clip: rect(0, 0, 0, 0);
236
+ white-space: nowrap;
237
+ border: 0;
238
+ }
239
+ </style>
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Gossamer - Svelte 5 Components
3
+ *
4
+ * ASCII visual effects components for Svelte applications.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ // =============================================================================
10
+ // Components
11
+ // =============================================================================
12
+
13
+ export { default as GossamerClouds } from './GossamerClouds.svelte';
14
+ export { default as GossamerImage } from './GossamerImage.svelte';
15
+ export { default as GossamerText } from './GossamerText.svelte';
16
+ export { default as GossamerOverlay } from './GossamerOverlay.svelte';
17
+ export { default as GossamerBorder } from './GossamerBorder.svelte';
18
+
19
+ // =============================================================================
20
+ // Component Types
21
+ // =============================================================================
22
+
23
+ export type { GossamerCloudsProps } from './GossamerClouds.svelte';
24
+ export type { GossamerImageProps } from './GossamerImage.svelte';
25
+ export type { GossamerTextProps } from './GossamerText.svelte';
26
+ export type { GossamerOverlayProps, BlendMode } from './GossamerOverlay.svelte';
27
+ export type { GossamerBorderProps, BorderStyle } from './GossamerBorder.svelte';
28
+
29
+ // =============================================================================
30
+ // Presets
31
+ // =============================================================================
32
+
33
+ export {
34
+ PRESETS,
35
+ grovePresets,
36
+ seasonalPresets,
37
+ ambientPresets,
38
+ getPreset,
39
+ getPresetNames,
40
+ getPresetsByCategory,
41
+ } from './presets';
42
+
43
+ // =============================================================================
44
+ // Re-exports from Core
45
+ // =============================================================================
46
+
47
+ export {
48
+ // Core types
49
+ type GossamerConfig,
50
+ type PresetConfig,
51
+ type PatternConfig,
52
+ type PatternType,
53
+ type CharacterSet,
54
+
55
+ // Constants
56
+ DEFAULT_CHARACTERS,
57
+ DEFAULT_CONFIG,
58
+ CHARACTER_SETS,
59
+
60
+ // Core functions
61
+ calculateBrightness,
62
+ brightnessToChar,
63
+
64
+ // Character utilities
65
+ getCharacterSet,
66
+ getCharacters,
67
+ getCharacterSetNames,
68
+ invertCharacters,
69
+
70
+ // Performance utilities
71
+ prefersReducedMotion,
72
+
73
+ // Version
74
+ VERSION,
75
+ } from '../index';
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Preset configurations for Gossamer effects
3
+ */
4
+
5
+ import type { PresetConfig } from '../index';
6
+
7
+ /**
8
+ * Grove-themed presets
9
+ * Organic, nature-inspired effects
10
+ */
11
+ export const grovePresets: Record<string, PresetConfig> = {
12
+ 'grove-mist': {
13
+ name: 'Grove Mist',
14
+ description: 'Soft fog effect drifting through the trees',
15
+ characters: ' ·∙•◦',
16
+ pattern: 'perlin',
17
+ frequency: 0.03,
18
+ amplitude: 0.8,
19
+ speed: 0.3,
20
+ opacity: 0.2,
21
+ },
22
+ 'grove-fireflies': {
23
+ name: 'Grove Fireflies',
24
+ description: 'Twinkling points of light in the darkness',
25
+ characters: ' ·*✦✧',
26
+ pattern: 'static',
27
+ frequency: 0.01,
28
+ amplitude: 1.2,
29
+ speed: 0.8,
30
+ opacity: 0.3,
31
+ },
32
+ 'grove-rain': {
33
+ name: 'Grove Rain',
34
+ description: 'Gentle rain falling through the canopy',
35
+ characters: ' │\\|/',
36
+ pattern: 'waves',
37
+ frequency: 0.05,
38
+ amplitude: 1.0,
39
+ speed: 1.5,
40
+ opacity: 0.15,
41
+ },
42
+ 'grove-dew': {
43
+ name: 'Grove Dew',
44
+ description: 'Morning dew glistening on spider silk',
45
+ characters: ' ·∘∙●',
46
+ pattern: 'fbm',
47
+ frequency: 0.04,
48
+ amplitude: 0.7,
49
+ speed: 0.1,
50
+ opacity: 0.15,
51
+ },
52
+ };
53
+
54
+ /**
55
+ * Seasonal presets
56
+ * Effects themed around the four seasons
57
+ */
58
+ export const seasonalPresets: Record<string, PresetConfig> = {
59
+ 'winter-snow': {
60
+ name: 'Winter Snow',
61
+ description: 'Gentle snowfall on a quiet night',
62
+ characters: ' ·∙*❄',
63
+ pattern: 'perlin',
64
+ frequency: 0.04,
65
+ amplitude: 0.9,
66
+ speed: 0.5,
67
+ opacity: 0.25,
68
+ },
69
+ 'autumn-leaves': {
70
+ name: 'Autumn Leaves',
71
+ description: 'Scattered leaves drifting on the wind',
72
+ characters: ' 🍂·∙',
73
+ pattern: 'perlin',
74
+ frequency: 0.06,
75
+ amplitude: 1.1,
76
+ speed: 0.4,
77
+ opacity: 0.2,
78
+ },
79
+ 'spring-petals': {
80
+ name: 'Spring Petals',
81
+ description: 'Cherry blossom petals floating on the breeze',
82
+ characters: ' ·✿❀',
83
+ pattern: 'waves',
84
+ frequency: 0.05,
85
+ amplitude: 0.8,
86
+ speed: 0.6,
87
+ opacity: 0.2,
88
+ },
89
+ 'summer-heat': {
90
+ name: 'Summer Heat',
91
+ description: 'Heat shimmer rising from sun-warmed ground',
92
+ characters: ' ~≈∿',
93
+ pattern: 'waves',
94
+ frequency: 0.08,
95
+ amplitude: 1.3,
96
+ speed: 1.0,
97
+ opacity: 0.1,
98
+ },
99
+ };
100
+
101
+ /**
102
+ * Ambient presets
103
+ * Subtle background textures
104
+ */
105
+ export const ambientPresets: Record<string, PresetConfig> = {
106
+ 'ambient-static': {
107
+ name: 'Ambient Static',
108
+ description: 'Gentle static noise texture',
109
+ characters: ' .:',
110
+ pattern: 'static',
111
+ frequency: 0.1,
112
+ amplitude: 0.5,
113
+ speed: 0.2,
114
+ opacity: 0.08,
115
+ },
116
+ 'ambient-waves': {
117
+ name: 'Ambient Waves',
118
+ description: 'Soft flowing wave pattern',
119
+ characters: ' ·~',
120
+ pattern: 'waves',
121
+ frequency: 0.02,
122
+ amplitude: 0.6,
123
+ speed: 0.3,
124
+ opacity: 0.1,
125
+ },
126
+ 'ambient-clouds': {
127
+ name: 'Ambient Clouds',
128
+ description: 'Drifting cloud-like patterns',
129
+ characters: ' .:-',
130
+ pattern: 'fbm',
131
+ frequency: 0.02,
132
+ amplitude: 0.7,
133
+ speed: 0.15,
134
+ opacity: 0.12,
135
+ },
136
+ };
137
+
138
+ /**
139
+ * All presets combined for easy access
140
+ */
141
+ export const PRESETS: Record<string, PresetConfig> = {
142
+ ...grovePresets,
143
+ ...seasonalPresets,
144
+ ...ambientPresets,
145
+ };
146
+
147
+ /**
148
+ * Get a preset by name
149
+ */
150
+ export function getPreset(name: string): PresetConfig | undefined {
151
+ return PRESETS[name];
152
+ }
153
+
154
+ /**
155
+ * List all available preset names
156
+ */
157
+ export function getPresetNames(): string[] {
158
+ return Object.keys(PRESETS);
159
+ }
160
+
161
+ /**
162
+ * List preset names by category
163
+ */
164
+ export function getPresetsByCategory(): {
165
+ grove: string[];
166
+ seasonal: string[];
167
+ ambient: string[];
168
+ } {
169
+ return {
170
+ grove: Object.keys(grovePresets),
171
+ seasonal: Object.keys(seasonalPresets),
172
+ ambient: Object.keys(ambientPresets),
173
+ };
174
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Canvas Utilities
3
+ *
4
+ * Helper functions for canvas creation, setup, and manipulation.
5
+ */
6
+
7
+ /**
8
+ * Options for canvas creation
9
+ */
10
+ export interface CanvasOptions {
11
+ /** Canvas width in pixels */
12
+ width?: number;
13
+ /** Canvas height in pixels */
14
+ height?: number;
15
+ /** Whether to use high DPI scaling */
16
+ highDPI?: boolean;
17
+ /** CSS class to add to canvas */
18
+ className?: string;
19
+ /** Inline styles to apply */
20
+ style?: Partial<CSSStyleDeclaration>;
21
+ }
22
+
23
+ /**
24
+ * Create a canvas element with optimal settings
25
+ */
26
+ export function createCanvas(options: CanvasOptions = {}): HTMLCanvasElement {
27
+ const { width = 300, height = 150, highDPI = true, className, style } = options;
28
+
29
+ const canvas = document.createElement('canvas');
30
+
31
+ if (highDPI) {
32
+ const dpr = window.devicePixelRatio || 1;
33
+ canvas.width = width * dpr;
34
+ canvas.height = height * dpr;
35
+ canvas.style.width = `${width}px`;
36
+ canvas.style.height = `${height}px`;
37
+
38
+ const ctx = canvas.getContext('2d');
39
+ if (ctx) {
40
+ ctx.scale(dpr, dpr);
41
+ }
42
+ } else {
43
+ canvas.width = width;
44
+ canvas.height = height;
45
+ }
46
+
47
+ if (className) {
48
+ canvas.className = className;
49
+ }
50
+
51
+ if (style) {
52
+ Object.assign(canvas.style, style);
53
+ }
54
+
55
+ return canvas;
56
+ }
57
+
58
+ /**
59
+ * Get the device pixel ratio
60
+ */
61
+ export function getDevicePixelRatio(): number {
62
+ return typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
63
+ }
64
+
65
+ /**
66
+ * Resize canvas to match container dimensions
67
+ */
68
+ export function resizeCanvasToContainer(
69
+ canvas: HTMLCanvasElement,
70
+ container: HTMLElement,
71
+ highDPI: boolean = true
72
+ ): { width: number; height: number } {
73
+ const rect = container.getBoundingClientRect();
74
+ const dpr = highDPI ? getDevicePixelRatio() : 1;
75
+
76
+ const width = rect.width;
77
+ const height = rect.height;
78
+
79
+ canvas.width = width * dpr;
80
+ canvas.height = height * dpr;
81
+ canvas.style.width = `${width}px`;
82
+ canvas.style.height = `${height}px`;
83
+
84
+ if (highDPI) {
85
+ const ctx = canvas.getContext('2d');
86
+ if (ctx) {
87
+ ctx.scale(dpr, dpr);
88
+ }
89
+ }
90
+
91
+ return { width, height };
92
+ }
93
+
94
+ /**
95
+ * Create an offscreen canvas for buffer rendering
96
+ */
97
+ export function createOffscreenCanvas(width: number, height: number): HTMLCanvasElement | OffscreenCanvas {
98
+ if (typeof OffscreenCanvas !== 'undefined') {
99
+ return new OffscreenCanvas(width, height);
100
+ }
101
+
102
+ // Fallback for environments without OffscreenCanvas
103
+ const canvas = document.createElement('canvas');
104
+ canvas.width = width;
105
+ canvas.height = height;
106
+ return canvas;
107
+ }
108
+
109
+ /**
110
+ * Clear a canvas
111
+ */
112
+ export function clearCanvas(
113
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
114
+ width: number,
115
+ height: number,
116
+ backgroundColor?: string
117
+ ): void {
118
+ if (backgroundColor) {
119
+ ctx.fillStyle = backgroundColor;
120
+ ctx.fillRect(0, 0, width, height);
121
+ } else {
122
+ ctx.clearRect(0, 0, width, height);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Get image data from a canvas region
128
+ */
129
+ export function getImageData(
130
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
131
+ x: number = 0,
132
+ y: number = 0,
133
+ width?: number,
134
+ height?: number
135
+ ): ImageData {
136
+ const canvas = ctx.canvas;
137
+ const w = width ?? canvas.width;
138
+ const h = height ?? canvas.height;
139
+
140
+ return ctx.getImageData(x, y, w, h);
141
+ }
142
+
143
+ /**
144
+ * Set optimal rendering context settings
145
+ */
146
+ export function optimizeContext(ctx: CanvasRenderingContext2D): void {
147
+ // Disable image smoothing for crisp ASCII rendering
148
+ ctx.imageSmoothingEnabled = false;
149
+
150
+ // Use source-over for standard compositing
151
+ ctx.globalCompositeOperation = 'source-over';
152
+ }
153
+
154
+ /**
155
+ * Set text rendering options for ASCII display
156
+ */
157
+ export function setupTextRendering(
158
+ ctx: CanvasRenderingContext2D,
159
+ fontSize: number,
160
+ fontFamily: string = 'monospace',
161
+ color: string = '#ffffff'
162
+ ): void {
163
+ ctx.font = `${fontSize}px ${fontFamily}`;
164
+ ctx.textBaseline = 'top';
165
+ ctx.textAlign = 'left';
166
+ ctx.fillStyle = color;
167
+ }
168
+
169
+ /**
170
+ * Measure text width for a given font configuration
171
+ */
172
+ export function measureTextWidth(
173
+ ctx: CanvasRenderingContext2D,
174
+ text: string,
175
+ fontSize: number,
176
+ fontFamily: string = 'monospace'
177
+ ): number {
178
+ const originalFont = ctx.font;
179
+ ctx.font = `${fontSize}px ${fontFamily}`;
180
+ const metrics = ctx.measureText(text);
181
+ ctx.font = originalFont;
182
+ return metrics.width;
183
+ }
184
+
185
+ /**
186
+ * Calculate optimal cell size for a given canvas and desired columns
187
+ */
188
+ export function calculateCellSize(
189
+ canvasWidth: number,
190
+ canvasHeight: number,
191
+ targetCols: number
192
+ ): { cellWidth: number; cellHeight: number; cols: number; rows: number } {
193
+ const cellWidth = Math.floor(canvasWidth / targetCols);
194
+ // Use a typical monospace aspect ratio of ~0.6
195
+ const cellHeight = Math.floor(cellWidth * 1.5);
196
+ const cols = Math.floor(canvasWidth / cellWidth);
197
+ const rows = Math.floor(canvasHeight / cellHeight);
198
+
199
+ return { cellWidth, cellHeight, cols, rows };
200
+ }
201
+
202
+ /**
203
+ * Apply a CSS blend mode to canvas compositing
204
+ */
205
+ export function setBlendMode(
206
+ ctx: CanvasRenderingContext2D,
207
+ mode: 'normal' | 'multiply' | 'screen' | 'overlay' | 'darken' | 'lighten' | 'color-dodge' | 'color-burn' | 'soft-light' | 'hard-light' | 'difference' | 'exclusion'
208
+ ): void {
209
+ ctx.globalCompositeOperation = mode === 'normal' ? 'source-over' : mode;
210
+ }