habitat.css
v0.1.0
Published
Ambient-adapting CSS: eink and camo display modes driven by measured light and room color.
Maintainers
Readme
habitat.css
habitat (noun): the natural home or environment of an organism — the specific conditions of light, temperature, and surroundings it has adapted to live in.
habitat.css senses the light and color actually around you and lets your
interface adapt to that habitat, instead of assuming a fixed one. Two
modes, one sensor:
- Eink mode — a warm paper display whose tone tracks measured ambient brightness, not a fixed sepia filter.
- Camo mode — the page's palette is derived from the room's own dominant colors (median-cut on a blurred, immediately-discarded camera frame), with every color walked until it clears your chosen WCAG contrast target.
Either mode can also be forced toward a light or dark identity — see Color scheme below.
TypeScript-first, zero runtime dependencies, ships ESM/CJS/IIFE, works from npm, Deno, and the CDN of your choice.
Install
npm install habitat.css
pnpm add habitat.css
yarn add habitat.css
bun add habitat.css// Deno
import { HabitatSensor } from "npm:habitat.css";<!-- unpkg -->
<link rel="stylesheet" href="https://unpkg.com/habitat.css/dist/habitat.css">
<script src="https://unpkg.com/habitat.css/dist/habitat.global.js"></script>
<script>const sensor = new Habitat.HabitatSensor();</script>// esm.sh
import { HabitatSensor } from "https://esm.sh/habitat.css";Usage
<link rel="stylesheet" href="node_modules/habitat.css/dist/habitat.css">import { HabitatSensor, initHabitatBanner } from "habitat.css";
const sensor = new HabitatSensor();
const banner = initHabitatBanner(sensor); // consent UI: banner + status pill
document.documentElement.dataset.habitatMode = "eink"; // or "camo" / "off"
banner.request(); // shows the consent banner if a mode wants the cameraWrap the parts of the page eink's filter should apply to in .habitat-filter
(fixed-position overlays like the consent banner deliberately sit outside
it). Style everything else with habitat's semantic custom properties —
--habitat-bg, --habitat-surface, --habitat-text, --habitat-text-muted,
--habitat-accent, --habitat-border, --habitat-shadow — and every mode,
present and future, works for free.
API
| Export | What it does |
|---|---|
| HabitatSensor | EventTarget subclass. Camera capture, median-cut, palette derivation, persistence. Emits habitatupdate and habitatstate (cast e to CustomEvent<HabitatUpdateDetail> / CustomEvent<HabitatStateDetail> to read .detail). |
| initHabitatBanner(sensor) | Injects the consent banner + "camera active" status pill. Returns { request(), resume() }. |
| headroom(el, opts?) | Dependency-free scroll-direction watcher: hides el on scroll-down, shows it on scroll-up. |
| medianCut(pixelSource, count?) | Pure dominant-color extraction from any { data: ArrayLike<number> } (works with real ImageData or synthetic test data). |
| deriveSemanticPalette(dominants, targets?, options?) | Pure: turns dominant colors into a contrast-guaranteed semantic palette. options.colorScheme forces it light or dark. |
| contrast(a, b) | WCAG contrast ratio between two [r,g,b] triplets. |
| CONTRAST_LEVELS | { aa: {text:4.5,accent:3}, aaa: {text:7,accent:3}, high: {text:10,accent:4.5} } |
HabitatSensor constructor options (all optional, backward-compatible
defaults): captureIntervalMs (15000), blurPx (2), frameWidth (64),
frameHeight (48), contrastLevel ("aa" | "aaa" | "high"), colorScheme
("light" | "dark", omit for automatic). sensor.setContrastLevel(level) and
sensor.setColorScheme(scheme | null) change either afterward.
Full type definitions ship in the package — see dist/habitat.d.ts or your
editor's autocomplete.
Color scheme
Automatic by default: everything follows prefers-color-scheme, same as any
CSS. Force it toward light or dark instead — this shapes every mode, not
just the base palette: off's tokens, eink's paper and filter (pinned to a
fixed bright/dim endpoint instead of tracking measured brightness), and
camo's derived palette (background lightness confined to that half of the
range, ink pole fixed, contrast guarantee still enforced).
sensor.setColorScheme("dark"); // or "light", or null to go back to automaticNo JavaScript required at all if your app already manages its own theme — these are equivalent:
<html data-habitat-scheme="dark">
<html class="habitat-dark">
<html class="dark"> <!-- the same convention shadcn/next-themes use -->shadcn/ui
habitat.css/habitat-shadcn.css maps habitat's semantic tokens onto
shadcn/ui's full theming variable
contract — --background/--foreground, --card, --popover, --primary,
--secondary, --muted, --accent, --destructive, --border, --input,
--ring, --chart-1…--chart-5, the --sidebar-* family, and --radius —
the same lightweight distribution model as shadcn's own
Typeset. Load it after habitat.css and
any shadcn/ui + Tailwind project re-themes under eink/camo modes automatically
— no component changes:
<link rel="stylesheet" href="habitat.css">
<link rel="stylesheet" href="habitat-shadcn.css">habitat.css's own dark handling (prefers-color-scheme, or a forced
color scheme) already flows through --habitat-*, so this
mapping needs no separate .dark block of its own. If your project uses
Tailwind v4, add the
matching @theme inline block from shadcn's own docs to your global CSS so
utilities like bg-primary resolve. The chart tokens are a 5-step
monochromatic ramp derived from the room's own accent color rather than 5
independent hues — introducing new colors would work against the
"camouflage into the room" premise, so it's a deliberate choice, not a
missing feature.
See it live: the landing page embeds a real shadcn-style component showcase (buttons, a card, badges, an alert) styled with nothing but this token set, re-theming live as you switch Off/Eink/Camo.
Privacy
Camo mode's camera use is consent-gated and minimal by construction: one
64×48 frame every 15 seconds (configurable), blurred in the same instant
it's drawn, analyzed in-browser, then erased — at most one blurred frame
exists at any moment, never shown, stored, or transmitted. Only a brightness
number and a handful of hex colors persist, on-device, in localStorage.
Development
npm install
npm run typecheck # tsc --noEmit
npm run build # tsup (ESM/CJS/IIFE + .d.ts) + CSS build
npm test # vitest — pure logic: median-cut, contrast, palette
# derivation (across aa/aaa/high), consent state
# machine, headroom pin/unpin
deno task test # Deno-native proof of zero Node dependenciesLicense
MIT © Arvind
