retro-crt 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 retro-crt contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # retro-crt
2
+
3
+ A CRT monitor effect your content renders *inside*. Curvature, bloom and chromatic aberration apply
4
+ to the real pixels of whatever you wrap, not a scanline sticker on top.
5
+
6
+ Zero runtime dependencies. 9.3 KB gzipped.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install retro-crt # plain JS
12
+ npm install retro-crt @retro-crt/react # or /vue, /svelte, /solid, /angular
13
+ ```
14
+
15
+ ## Use it
16
+
17
+ ```js
18
+ import { CRT } from 'retro-crt'
19
+ import 'retro-crt/styles.css'
20
+
21
+ const screen = CRT.mount('#app', { curvature: 0.4 })
22
+
23
+ screen.update({ curvature: 0.7 })
24
+ screen.activeRenderer // 'webgl' | 'svg' | 'css'
25
+ screen.destroy()
26
+ ```
27
+
28
+ ```jsx
29
+ import { CRTScreen } from '@retro-crt/react'
30
+ import 'retro-crt/styles.css'
31
+
32
+ <CRTScreen curvature={0.4} scanlines={{ intensity: 0.6 }}>
33
+ <App />
34
+ </CRTScreen>
35
+ ```
36
+
37
+ Every adapter takes the same plain `CRTOptions` object. All adapters are SSR-safe. To wrap a whole
38
+ page, call `CRT.mountFullPage()`.
39
+
40
+ > **Wrapping content breaks `position: fixed` inside it.** A CSS filter makes its element the
41
+ > containing block for every fixed descendant. Use `CRT.mountFullPage()` for a whole page, which the
42
+ > spec exempts.
43
+
44
+ ## Renderers
45
+
46
+ `renderer: 'auto'` picks one of three tiers from measured facts, never the user-agent string.
47
+
48
+ | | `webgl` | `svg` | `css` |
49
+ |---|---|---|---|
50
+ | Curvature | true barrel | approximation | edge shading |
51
+ | Chromatic aberration | yes | yes | no |
52
+ | Persistence | yes | no | no |
53
+ | Content stays interactive | **no** | yes | yes |
54
+ | Picked for | a lone image, video or canvas | Chrome and Firefox on desktop | Safari over animating content, low-end devices |
55
+
56
+ The WebGL tier is a separate chunk that loads only when something uses it.
57
+
58
+ ## Options
59
+
60
+ ```ts
61
+ interface CRTOptions {
62
+ renderer?: 'auto' | 'webgl' | 'svg' | 'css' // 'auto'
63
+ background?: string // '#000000'
64
+ curvature?: number // 0-1, 0.3
65
+ vignette?: number // 0-1, 0.5
66
+ scanlines?: { intensity?: number; gap?: number; flicker?: boolean } // 0.5, 3px, false
67
+ tint?: { preset?: 'default' | 'green' | 'amber' | 'blue' | 'violet' | 'lime' | 'pink' | 'red'
68
+ strength?: number } // 'default', 0.35
69
+ bloom?: { enabled?: boolean; radius?: number; strength?: number; threshold?: number }
70
+ chromaticAberration?: { intensity?: number } // 0-6px, 0.6
71
+ noise?: { enabled?: boolean; static?: number } // true, 0.04
72
+ persistence?: { strength?: number } // 0-0.5, 0 (webgl only)
73
+ bezel?: { enabled?: boolean; thickness?: number; radius?: { outer?: number; screen?: number }
74
+ color?: string; bevel?: number; lightAngle?: number; innerLip?: number
75
+ screenSpill?: number; glare?: number; shadow?: boolean }
76
+ quality?: 'auto' | 'high' | 'balanced' | 'low' // 'auto'
77
+ maxPixelRatio?: number // 2
78
+ pauseWhenOffscreen?: boolean // true
79
+ respectReducedMotion?: boolean // true
80
+ respectSaveData?: boolean // true
81
+ }
82
+ ```
83
+
84
+ Out-of-range numbers clamp. Unknown values fall back to the default. `background` and `bezel.color`
85
+ accept `#rgb`, `#rrggbb` and `rgb()` only. When a renderer cannot honour an option you set, it
86
+ ignores it and warns once in development.
87
+
88
+ `prefers-reduced-motion` stops flicker, grain and persistence. `prefers-reduced-transparency` halves
89
+ tint and bloom. Save-Data forces the CSS tier. Effect layers are `aria-hidden` and
90
+ `pointer-events: none`.
91
+
92
+ ## License
93
+
94
+ MIT
@@ -0,0 +1 @@
1
+ function L(t){let e=t.trim().match(/^#([\da-f]{3}|[\da-f]{6})$/i)?.[1];if(e){let n=e.length===3?[...e].map(o=>o+o).join(""):e,a=o=>parseInt(n.slice(o,o+2),16);return[a(0),a(2),a(4)]}let r=t.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i);return r?[+r[1],+r[2],+r[3]]:[107,91,74]}var C=t=>(t/=255)<=.04045?t/12.92:((t+.055)/1.055)**2.4,S=t=>255*(t<=.0031308?12.92*t:1.055*t**(1/2.4)-.055);function $([t,e,r]){let[n,a,o]=[t,e,r].map(C),i=Math.cbrt(.4122214708*n+.5363325363*a+.0514459929*o),s=Math.cbrt(.2119034982*n+.6806995451*a+.1073969566*o),l=Math.cbrt(.0883024619*n+.2817188376*a+.6299787005*o),c=.2104542553*i+.793617785*s-.0040720468*l,u=1.9779984951*i-2.428592205*s+.4505937099*l,m=.0259040371*i+.7827717662*s-.808675766*l;return[c,Math.hypot(u,m),Math.atan2(m,u)]}function A(t,e,r){let n=e*Math.cos(r),a=e*Math.sin(r),o=(t+.3963377774*n+.2158037573*a)**3,i=(t-.1055613458*n-.0638541728*a)**3,s=(t-.0894841775*n-1.291485548*a)**3,l=[4.0767416621*o-3.3077115913*i+.2309699292*s,-1.2684380046*o+2.6097574011*i-.3413193965*s,-.0041960863*o-.7034186147*i+1.707614701*s],[c,u,m]=l.map(b=>Math.round(Math.min(255,Math.max(0,S(b)))));return[c,u,m]}var N=([t,e,r],n=1)=>`rgb(${t} ${e} ${r} / ${+n.toFixed(3)})`;function D(t){let e=L(t),[r,n,a]=$(e),o=(i,s)=>A(Math.min(.97,Math.max(.04,r+i)),n*s,a);return{base:e,highlight:o(.14,.8),shade:o(-.16,.9),lip:o(-r*.78,.5)}}var y={renderer:"auto",background:"#000000",curvature:.3,vignette:.5,scanlines:{intensity:.5,gap:3,flicker:!1},tint:{preset:"default",strength:.35},bloom:{enabled:!0,radius:8,strength:.4,threshold:.6},chromaticAberration:{intensity:.6},noise:{enabled:!0,static:.04},persistence:{strength:0},bezel:{enabled:!1,thickness:48,radius:{outer:28,screen:10},color:"#6b5b4a",bevel:.6,lightAngle:315,innerLip:.7,screenSpill:.35,glare:.2,shadow:!0},quality:"auto",maxPixelRatio:2,pauseWhenOffscreen:!0,respectReducedMotion:!0,respectSaveData:!0},h={default:null,green:[51,255,102],amber:[255,176,0],blue:[190,215,255],violet:[186,140,255],lime:[190,255,80],pink:[255,140,200],red:[255,90,70]},E={curvature:[0,1],vignette:[0,1],"scanlines.intensity":[0,1],"scanlines.gap":[1,8],"tint.strength":[0,1],"bloom.radius":[0,40],"bloom.strength":[0,1],"bloom.threshold":[0,1],"chromaticAberration.intensity":[0,6],"noise.static":[0,.3],"persistence.strength":[0,.5],"bezel.thickness":[0,200],"bezel.radius.outer":[0,200],"bezel.radius.screen":[0,200],"bezel.bevel":[0,1],"bezel.lightAngle":[0,360],"bezel.innerLip":[0,1],"bezel.screenSpill":[0,1],"bezel.glare":[0,1],maxPixelRatio:[.5,4]},g=t=>typeof t=="object"&&t!==null&&!Array.isArray(t);function x(t,e,r){let n={},a=g(e)?e:{};for(let o of Object.keys(t)){let i=t[o],s=a[o],l=r?`${r}.${o}`:o;if(g(i))n[o]=x(i,s,l);else if(typeof i=="number"){let c=E[l],u=typeof s=="number"&&Number.isFinite(s)?s:i;n[o]=c?Math.min(c[1],Math.max(c[0],u)):u}else if(typeof i=="string"&&typeof s=="string"){let c=P.has(l)?z(s):B[l]?.includes(s);n[o]=c?s:i}else n[o]=typeof s==typeof i?s:i}return n}var B={renderer:["auto","webgl","svg","css"],quality:["auto","high","balanced","low"],"tint.preset":Object.keys(h)},P=new Set(["background","bezel.color"]),z=t=>/^#([\da-f]{3}|[\da-f]{6})$/i.test(t.trim())||/^rgba?\(\s*\d+[\s,]+\d+[\s,]+\d+/i.test(t.trim()),G=t=>x(y,t,"");function I(t){let e=G(t),r=(a,o)=>{let i={};for(let s of Object.keys(a))if(g(a[s])){let l=r(a[s],o[s]);Object.keys(l).length&&(i[s]=l)}else a[s]!==o[s]&&(i[s]=a[s]);return i},n=r(e,y);return e.bezel.enabled||delete n.bezel,n}var p=class{constructor(e){this.parent=e}parent;nodes=new Map;toggle(e,r){let n=this.nodes.get(e);if(!r){n?.remove(),this.nodes.delete(e);return}return n||(n=document.createElement("div"),n.className=`rcrt-${e}`,n.setAttribute("aria-hidden","true"),this.nodes.set(e,n),this.parent.appendChild(n)),n}clear(){this.nodes.forEach(e=>e.remove()),this.nodes.clear()}},f=(t,e)=>{for(let[r,n]of Object.entries(e))t.style.setProperty(`--rcrt-${r}`,String(n))};var R=new Map,v=new Map;function k(t,e){let r=Object.assign(document.createElement("canvas"),{width:t,height:t}),n=r.getContext("2d");if(!n)return"";let a=n.createImageData(t,t);for(let o=0;o<t;o++)for(let i=0;i<t;i++)e(a.data,i,o,(o*t+i)*4);return n.putImageData(a,0,0),r.toDataURL("image/png")}function K(t){let e=Math.round(Math.min(4,Math.max(.25,t))*20)/20,r=String(e),n=R.get(r);if(n)return n;let a=256,o=e>=1?1:e,i=e>=1?1/e:1;return n=k(a,(s,l,c,u)=>{let m=(l+.5)/a*2-1,b=(c+.5)/a*2-1,d=m*m+b*b;s[u]=255*(.5+m*d*o/4),s[u+1]=255*(.5+b*d*i/4),s[u+3]=255}),R.set(r,n),n}var F=(t,e,r)=>2*t*.12*Math.max(e,r);function q(t,e,r){let n=e/Math.max(1,r);return{x:t*.25*Math.min(1,n),y:t*.25*Math.min(1,1/n)}}var Q={high:256,balanced:160,low:96};function M(t){let e=v.get(t);return e||(e=k(Q[t],(r,n,a,o)=>{r[o]=r[o+1]=r[o+2]=Math.random()*255,r[o+3]=255}),v.set(t,e),e)}var T={high:1,balanced:.75,low:.5},J=t=>T[t];function j(t,e){let r=Math.max(2,Math.round(t*e)),n=Math.max(1,Math.round(r/2));return{gap:r/e,line:n/e}}var w=(t,e)=>{let r=document.createElement("div");return r.className=e,r.setAttribute("aria-hidden","true"),t.appendChild(r),r},O=class{root;motion;layers;motionLayers;constructor(e){this.root=w(e,"rcrt-fx"),this.motion=w(e,"rcrt-fx rcrt-fx-motion"),this.layers=new p(this.root),this.motionLayers=new p(this.motion)}update(e,{tier:r,quality:n,dpr:a,viewport:o,pull:i}){let s=h[e.tint.preset],l=j(e.scanlines.gap,a),c=e.noise.enabled&&e.noise.static>0,u=r==="css"&&e.bloom.enabled&&e.bloom.strength>0,m=e.bloom.radius*T[n];this.layers.toggle("tint",s!==null&&e.tint.strength>0),this.motionLayers.toggle("scanlines",e.scanlines.intensity>0),this.motionLayers.toggle("flicker",e.scanlines.flicker&&n!=="low"),this.layers.toggle("vignette",e.vignette>0),this.layers.toggle("bloom",u),this.layers.toggle("curve",!o&&e.curvature>0);let b=this.motionLayers.toggle("noise",c);b&&(b.dataset.frames=n==="high"?"8":n==="balanced"?"6":"4");let d={tint:s?`rgb(${s.join(" ")})`:"transparent","tint-alpha":e.tint.strength*.85,"scan-alpha":e.scanlines.intensity*.75,"scan-gap":`${l.gap}px`,"scan-line":`${l.line}px`,"noise-url":c?`url("${M(n)}")`:"none","noise-alpha":e.noise.static*2.5,vignette:e.vignette*.85,"bloom-filter":`contrast(${1+e.bloom.threshold*1.5}) brightness(${1+e.bloom.strength}) blur(${m}px)`,"bloom-alpha":e.bloom.strength*.55,"edge-blur":`${r==="css"?e.curvature*60:i.x*1.4+6}px`,"edge-spread":`${r==="css"?e.curvature*12:i.x*.25}px`,"curve-x":`${i.x}px`,"curve-y":`${i.y}px`};f(this.root,d),f(this.motion,d)}destroy(){this.layers.clear(),this.motionLayers.clear(),this.root.remove(),this.motion.remove()}};export{L as a,N as b,D as c,p as d,f as e,y as f,h as g,G as h,I as i,K as j,F as k,q as l,J as m,j as n,O as o};
@@ -0,0 +1,93 @@
1
+ import { C as CRTOptions, R as Renderer, Q as Quality } from './options-DWrUxuy7.js';
2
+ export { D as DEFAULTS, a as ResolvedOptions, T as TINT_COLORS, b as TintPreset, m as minimalOptions, r as resolveOptions } from './options-DWrUxuy7.js';
3
+
4
+ type ScreenListener = (screen: CRTScreen) => void;
5
+ declare class CRTScreen {
6
+ private readonly target;
7
+ private readonly fullPage;
8
+ private input;
9
+ private opts;
10
+ private dom;
11
+ private layers;
12
+ private bezel;
13
+ private filters;
14
+ private gl;
15
+ private glFailed;
16
+ private tier;
17
+ private quality;
18
+ private qualityCap;
19
+ private tierCap;
20
+ private caps;
21
+ private readonly device;
22
+ private size;
23
+ private inView;
24
+ private listeners;
25
+ private cleanups;
26
+ private stopSampling;
27
+ private destroyed;
28
+ private notified;
29
+ private readonly settle;
30
+ constructor(target: HTMLElement, options?: CRTOptions, fullPage?: boolean);
31
+ get activeRenderer(): Renderer;
32
+ get activeQuality(): Quality;
33
+ /** Deep-merges into the current options. */
34
+ update(partial: CRTOptions): void;
35
+ /** Replaces the options wholesale — what adapters use, so removed props revert to defaults. */
36
+ set(options: CRTOptions): void;
37
+ subscribe(fn: ScreenListener): () => void;
38
+ destroy(): void;
39
+ private build;
40
+ private teardown;
41
+ private resizeObserver;
42
+ private observe;
43
+ private get running();
44
+ private syncRunning;
45
+ private pickTier;
46
+ private render;
47
+ /** Builds exactly the parts a tier needs and removes the rest; `null` removes everything. */
48
+ private setTier;
49
+ private loadWebgl;
50
+ /** Runtime downgrade: quality first, then tier, one way only, until the budget holds (§4.2). */
51
+ private sample;
52
+ }
53
+
54
+ interface Capabilities {
55
+ webgl: boolean;
56
+ svg: boolean;
57
+ /** SVG filters render statically but drop out on composited (animating) content — WebKit #184601. */
58
+ svgStaticOnly: boolean;
59
+ }
60
+ declare function probeCapabilities(): Promise<Capabilities>;
61
+
62
+ type RGB = readonly [number, number, number];
63
+ /** Parses #rgb / #rrggbb / rgb(a) strings. Anything unparseable falls back to the default housing colour. */
64
+ declare function parseColor(input: string): RGB;
65
+ declare const rgbCss: ([r, g, b]: RGB, alpha?: number) => string;
66
+ interface BezelPalette {
67
+ base: RGB;
68
+ highlight: RGB;
69
+ shade: RGB;
70
+ lip: RGB;
71
+ }
72
+ /**
73
+ * Lightness steps in OKLCH so any hue gets a plausible moulded housing from one swatch (§4.3).
74
+ * Chroma is eased toward the extremes because saturated highlights read as paint, not plastic.
75
+ */
76
+ declare function bezelPalette(color: string): BezelPalette;
77
+
78
+ /** Snaps the gap to whole device pixels, never below 2, so it can't alias on a 3× screen (§6.2). */
79
+ declare function scanlineGeometry(gap: number, dpr: number): {
80
+ gap: number;
81
+ line: number;
82
+ };
83
+
84
+ declare const CRT: {
85
+ mount: (target: string | HTMLElement, options?: CRTOptions) => CRTScreen;
86
+ /**
87
+ * The whole page as a CRT. The spec exempts the root element from the filter containing-block
88
+ * rule, so `position: fixed` keeps anchoring to the viewport here (§5.1).
89
+ */
90
+ mountFullPage: (options?: CRTOptions) => CRTScreen;
91
+ };
92
+
93
+ export { type BezelPalette, CRT, CRTOptions, CRTScreen, type Capabilities, Quality, type RGB, Renderer, type ScreenListener, bezelPalette, parseColor, probeCapabilities, rgbCss, scanlineGeometry };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ import{a as E,b as h,c as S,d as q,e as v,f as ie,g as R,h as y,i as re,j as F,k as x,l as D,m as w,n as ne,o as I}from"./chunk-VWPH2GPA.js";var se=["inset","outer-radius","housing","bezel-shadow"],oe=[200,220,255],C=class{constructor(e){this.host=e;this.layers=new q(e)}host;layers;update(e,i){let r=e.bezel;if(this.host.toggleAttribute("data-rcrt-bezel",r.enabled),!r.enabled)return this.clear();let n=S(r.color),l=(r.lightAngle-90)*Math.PI/180,[a,s]=[Math.cos(l),Math.sin(l)],o=Math.max(1,r.thickness*.04),c=Math.max(2,r.thickness*.14)*r.innerLip,p=i?0:r.screenSpill,f=R[e.tint.preset]??oe;this.layers.toggle("bevel",!0),this.layers.toggle("spill",p>0),this.layers.toggle("lip",!0),this.layers.toggle("glare",r.glare>0),v(this.host,{inset:`${r.thickness}px`,"outer-radius":`${r.radius.outer}px`,"screen-radius":`${r.radius.screen}px`,housing:h(n.base),"bezel-shadow":r.shadow?"0 18px 40px -12px rgb(0 0 0 / .55), 0 4px 10px rgb(0 0 0 / .3)":"none","bevel-angle":`${r.lightAngle+180}deg`,"bevel-hi":h(n.highlight,r.bevel),"bevel-lo":h(n.shade,r.bevel),"bevel-edge":[`inset ${-a*o}px ${-s*o}px ${o}px ${h(n.highlight,r.bevel)}`,`inset ${a*o*1.5}px ${s*o*1.5}px ${o*2}px ${h(n.shade,r.bevel)}`].join(","),"lip-shadow":[`0 0 0 ${c}px ${h(n.lip)}`,`${-a*c*.5}px ${-s*c*.5}px 0 ${c}px ${h(n.highlight,.5*r.innerLip)}`,`inset ${-a*3}px ${-s*3}px ${10*r.innerLip}px ${2*r.innerLip}px rgb(0 0 0 / ${.35+r.innerLip*.5})`].join(","),spill:`0 0 ${r.thickness*.8}px ${r.thickness*.1}px ${h(f,p*.45)}`,glare:r.glare*.35})}clear(){this.layers.clear(),se.forEach(e=>this.host.style.removeProperty(`--rcrt-${e}`))}destroy(){this.clear(),this.host.removeAttribute("data-rcrt-bezel")}};var m=typeof window<"u"&&typeof document<"u",M=typeof process<"u"&&process.env.NODE_ENV!=="production",G=new Set;function u(t,e){!M||G.has(t)||(G.add(t),console.warn(`[retro-crt] ${e}`))}var H=t=>m&&typeof matchMedia=="function"&&matchMedia(t).matches;function O(t){let e=m?navigator.connection:void 0;return{reducedMotion:H("(prefers-reduced-motion: reduce)"),reducedTransparency:H("(prefers-reduced-transparency: reduce)"),saveData:e?.saveData===!0,dpr:Math.min(m&&window.devicePixelRatio||1,t)}}function N(t){if(!m||typeof matchMedia!="function")return()=>{};let e=["(prefers-reduced-motion: reduce)","(prefers-reduced-transparency: reduce)"].map(i=>matchMedia(i));return e.forEach(i=>i.addEventListener("change",t)),()=>e.forEach(i=>i.removeEventListener("change",t))}function L(t,e){let i,r=()=>{clearTimeout(i),i=setTimeout(t,e)};return r.cancel=()=>clearTimeout(i),r}var _,V,B=()=>_;function P(){return m?(V??=ce().then(t=>_={webgl:ae(),svg:t,svgStaticOnly:t&&de()}),V):Promise.resolve({webgl:!1,svg:!1,svgStaticOnly:!1})}function ae(){try{let t=document.createElement("canvas").getContext("webgl");if(!t)return!1;let e=t.createShader(t.FRAGMENT_SHADER);if(!e)return!1;t.shaderSource(e,"precision mediump float;void main(){gl_FragColor=vec4(1.);}"),t.compileShader(e);let i=t.getShaderParameter(e,t.COMPILE_STATUS)===!0;return t.deleteShader(e),t.getExtension("WEBGL_lose_context")?.loseContext(),i}catch{return!1}}var le='<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><filter id="p" color-interpolation-filters="sRGB"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/></filter><rect width="2" height="2" fill="#f00" filter="url(#p)"/></svg>';function ce(){return typeof CSS>"u"||!CSS.supports("filter","url(#a)")?Promise.resolve(!1):new Promise(t=>{let e=new Image;e.onload=()=>{try{let i=Object.assign(document.createElement("canvas"),{width:2,height:2}).getContext("2d");if(!i)return t(!1);i.drawImage(e,0,0);let[r,n]=i.getImageData(1,1,1,1).data;t(n>200&&r<50)}catch{t(!1)}},e.onerror=()=>t(!1),e.src=`data:image/svg+xml;charset=utf-8,${encodeURIComponent(le)}`})}var de=()=>typeof window.GestureEvent=="function"||"webkitSetPresentationMode"in HTMLVideoElement.prototype;function Q(){if(!m)return"mid";let t=navigator.hardwareConcurrency||4,e=navigator.deviceMemory??4,i=matchMedia("(pointer: coarse)").matches;return e<=2||t<=4&&i&&window.devicePixelRatio>=3?"low":i||t<=4?"mid":"high"}var W=t=>t==="high"?"high":t==="mid"?"balanced":"low";function $(t){let e=t.childElementCount===1?t.firstElementChild:null;return e instanceof HTMLVideoElement||e instanceof HTMLCanvasElement||e instanceof HTMLImageElement?e:null}var j=t=>(t.getAnimations?.({subtree:!0}).length??0)>0||t.querySelector("video, canvas, iframe")!==null;function A({requested:t,caps:e,device:i,saveData:r,hasTexture:n,animates:l}){if(r)return"css";let a=e.webgl&&n,s=e.svg&&!(e.svgStaticOnly&&l);return t==="webgl"&&a?"webgl":t==="svg"||t==="webgl"?s?"svg":"css":t==="css"?"css":a?"webgl":s&&i!=="low"?"svg":"css"}function U(t,e,i,r){return i&&t!=="low"?{quality:t==="high"?"balanced":"low",tier:e}:r&&e!=="css"?{quality:t,tier:e==="webgl"?"svg":"css"}:null}function K(t){let e=new Float64Array(240),i=0,r=0,n=0,l=requestAnimationFrame(function a(s){if(r?e[i++%e.length]=s-r:n=s,r=s,s-n<2e3){l=requestAnimationFrame(a);return}let o=e.subarray(0,Math.min(i,e.length)).sort();t(o.length>10&&o[o.length>>1]>22)});return()=>cancelAnimationFrame(l)}function J(t){t.classList.add("rcrt"),t.dataset.rcrtMode="mask";let e=t.querySelector(":scope > [data-rcrt-content]"),i=!e;e||(e=document.createElement("div"),e.append(...t.childNodes),t.appendChild(e)),e.classList.add("rcrt-content"),M&&queueMicrotask(()=>e&&he(e));let r=e;return{host:t,content:e,detach(){t.classList.remove("rcrt");for(let n of["rcrtMode","rcrtTier","rcrtStill","rcrtPaused","rcrtTuning"])delete t.dataset[n];for(let n of[...t.style])n.startsWith("--rcrt-")&&t.style.removeProperty(n);r.classList.remove("rcrt-content"),delete r.dataset.rcrtFiltered,i&&(t.append(...r.childNodes),r.remove())}}}function X(){let t=document.createElement("div");return t.className="rcrt",t.dataset.rcrtMode="viewport",t.setAttribute("aria-hidden","true"),document.body.appendChild(t),{host:t,content:null,detach:()=>t.remove()}}function he(t){let e=[];for(let i of t.querySelectorAll("*"))if(getComputedStyle(i).position==="fixed"&&(e.push(i.tagName.toLowerCase()+(i.id?`#${i.id}`:"")+(i.classList.length?`.${[...i.classList].join(".")}`:"")),e.length>=10))break;e.length&&u("fixed",`position: fixed descendants will anchor to the CRT wrapper, not the viewport: ${e.join(", ")}. Use CRT.mountFullPage() to wrap a whole page.`)}var z="http://www.w3.org/2000/svg",Y=0;function d(t,e,i){let r=document.createElementNS(z,t);r.setAttribute("color-interpolation-filters","sRGB");for(let[n,l]of Object.entries(e))r.setAttribute(n,String(l));return i.appendChild(r),r}var k=(t,e,i,r)=>{let n=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0];n[r*6]=1,d("feColorMatrix",{in:e,values:n.join(" "),result:i},t)},T=class{root;contentId=`rcrt-f${++Y}`;geometryId=`rcrt-g${Y}`;key="";constructor(e){this.root=document.createElementNS(z,"svg");for(let[i,r]of Object.entries({width:0,height:0,"aria-hidden":"true",focusable:"false"}))this.root.setAttribute(i,String(r));this.root.style.cssText="position:absolute;width:0;height:0;overflow:hidden;pointer-events:none",e.appendChild(this.root)}update({opts:e,width:i,height:r,bloomScale:n,curvature:l}){let a=l?x(e.curvature,i,r):0,s=a>0?F(i/Math.max(1,r)):"",o=e.bloom.enabled&&e.bloom.strength>0?e.bloom:null,c=JSON.stringify([s,Math.round(a),Math.round(i),Math.round(r),e.chromaticAberration.intensity,o,n]);if(c===this.key)return;this.key=c,this.root.replaceChildren();let p={width:Math.max(1,i),height:Math.max(1,r)};this.build(this.contentId,p,s,a,e.chromaticAberration.intensity,o&&{...o,radius:o.radius*n}),this.build(this.geometryId,p,s,a,0,null)}build(e,i,r,n,l,a){let s=d("filter",{id:e,x:0,y:0,width:1,height:1},this.root),o="SourceGraphic";if(r&&(d("feImage",{href:r,x:0,y:0,...i,preserveAspectRatio:"none",result:"map"},s),d("feDisplacementMap",{in:o,in2:"map",scale:n,xChannelSelector:"R",yChannelSelector:"G",result:"geo"},s),o="geo"),l>0&&(k(s,o,"r",0),k(s,o,"g",1),k(s,o,"b",2),d("feOffset",{in:"r",dx:l,result:"ro"},s),d("feOffset",{in:"b",dx:-l,result:"bo"},s),d("feComposite",{in:"ro",in2:"g",operator:"arithmetic",k2:1,k3:1,result:"rg"},s),d("feComposite",{in:"rg",in2:"bo",operator:"arithmetic",k2:1,k3:1,result:"ca"},s),o="ca"),a){let c=Math.min(a.threshold,.98),p=1/(1-c),f=d("feComponentTransfer",{in:o,result:"bright"},s);for(let g of["feFuncR","feFuncG","feFuncB"])d(g,{type:"linear",slope:p,intercept:-c*p},f);d("feGaussianBlur",{in:"bright",stdDeviation:Math.max(.5,a.radius/2),result:"glow"},s),d("feComposite",{in:o,in2:"glow",operator:"arithmetic",k2:1,k3:a.strength*1.6},s)}s.childElementCount||d("feMerge",{},s).appendChild(document.createElementNS(z,"feMergeNode"))}destroy(){this.root.remove()}};function Z(t,e,i){t.curvature&&i&&u("page-curvature","curvature is ignored by mountFullPage: the root box spans the whole document, not the viewport."),t.persistence?.strength&&e!=="webgl"&&u("persistence",`persistence needs frame history and is a no-op on the ${e} renderer (webgl only).`),t.chromaticAberration?.intensity&&e==="css"&&u("aberration","chromatic aberration needs per-channel filtering and is a no-op on the css renderer.")}var ee={css:0,svg:1,webgl:2},te,b=class{constructor(e,i={},r=!1){this.target=e;this.fullPage=r;this.input=i,this.opts=y(i),this.build(),this.observe(),this.render(),P().then(n=>{this.caps=n,this.render(),this.sample()})}target;fullPage;input={};opts=y();dom;layers=null;bezel;filters=null;gl=null;glFailed=!1;tier="css";quality="balanced";qualityCap=null;tierCap="webgl";caps=B()??{webgl:!1,svg:!1,svgStaticOnly:!1};device=Q();size={width:0,height:0};inView=!0;listeners=new Set;cleanups=[];stopSampling;destroyed=!1;notified="";settle=L(()=>delete this.dom.host.dataset.rcrtTuning,400);get activeRenderer(){return this.tier}get activeQuality(){return this.quality}update(e){this.set(ue(this.input,e))}set(e){this.destroyed||(this.input=e,this.opts=y(e),this.dom.host.dataset.rcrtTuning="",this.settle(),this.render())}subscribe(e){return this.listeners.add(e),()=>this.listeners.delete(e)}destroy(){this.destroyed||(this.destroyed=!0,this.cleanups.forEach(e=>e()),this.settle.cancel(),this.stopSampling?.(),this.teardown(),this.listeners.clear())}build(){this.dom=this.fullPage?X():J(this.target),this.bezel=new C(this.dom.host),this.resizeObserver?.observe(this.dom.host)}teardown(){this.resizeObserver?.unobserve(this.dom.host),this.setTier(null),this.bezel.destroy(),this.fullPage&&document.documentElement.style.removeProperty("filter"),this.dom.detach()}resizeObserver;observe(){let e=L(()=>this.render(),100);this.resizeObserver=new ResizeObserver(([a])=>{let{width:s,height:o}=a.contentRect,c=this.size.width===0;if(this.size={width:s,height:o},c)return this.render();this.gl?.resize(s,o),e()}),this.resizeObserver.observe(this.dom.host);let i=new IntersectionObserver(([a])=>{this.inView=a.isIntersecting,this.syncRunning()});i.observe(this.target);let r=()=>this.syncRunning();document.addEventListener("visibilitychange",r);let n=()=>this.tier==="svg"&&this.caps.svgStaticOnly&&this.render();this.target.addEventListener("animationstart",n),this.target.addEventListener("transitionstart",n);let l=N(()=>this.render());this.cleanups.push(()=>this.resizeObserver?.disconnect(),()=>i.disconnect(),e.cancel,()=>document.removeEventListener("visibilitychange",r),()=>this.target.removeEventListener("animationstart",n),()=>this.target.removeEventListener("transitionstart",n),l)}get running(){return!document.hidden&&(this.inView||!this.opts.pauseWhenOffscreen)}syncRunning(){this.dom.host.toggleAttribute("data-rcrt-paused",!this.running),this.gl?.setRunning(this.running)}pickTier(e){let i=this.dom.content,r={caps:{...this.caps,webgl:this.caps.webgl&&!this.glFailed},device:this.device,saveData:e.saveData&&this.opts.respectSaveData,hasTexture:!!i&&!!$(i),animates:i?j(i):!0},n=A({...r,requested:this.opts.renderer});return ee[n]>ee[this.tierCap]?A({...r,requested:this.tierCap}):n}render(){if(this.destroyed)return;let e=O(this.opts.maxPixelRatio),i=pe(this.opts,e),r=this.opts.quality==="auto"?W(this.device):this.opts.quality;this.quality=this.qualityCap&&w(this.qualityCap)<w(r)?this.qualityCap:r;let n=this.pickTier(e);this.setTier(n);let l=this.dom.host;l.toggleAttribute("data-rcrt-still",e.reducedMotion&&i.respectReducedMotion),this.syncRunning(),Z(this.input,this.tier,this.fullPage);let a=!this.fullPage&&i.curvature>0;v(l,{background:h(E(i.background))}),i.bezel.enabled||v(l,{"screen-radius":a?`${Math.round(i.curvature*28)}px`:"0px"}),this.bezel.update(i,e.reducedTransparency);let{width:s,height:o}=this.size,c=a&&this.tier==="svg"?D(x(i.curvature,s,o),s,o):{x:0,y:0},p={tier:this.tier,quality:this.quality,dpr:e.dpr,viewport:this.fullPage,pull:c};if(this.layers?.update(i,p),this.filters){this.filters.update({opts:i,width:s,height:o,bloomScale:w(this.quality),curvature:a});let g=this.fullPage?document.documentElement:null;g?g.style.filter=`url(#${this.filters.contentId})`:this.dom.content&&(this.dom.content.dataset.rcrtFiltered="",v(l,{"content-filter":`url(#${this.filters.contentId})`,"fx-filter":`url(#${this.filters.geometryId})`}),this.layers?.root.toggleAttribute("data-rcrt-filtered",a))}this.gl?.update({opts:i,quality:this.quality,scale:e.dpr*w(this.quality),motion:!l.hasAttribute("data-rcrt-still")},s,o);let f=[this.tier,this.quality].join();f!==this.notified&&(this.notified=f,this.listeners.forEach(g=>g(this)))}setTier(e){let i=this.dom.host,r=e==="svg"||e==="css"||e==="webgl"&&!this.gl;r&&!this.layers&&(this.layers=new I(i)),!r&&this.layers&&(this.layers=(this.layers.destroy(),null)),e==="svg"&&!this.filters&&(this.filters=new T(i)),e!=="svg"&&this.filters&&(this.filters=(this.filters.destroy(),null),this.dom.content&&delete this.dom.content.dataset.rcrtFiltered,this.fullPage&&document.documentElement.style.removeProperty("filter")),e!=="webgl"&&this.gl&&(this.gl=(this.gl.destroy(),null)),e==="webgl"&&!this.gl&&this.loadWebgl(),this.tier=e==="webgl"&&!this.gl?"css":e??"css",e&&(i.dataset.rcrtTier=this.tier)}loadWebgl(){let e=this.dom.content&&$(this.dom.content);e&&(te??=import("./webgl.js"),te.then(({WebGLTier:i})=>{if(!(this.destroyed||this.gl||this.pickTier(O(this.opts.maxPixelRatio))!=="webgl")){try{this.gl=new i(this.dom.host,e)}catch{this.glFailed=!0}this.render()}}))}sample(){let e=this.opts.quality==="auto",i=this.opts.renderer==="auto";!e&&!i||!this.running||this.destroyed||(this.stopSampling=K(r=>{if(!r||this.destroyed)return;let n=U(this.quality,this.tier,e,i);n&&(n.tier!==this.tier&&(this.tierCap=n.tier,u(`downgrade-${n.tier}`,`frame budget missed; dropping to the ${n.tier} renderer.`)),this.qualityCap=n.quality,this.render(),this.sample())}))}};function pe(t,e){let i=e.reducedMotion&&t.respectReducedMotion,r=e.reducedTransparency?.5:1;return{...t,scanlines:{...t.scanlines,flicker:t.scanlines.flicker&&!i},persistence:{strength:i?0:t.persistence.strength},tint:{...t.tint,strength:t.tint.strength*r},bloom:{...t.bloom,strength:t.bloom.strength*r}}}var ue=(t,e)=>({...t,...e,scanlines:{...t.scanlines,...e.scanlines},tint:{...t.tint,...e.tint},bloom:{...t.bloom,...e.bloom},chromaticAberration:{...t.chromaticAberration,...e.chromaticAberration},noise:{...t.noise,...e.noise},persistence:{...t.persistence,...e.persistence},bezel:{...t.bezel,...e.bezel,radius:{...t.bezel?.radius,...e.bezel?.radius}}});var me=t=>{let e=typeof t=="string"?document.querySelector(t):t;if(!e)throw new Error(`[retro-crt] mount target not found: ${String(t)}`);return e},_e={mount:(t,e)=>new b(me(t),e),mountFullPage:t=>new b(document.documentElement,t,!0)};export{_e as CRT,b as CRTScreen,ie as DEFAULTS,R as TINT_COLORS,S as bezelPalette,re as minimalOptions,E as parseColor,P as probeCapabilities,y as resolveOptions,h as rgbCss,ne as scanlineGeometry};
@@ -0,0 +1,70 @@
1
+ type Renderer = 'webgl' | 'svg' | 'css';
2
+ type Quality = 'high' | 'balanced' | 'low';
3
+ type TintPreset = 'default' | 'green' | 'amber' | 'blue' | 'violet' | 'lime' | 'pink' | 'red';
4
+ interface CRTOptions {
5
+ renderer?: 'auto' | Renderer;
6
+ /** The glass behind the content. Any CSS hex or rgb() string; anything else falls back to black. */
7
+ background?: string;
8
+ curvature?: number;
9
+ vignette?: number;
10
+ scanlines?: {
11
+ intensity?: number;
12
+ gap?: number;
13
+ flicker?: boolean;
14
+ };
15
+ tint?: {
16
+ preset?: TintPreset;
17
+ strength?: number;
18
+ };
19
+ bloom?: {
20
+ enabled?: boolean;
21
+ radius?: number;
22
+ strength?: number;
23
+ threshold?: number;
24
+ };
25
+ chromaticAberration?: {
26
+ intensity?: number;
27
+ };
28
+ noise?: {
29
+ enabled?: boolean;
30
+ static?: number;
31
+ };
32
+ persistence?: {
33
+ strength?: number;
34
+ };
35
+ bezel?: {
36
+ enabled?: boolean;
37
+ thickness?: number;
38
+ radius?: {
39
+ outer?: number;
40
+ screen?: number;
41
+ };
42
+ color?: string;
43
+ bevel?: number;
44
+ lightAngle?: number;
45
+ innerLip?: number;
46
+ screenSpill?: number;
47
+ glare?: number;
48
+ shadow?: boolean;
49
+ };
50
+ quality?: 'auto' | Quality;
51
+ maxPixelRatio?: number;
52
+ pauseWhenOffscreen?: boolean;
53
+ respectReducedMotion?: boolean;
54
+ respectSaveData?: boolean;
55
+ }
56
+ type DeepRequired<T> = {
57
+ [K in keyof T]-?: T[K] extends object | undefined ? DeepRequired<NonNullable<T[K]>> : T[K];
58
+ };
59
+ type ResolvedOptions = DeepRequired<CRTOptions>;
60
+ declare const DEFAULTS: ResolvedOptions;
61
+ /** `null` for `default`: a true pass-through, never a neutral grey (§4.2). */
62
+ declare const TINT_COLORS: Record<TintPreset, readonly [number, number, number] | null>;
63
+ declare const resolveOptions: (input?: CRTOptions) => ResolvedOptions;
64
+ /**
65
+ * Only the keys that differ from the defaults — the user's intent, not the schema (§8.1).
66
+ * A disabled bezel contributes nothing at all (§4.3).
67
+ */
68
+ declare function minimalOptions(input: CRTOptions): CRTOptions;
69
+
70
+ export { type CRTOptions as C, DEFAULTS as D, type Quality as Q, type Renderer as R, TINT_COLORS as T, type ResolvedOptions as a, type TintPreset as b, minimalOptions as m, resolveOptions as r };
@@ -0,0 +1,229 @@
1
+ /* retro-crt — every animated layer composites on transform/opacity only (§10). */
2
+
3
+ .rcrt {
4
+ --rcrt-inset: 0px;
5
+ --rcrt-screen-radius: 0px;
6
+ position: relative;
7
+ isolation: isolate;
8
+ box-sizing: border-box;
9
+ /* The glass is opaque: curvature pulls the picture inward and grain blends over what it exposes. */
10
+ background: var(--rcrt-background, #000);
11
+ }
12
+
13
+ .rcrt[data-rcrt-bezel] {
14
+ padding: var(--rcrt-inset);
15
+ border-radius: var(--rcrt-outer-radius);
16
+ background: var(--rcrt-housing);
17
+ box-shadow: var(--rcrt-bezel-shadow);
18
+ }
19
+
20
+ .rcrt-content {
21
+ position: relative;
22
+ z-index: 1;
23
+ height: 100%;
24
+ border-radius: var(--rcrt-screen-radius);
25
+ overflow: hidden;
26
+ background: var(--rcrt-background, #000);
27
+ }
28
+
29
+ .rcrt[data-rcrt-tier='svg'] .rcrt-content[data-rcrt-filtered] {
30
+ filter: var(--rcrt-content-filter);
31
+ }
32
+
33
+ .rcrt-fx,
34
+ .rcrt-bevel,
35
+ .rcrt-spill,
36
+ .rcrt-lip,
37
+ .rcrt-glare,
38
+ .rcrt-gl {
39
+ position: absolute;
40
+ pointer-events: none;
41
+ }
42
+
43
+ .rcrt-fx,
44
+ .rcrt-spill,
45
+ .rcrt-lip,
46
+ .rcrt-glare,
47
+ .rcrt-gl {
48
+ inset: var(--rcrt-inset);
49
+ border-radius: var(--rcrt-screen-radius);
50
+ }
51
+
52
+ .rcrt-fx {
53
+ z-index: 2;
54
+ overflow: hidden;
55
+ }
56
+
57
+ /*
58
+ * Blend the whole grain group against the picture; a blend mode on the grain itself would only see
59
+ * its own transparent group. This container is deliberately unfiltered (animating inside a filter
60
+ * re-rasterises it every step), so it is inset by hand to the area the barrel leaves covered.
61
+ */
62
+ .rcrt-fx-motion {
63
+ mix-blend-mode: overlay;
64
+ inset: calc(var(--rcrt-inset) + var(--rcrt-curve-y, 0px)) calc(var(--rcrt-inset) + var(--rcrt-curve-x, 0px));
65
+ border-radius: calc(var(--rcrt-screen-radius) + var(--rcrt-curve-x, 0px));
66
+ }
67
+
68
+ .rcrt-fx[data-rcrt-filtered] {
69
+ filter: var(--rcrt-fx-filter);
70
+ }
71
+
72
+ .rcrt-gl {
73
+ z-index: 1;
74
+ width: calc(100% - 2 * var(--rcrt-inset));
75
+ height: calc(100% - 2 * var(--rcrt-inset));
76
+ }
77
+
78
+ .rcrt[data-rcrt-tier='webgl'] .rcrt-content > * {
79
+ opacity: 0;
80
+ }
81
+
82
+ .rcrt-fx > * {
83
+ position: absolute;
84
+ inset: 0;
85
+ }
86
+
87
+ .rcrt-bloom {
88
+ z-index: 0;
89
+ -webkit-backdrop-filter: var(--rcrt-bloom-filter);
90
+ backdrop-filter: var(--rcrt-bloom-filter);
91
+ opacity: var(--rcrt-bloom-alpha);
92
+ }
93
+
94
+ .rcrt-tint {
95
+ z-index: 1;
96
+ background: var(--rcrt-tint);
97
+ mix-blend-mode: color;
98
+ opacity: var(--rcrt-tint-alpha);
99
+ }
100
+
101
+ .rcrt-scanlines {
102
+ z-index: 2;
103
+ /* A ramp, not hard bands: sharp edges beat against the pixel grid into moire at fractional gaps. */
104
+ background: repeating-linear-gradient(
105
+ to bottom,
106
+ rgb(0 0 0 / var(--rcrt-scan-alpha)) 0,
107
+ transparent var(--rcrt-scan-line),
108
+ transparent calc(var(--rcrt-scan-gap) - var(--rcrt-scan-line)),
109
+ rgb(0 0 0 / var(--rcrt-scan-alpha)) var(--rcrt-scan-gap)
110
+ );
111
+ }
112
+
113
+ .rcrt-noise {
114
+ z-index: 3;
115
+ inset: -50%;
116
+ background-image: var(--rcrt-noise-url);
117
+ opacity: var(--rcrt-noise-alpha);
118
+ animation: rcrt-noise-8 0.4s steps(1) infinite;
119
+ }
120
+
121
+ .rcrt-noise[data-frames='6'] { animation-name: rcrt-noise-6; }
122
+ .rcrt-noise[data-frames='4'] { animation-name: rcrt-noise-4; }
123
+
124
+ .rcrt-curve {
125
+ z-index: 4;
126
+ border-radius: inherit;
127
+ box-shadow: inset 0 0 var(--rcrt-edge-blur) var(--rcrt-edge-spread) rgb(0 0 0 / 0.75);
128
+ }
129
+
130
+ .rcrt-vignette {
131
+ z-index: 5;
132
+ background: radial-gradient(ellipse at center, transparent 45%, rgb(0 0 0 / var(--rcrt-vignette)) 100%);
133
+ }
134
+
135
+ .rcrt-flicker {
136
+ z-index: 6;
137
+ background: #000;
138
+ opacity: 0;
139
+ animation: rcrt-flicker 3.1s steps(1) infinite;
140
+ }
141
+
142
+ .rcrt[data-rcrt-still] .rcrt-noise,
143
+ .rcrt[data-rcrt-still] .rcrt-flicker {
144
+ animation: none;
145
+ }
146
+
147
+ .rcrt[data-rcrt-paused] .rcrt-noise,
148
+ .rcrt[data-rcrt-paused] .rcrt-flicker {
149
+ animation-play-state: paused;
150
+ }
151
+
152
+ .rcrt[data-rcrt-tuning] .rcrt-fx,
153
+ .rcrt[data-rcrt-tuning] .rcrt-content {
154
+ will-change: filter;
155
+ }
156
+
157
+ /* bezel — housing and shadow are on .rcrt itself */
158
+ .rcrt-bevel {
159
+ inset: 0;
160
+ z-index: 0;
161
+ border-radius: var(--rcrt-outer-radius);
162
+ background: linear-gradient(var(--rcrt-bevel-angle), var(--rcrt-bevel-hi), transparent 45%, transparent 55%, var(--rcrt-bevel-lo));
163
+ box-shadow: var(--rcrt-bevel-edge);
164
+ }
165
+
166
+ .rcrt-spill { z-index: 0; box-shadow: var(--rcrt-spill); }
167
+ .rcrt-lip { z-index: 3; box-shadow: var(--rcrt-lip-shadow); }
168
+
169
+ .rcrt-glare {
170
+ z-index: 4;
171
+ background: linear-gradient(135deg, rgb(255 255 255 / var(--rcrt-glare)) 0%, rgb(255 255 255 / calc(var(--rcrt-glare) * 0.3)) 22%, transparent 42%);
172
+ }
173
+
174
+ @media (prefers-reduced-transparency: reduce) {
175
+ .rcrt-spill { display: none; }
176
+ }
177
+
178
+ /* mountFullPage: effect layers pinned to the viewport; the filter itself sits on <html> */
179
+ .rcrt[data-rcrt-mode='viewport'] {
180
+ position: fixed;
181
+ inset: 0;
182
+ z-index: 2147483000;
183
+ pointer-events: none;
184
+ padding: 0;
185
+ background: none;
186
+ box-shadow: none;
187
+ border-radius: 0;
188
+ }
189
+
190
+ /* Offsets stay within ±25% of the 200% grain layer so it always covers the screen. */
191
+ @keyframes rcrt-noise-8 {
192
+ 0% { transform: translate(0, 0); }
193
+ 12.5% { transform: translate(-14%, 7%); }
194
+ 25% { transform: translate(9%, -18%); }
195
+ 37.5% { transform: translate(-25%, -6%); }
196
+ 50% { transform: translate(18%, 15%); }
197
+ 62.5% { transform: translate(-6%, 23%); }
198
+ 75% { transform: translate(23%, -10%); }
199
+ 87.5% { transform: translate(-18%, -23%); }
200
+ }
201
+
202
+ @keyframes rcrt-noise-6 {
203
+ 0% { transform: translate(0, 0); }
204
+ 16.6% { transform: translate(-15%, 9%); }
205
+ 33.3% { transform: translate(10%, -20%); }
206
+ 50% { transform: translate(-23%, -7%); }
207
+ 66.6% { transform: translate(17%, 18%); }
208
+ 83.3% { transform: translate(-7%, -25%); }
209
+ }
210
+
211
+ @keyframes rcrt-noise-4 {
212
+ 0% { transform: translate(0, 0); }
213
+ 25% { transform: translate(-17%, 10%); }
214
+ 50% { transform: translate(14%, -22%); }
215
+ 75% { transform: translate(-22%, -9%); }
216
+ }
217
+
218
+ @keyframes rcrt-flicker {
219
+ 0% { opacity: 0; }
220
+ 7% { opacity: 0.06; }
221
+ 8% { opacity: 0; }
222
+ 31% { opacity: 0.03; }
223
+ 33% { opacity: 0; }
224
+ 58% { opacity: 0.08; }
225
+ 59% { opacity: 0.02; }
226
+ 61% { opacity: 0; }
227
+ 84% { opacity: 0.04; }
228
+ 86% { opacity: 0; }
229
+ }
@@ -0,0 +1,76 @@
1
+ import { a as ResolvedOptions, Q as Quality } from './options-DWrUxuy7.js';
2
+
3
+ type TextureSource = TexImageSource;
4
+ interface FrameParams {
5
+ opts: ResolvedOptions;
6
+ quality: Quality;
7
+ /** CSS-px → buffer-px factor, so gap and aberration stay in CSS px at any DPR and quality. */
8
+ scale: number;
9
+ motion: boolean;
10
+ }
11
+ /**
12
+ * Standalone WebGL CRT pass. Used by the `webgl` tier and directly by exporters that need the
13
+ * effect burnt into pixels. All GPU objects are created up front: no per-frame allocation (§10).
14
+ */
15
+ declare class GLRenderer {
16
+ readonly canvas: HTMLCanvasElement;
17
+ readonly gl: WebGLRenderingContext;
18
+ private effect;
19
+ private copy;
20
+ private source;
21
+ private blank;
22
+ private targets;
23
+ private front;
24
+ private persist;
25
+ private uniforms;
26
+ constructor(canvas: HTMLCanvasElement, preserveDrawingBuffer?: boolean);
27
+ private program;
28
+ private texture;
29
+ /** Reallocates buffers only when the pixel size actually changes. */
30
+ resize(width: number, height: number): void;
31
+ private effectProgram;
32
+ private set;
33
+ /** Uploads every uniform except time. Call when options change, not per frame. */
34
+ configure({ opts: o, quality, scale, motion }: FrameParams): void;
35
+ /**
36
+ * How the source fills the buffer, in the spirit of CSS object-fit. Source and target sizes are
37
+ * in any consistent unit; only their aspect ratios matter.
38
+ */
39
+ setFit(fit: 'fill' | 'cover' | 'contain', sourceW: number, sourceH: number): void;
40
+ render(source: TextureSource, time: number): void;
41
+ private present;
42
+ destroy(): void;
43
+ }
44
+
45
+ type Source = HTMLVideoElement | HTMLCanvasElement | HTMLImageElement;
46
+ /**
47
+ * Drives a GLRenderer over a live source inside a mounted screen. The source stays in the DOM
48
+ * (transparent) so layout, media playback and resize observation behave exactly as unwrapped.
49
+ */
50
+ declare class WebGLTier {
51
+ private source;
52
+ readonly canvas: HTMLCanvasElement;
53
+ private renderer;
54
+ private frame;
55
+ private params;
56
+ private running;
57
+ private readonly start;
58
+ private readonly redraw;
59
+ private readonly refit;
60
+ constructor(host: HTMLElement, source: Source);
61
+ /** Continuous animation is needed only if something on screen changes without our input. */
62
+ private get animated();
63
+ update(params: FrameParams, width: number, height: number): void;
64
+ /**
65
+ * Buffer-only resize, cheap enough to run on every frame of an animating box. Without it the
66
+ * drawing buffer keeps the old aspect ratio while CSS stretches it, and the picture visibly
67
+ * skews until the debounced full render catches up.
68
+ */
69
+ resize(width: number, height: number): void;
70
+ private fit;
71
+ setRunning(running: boolean): void;
72
+ private draw;
73
+ destroy(): void;
74
+ }
75
+
76
+ export { type FrameParams, GLRenderer, WebGLTier };
package/dist/webgl.js ADDED
@@ -0,0 +1,78 @@
1
+ import{a as f,g as h,n as d}from"./chunk-VWPH2GPA.js";var g=`
2
+ attribute vec2 aPos;
3
+ varying vec2 vUv;
4
+ void main() {
5
+ vUv = aPos * 0.5 + 0.5;
6
+ gl_Position = vec4(aPos, 0.0, 1.0);
7
+ }`,p=`
8
+ precision mediump float;
9
+ varying vec2 vUv;
10
+ uniform sampler2D uTex;
11
+ void main() { gl_FragColor = texture2D(uTex, vUv); }`,v=u=>`
12
+ precision mediump float;
13
+ #define TAPS ${u}
14
+ varying vec2 vUv;
15
+ uniform sampler2D uTex;
16
+ uniform sampler2D uPrev;
17
+ uniform vec2 uRes;
18
+ uniform float uTime, uCurv, uVig, uScanA, uScanGap, uTintA, uAberr, uNoise, uPersist, uFlicker;
19
+ uniform vec3 uTint, uBloom, uBg;
20
+ uniform vec4 uCrop;
21
+
22
+ float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
23
+
24
+ // uCrop maps screen uv into source uv, which is how object-fit cover/contain/fill is honoured.
25
+ // The k factor tapers the split to nothing at the picture edge; without it the clamped R and B samples
26
+ // smear the outermost column into a red and blue outline around the whole screen.
27
+ vec3 sampleAberr(vec2 uv, float k) {
28
+ vec2 f = vec2(uAberr / uRes.x * uCrop.z, 0.0) * k;
29
+ return vec3(texture2D(uTex, uv - f).r, texture2D(uTex, uv).g, texture2D(uTex, uv + f).b);
30
+ }
31
+
32
+ // Distance from this pixel to the nearest edge of the unit square, in uv units.
33
+ float inset(vec2 p) {
34
+ vec2 d = min(p, 1.0 - p);
35
+ return min(d.x, d.y);
36
+ }
37
+
38
+ void main() {
39
+ vec2 c = vUv * 2.0 - 1.0;
40
+ vec2 screen = c * (1.0 + uCurv * dot(c, c)) * 0.5 + 0.5;
41
+ vec2 uv = uCrop.xy + screen * uCrop.zw;
42
+ // ~2.5 buffer px of feather: enough to read as a smooth curve at any DPR, still a crisp edge.
43
+ float px = 2.5 / min(uRes.x, uRes.y);
44
+ float near = min(inset(screen), inset(uv));
45
+ float edge = smoothstep(0.0, px, near);
46
+ if (edge <= 0.0) { gl_FragColor = vec4(uBg, 1.0); return; }
47
+ float split = clamp(near * uRes.x / max(uAberr * uCrop.z, 0.001), 0.0, 1.0);
48
+ uv = clamp(uv, 0.0, 1.0);
49
+
50
+ vec3 col = sampleAberr(uv, split);
51
+
52
+ if (uBloom.y > 0.0) {
53
+ vec3 glow = vec3(0.0);
54
+ for (int i = 0; i < TAPS; i++) {
55
+ float a = float(i) * 2.39996;
56
+ float r = sqrt((float(i) + 0.5) / float(TAPS)) * uBloom.x;
57
+ vec3 s = texture2D(uTex, uv + vec2(cos(a), sin(a)) * r / uRes * uCrop.zw).rgb;
58
+ glow += max(s - uBloom.z, 0.0) / max(1.0 - uBloom.z, 0.02);
59
+ }
60
+ col += glow / float(TAPS) * uBloom.y * 1.6;
61
+ }
62
+
63
+ if (uTintA > 0.0) {
64
+ vec3 w = vec3(0.299, 0.587, 0.114);
65
+ vec3 tinted = clamp(uTint * dot(col, w) / max(dot(uTint, w), 0.001), 0.0, 1.0);
66
+ col = mix(col, tinted, uTintA);
67
+ }
68
+
69
+ // A cosine profile rather than hard bands: at any gap and DPR it cannot alias into moire.
70
+ col *= 1.0 - uScanA * (0.5 + 0.5 * cos(6.2831853 * gl_FragCoord.y / max(uScanGap, 2.0)));
71
+ col += (hash(gl_FragCoord.xy + fract(uTime) * 97.0) - 0.5) * uNoise;
72
+ col *= 1.0 - uFlicker * 0.07 * step(0.93, hash(vec2(floor(uTime * 24.0), 3.0)));
73
+ col *= 1.0 - uVig * clamp((length(c) / 1.4142 - 0.45) / 0.55, 0.0, 1.0);
74
+
75
+ col = mix(uBg, col, edge);
76
+ vec3 prev = texture2D(uPrev, vUv).rgb;
77
+ gl_FragColor = vec4(max(col, prev * uPersist), 1.0);
78
+ }`;var m={high:24,balanced:12,low:6},c=class{constructor(t,r=!1){this.canvas=t;let e=t.getContext("webgl",{alpha:!1,antialias:!1,preserveDrawingBuffer:r});if(!e)throw new Error("WebGL unavailable");this.gl=e;let a=e.createBuffer();e.bindBuffer(e.ARRAY_BUFFER,a),e.bufferData(e.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),e.STATIC_DRAW),this.copy=this.program(p),e.useProgram(this.copy),e.uniform1i(e.getUniformLocation(this.copy,"uTex"),1),this.source=this.texture(),this.blank=this.texture(),e.texImage2D(e.TEXTURE_2D,0,e.RGBA,1,1,0,e.RGBA,e.UNSIGNED_BYTE,new Uint8Array(4))}canvas;gl;effect=null;copy;source;blank;targets=[];front=0;persist=0;uniforms=new Map;program(t){let r=this.gl,e=r.createProgram();for(let[a,i]of[[r.VERTEX_SHADER,g],[r.FRAGMENT_SHADER,t]]){let o=r.createShader(a);if(!o)throw new Error("shader allocation failed");if(r.shaderSource(o,i),r.compileShader(o),!r.getShaderParameter(o,r.COMPILE_STATUS))throw new Error(r.getShaderInfoLog(o)??"shader error");r.attachShader(e,o)}if(r.bindAttribLocation(e,0,"aPos"),r.linkProgram(e),!r.getProgramParameter(e,r.LINK_STATUS))throw new Error(r.getProgramInfoLog(e)??"link error");return e}texture(){let t=this.gl,r=t.createTexture();t.bindTexture(t.TEXTURE_2D,r);for(let[e,a]of[[t.TEXTURE_MIN_FILTER,t.LINEAR],[t.TEXTURE_MAG_FILTER,t.LINEAR],[t.TEXTURE_WRAP_S,t.CLAMP_TO_EDGE],[t.TEXTURE_WRAP_T,t.CLAMP_TO_EDGE]])t.texParameteri(t.TEXTURE_2D,e,a);return r}resize(t,r){let e=Math.max(1,Math.round(t)),a=Math.max(1,Math.round(r));if(this.canvas.width===e&&this.canvas.height===a&&this.targets.length)return;Object.assign(this.canvas,{width:e,height:a});let i=this.gl;this.targets.forEach(({tex:o,fb:n})=>(i.deleteTexture(o),i.deleteFramebuffer(n))),this.targets=[0,1].map(()=>{let o=this.texture();i.texImage2D(i.TEXTURE_2D,0,i.RGBA,e,a,0,i.RGBA,i.UNSIGNED_BYTE,null);let n=i.createFramebuffer();return i.bindFramebuffer(i.FRAMEBUFFER,n),i.framebufferTexture2D(i.FRAMEBUFFER,i.COLOR_ATTACHMENT0,i.TEXTURE_2D,o,0),i.clearColor(0,0,0,1),i.clear(i.COLOR_BUFFER_BIT),{tex:o,fb:n}}),i.bindFramebuffer(i.FRAMEBUFFER,null)}effectProgram(t){let r=this.gl;if(this.effect?.taps===m[t])return this.effect.program;this.effect&&r.deleteProgram(this.effect.program);let e=this.program(v(m[t]));return this.effect={program:e,taps:m[t]},this.uniforms.clear(),r.useProgram(e),r.uniform4f(r.getUniformLocation(e,"uCrop"),0,0,1,1),r.uniform1i(r.getUniformLocation(e,"uTex"),0),r.uniform1i(r.getUniformLocation(e,"uPrev"),1),e}set(t,r,...e){let a=this.gl,i=this.uniforms.get(r);i===void 0&&this.uniforms.set(r,i=a.getUniformLocation(t,r)),e.length===1?a.uniform1f(i,e[0]):e.length===2?a.uniform2f(i,e[0],e[1]):a.uniform3f(i,e[0],e[1],e[2])}configure({opts:t,quality:r,scale:e,motion:a}){let i=this.gl,o=this.effectProgram(r),n=h[t.tint.preset]??[0,0,0],s=(x,...R)=>this.set(o,x,...R);this.persist=a?t.persistence.strength*1.8:0,i.useProgram(o),s("uCurv",t.curvature*.12),s("uVig",t.vignette*.85),s("uScanA",t.scanlines.intensity*.75),s("uScanGap",d(t.scanlines.gap,e).gap*e),s("uTint",n[0]/255,n[1]/255,n[2]/255);let l=f(t.background);s("uBg",l[0]/255,l[1]/255,l[2]/255),s("uTintA",t.tint.preset==="default"?0:t.tint.strength*.85),s("uBloom",t.bloom.radius*e,t.bloom.enabled?t.bloom.strength:0,t.bloom.threshold),s("uAberr",t.chromaticAberration.intensity*e),s("uNoise",t.noise.enabled?t.noise.static*1.5:0),s("uPersist",this.persist),s("uFlicker",a&&t.scanlines.flicker&&r!=="low"?1:0)}setFit(t,r,e){let a=this.effect?.program;if(!a||!r||!e)return;let i=r/e/(this.canvas.width/this.canvas.height),[o,n]=[1,1];t==="cover"&&(i>1?o=1/i:n=i),t==="contain"&&(i>1?n=i:o=1/i),this.gl.useProgram(a),this.gl.uniform4f(this.gl.getUniformLocation(a,"uCrop"),(1-o)/2,(1-n)/2,o,n)}render(t,r){let e=this.gl,a=this.effect?.program;if(!a)return;let{width:i,height:o}=this.canvas;this.targets.length||this.resize(i,o),e.pixelStorei(e.UNPACK_FLIP_Y_WEBGL,!0),e.activeTexture(e.TEXTURE0),e.bindTexture(e.TEXTURE_2D,this.source),e.texImage2D(e.TEXTURE_2D,0,e.RGBA,e.RGBA,e.UNSIGNED_BYTE,t),e.useProgram(a),e.enableVertexAttribArray(0),e.vertexAttribPointer(0,2,e.FLOAT,!1,0,0),this.set(a,"uRes",i,o),this.set(a,"uTime",r);let n=this.persist>0,s=this.targets[this.front];e.activeTexture(e.TEXTURE1),e.bindTexture(e.TEXTURE_2D,n?this.targets[1-this.front].tex:this.blank),e.bindFramebuffer(e.FRAMEBUFFER,n?s.fb:null),e.viewport(0,0,i,o),e.drawArrays(e.TRIANGLE_STRIP,0,4),n&&this.present(s.tex),this.front=1-this.front}present(t){let r=this.gl;r.bindFramebuffer(r.FRAMEBUFFER,null),r.useProgram(this.copy),r.activeTexture(r.TEXTURE1),r.bindTexture(r.TEXTURE_2D,t),r.drawArrays(r.TRIANGLE_STRIP,0,4)}destroy(){this.gl.getExtension("WEBGL_lose_context")?.loseContext()}};var T=["seeked","pause","play"],E=["load","loadedmetadata","loadeddata","resize"],A=u=>u instanceof HTMLVideoElement?u.readyState>=2:u instanceof HTMLImageElement?u.complete&&u.naturalWidth>0:u.width>0,b=class{constructor(t,r){this.source=r;this.canvas.className="rcrt-gl",this.canvas.setAttribute("aria-hidden","true"),this.renderer=new c(this.canvas),t.appendChild(this.canvas);for(let e of T)r.addEventListener(e,this.redraw);for(let e of E)r.addEventListener(e,this.refit)}source;canvas=document.createElement("canvas");renderer;frame=0;params=null;running=!1;start=performance.now();redraw=()=>this.draw();refit=()=>(this.fit(),this.draw());get animated(){let t=this.params;if(!t)return!1;let r=t.opts;return this.source instanceof HTMLCanvasElement||this.source instanceof HTMLVideoElement&&!this.source.paused||t.motion&&(r.persistence.strength>0||r.noise.enabled&&r.noise.static>0||r.scanlines.flicker)}update(t,r,e){this.params=t,this.renderer.resize(r*t.scale,e*t.scale),this.renderer.configure(t),this.fit(),this.draw()}resize(t,r){this.params&&(this.renderer.resize(t*this.params.scale,r*this.params.scale),this.fit(),this.draw())}fit(){let t=this.source,[r,e]=t instanceof HTMLVideoElement?[t.videoWidth,t.videoHeight]:t instanceof HTMLImageElement?[t.naturalWidth,t.naturalHeight]:[t.width,t.height],a=getComputedStyle(t).objectFit;this.renderer.setFit(a==="cover"||a==="contain"?a:"fill",r,e)}setRunning(t){this.running=t,this.draw()}draw(){cancelAnimationFrame(this.frame),this.params&&(A(this.source)&&this.renderer.render(this.source,this.params.motion?(performance.now()-this.start)/1e3:0),this.running&&this.animated&&(this.frame=requestAnimationFrame(this.redraw)))}destroy(){cancelAnimationFrame(this.frame);for(let t of T)this.source.removeEventListener(t,this.redraw);for(let t of E)this.source.removeEventListener(t,this.refit);this.renderer.destroy(),this.canvas.remove()}};export{c as GLRenderer,b as WebGLTier};
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "retro-crt",
3
+ "version": "1.0.0",
4
+ "description": "A CRT monitor effect that renders your content inside the screen. Framework-agnostic, zero dependencies.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": [
8
+ "*.css"
9
+ ],
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ },
20
+ "./webgl": {
21
+ "types": "./dist/webgl.d.ts",
22
+ "import": "./dist/webgl.js"
23
+ },
24
+ "./styles.css": "./dist/styles.css"
25
+ },
26
+ "scripts": {
27
+ "build": "tsup && node -e \"require('fs').copyFileSync('src/styles.css','dist/styles.css')\""
28
+ },
29
+ "keywords": [
30
+ "crt",
31
+ "retro",
32
+ "scanlines",
33
+ "webgl",
34
+ "svg-filter",
35
+ "effect"
36
+ ],
37
+ "author": "pixelyokai",
38
+ "homepage": "https://github.com/pixelyokai/retro-crt#readme",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/pixelyokai/retro-crt.git",
42
+ "directory": "packages/core"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/pixelyokai/retro-crt/issues"
46
+ }
47
+ }