safelight
v0.1.0
Published
Ordered-dither surfaces, grain effects, and duotone image pipelines for the web. The darkroom, as a library.
Maintainers
Readme
safelight
Ordered-dither surfaces, grain effects, and duotone image pipelines for the web. The darkroom, as a library.
Everything here obeys one law: a pixel flips when an exposure field e(t) crosses its fixed threshold T(x, y) in an 8×8 Bayer matrix. Images develop darkest-first from blank paper, hovers bloom outward as square grain, pages wipe cell-by-cell through view transitions, theme flips develop the new theme over the old — all from the same matrix, the same law, so every effect on a page reads as one material. Nothing wipes, slides, or fades.
Extracted from (and battle-tested on) shannadige.com, where every surface is a live demo.
Install
npm install safelightZero runtime dependencies. sharp is an optional peer dependency, needed only by the build-time CLI.
Setup
Safelight paints in exactly two colors — ink and paper — resolved from CSS custom properties on your root element and re-resolved whenever its class or data-theme attribute changes:
:root { --ink: #d63a00; --background: #f0eeeb; }
.dark { --ink: #f0521a; --background: #16110d; }import { configure, astroPreset } from 'safelight';
configure({
tokens: { ink: '--ink', paper: '--background' }, // these are the defaults
...astroPreset, // on Astro ClientRouter: teardown/reattach across page swaps
});On a plain multi-page site, configure() is optional. With any client-side router that swaps the DOM, set teardownEvents/reattachEvents to its lifecycle events (astroPreset covers Astro's ClientRouter).
Runtime behaviors
All canvas behaviors share one runtime contract: internal resolution upscaled with image-rendering: pixelated (grain stays square), rAF loops that pause offscreen via IntersectionObserver and settle to zero idle cost, ink/paper re-read on theme change, and a single static frame under prefers-reduced-motion.
Develop — images that develop like prints
import { createSurface, mountDevelop, holdOnPaper } from 'safelight';
const release = holdOnPaper(canvas); // rest on blank paper until the pass runs
const surface = await createSurface('/photo.jpg'); // precomputed buffers, one per image
release();
mountDevelop(canvas, surface, { settle: 'crisp' }); // or 'dither' to rest on grainInk condenses darkest-first from blank paper, then pixels resolve crisp behind a one-band ink frontier. A drop-in replacement for blur-up placeholders.
Bloom — hover reveal
import { mountBloom } from 'safelight';
const bloom = mountBloom(canvas, stage, surface, {
mask: true, // reveal the DOM beneath instead of the surface's own pixels
onSettled: (revealed) => revealed && video.play(),
});A spring-driven boundary expands from the cursor in Chebyshev distance, quantized to the cell grid — square and grid-aligned, never circular.
Ambience & Scanbed — idle fields
import { mountAmbience, mountScanbed, timeOfDayDensity } from 'safelight';
mountAmbience(canvas, { density: timeOfDayDensity() }); // two-sine interference cloud
mountAmbience(canvas, { pattern: 'specimen' }); // raster sweep over procedural noise
mountScanbed(canvas, { onFrame: ({ flare }) => syncDomLayers(flare) }); // photocopier passScreen & Plate — panel entrances
import { mountScreen, mountPlate, hidePlate, warmPlates } from 'safelight';
requestIdleCallback(() => warmPlates()); // pre-rasterize mask tiles at idle
hidePlate(panel); // before layout: panel rests masked-out
mountScreen(cover, { onComplete }); // canvas cover dissolves off the content
mountPlate(panel); // panel's own surface carves in with the same fieldTheme wash — light/dark developed, not faded
import { mountThemeWash } from 'safelight';
mountThemeWash(() => document.documentElement.classList.toggle('dark'));The theme flips on frame zero; a cover in the old theme's paper dissolves off it, with a frontier in the old theme's ink (override with frontier: [r, g, b]).
Dissolve — page transitions as a grain wipe
import { installDissolve } from 'safelight';
installDissolve({ pin: ['site-header'] }); // pin chrome identical on both pagesGenerated CSS over the View Transitions API — no per-navigation JS. Works with Astro's <ClientRouter /> or any document.startViewTransition flow.
Server-side: safelight/planes
import { ditherPlanes } from 'safelight/planes';
const rgb = ditherPlanes(tonePlane, maskPlane, { width, height, ink, paper, graphite });
// → Uint8Array of raw RGB, straight into sharp/canvasComposites two grayscale planes (coverage + ink selection) through the same Bayer matrix — for OG images that carry identical grain to the page they open.
Build-time CLI
npx safelight photo.jpg out.png --mode atkinson --width 900 --ink '#d63a00'
npx safelight --batch # reads safelight.config.json: themes × jobs → PNGs + manifest.jsonPre-renders 1-bit duotone placeholders (per theme) with atkinson, bayer, or scanline screening. The bayer mode uses the runtime's own 8×8 matrix, so pre-rendered placeholders and live passes land on identical grain. Config shape is documented in cli/duotone.mjs.
License
MIT © Shan Nadige
