@exegia/specular 0.1.1 → 0.2.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/README.md CHANGED
@@ -1,50 +1,47 @@
1
- <div align="center">
2
-
3
- <img src="assets/logo.svg" alt="" width="132" height="132">
4
-
5
1
  # @exegia/specular
6
2
 
7
- **✨ Specular lighting for Tailwind v4 + React — a two-layer inset bezel as
8
- utilities, a `cn()` that keeps them alive, and a rim highlight that tracks the
9
- pointer.**
10
-
11
- [![npm](https://img.shields.io/npm/v/@exegia/specular?color=6366f1&labelColor=1c1917)](https://www.npmjs.com/package/@exegia/specular) [![types](https://img.shields.io/npm/types/@exegia/specular?color=38bdf8&labelColor=1c1917)](https://www.npmjs.com/package/@exegia/specular) [![provenance](https://img.shields.io/badge/npm-provenance-a855f7?labelColor=1c1917)](https://www.npmjs.com/package/@exegia/specular#provenance) [![runtime deps](https://img.shields.io/badge/runtime%20deps-0-f43f5e?labelColor=1c1917)](#install) [![license](https://img.shields.io/npm/l/@exegia/specular?color=fbbf24&labelColor=1c1917)](LICENSE)
3
+ Pointer-following rim highlights and inset bezel utilities for Tailwind v4 + React.
12
4
 
13
- <img src="assets/demo.gif" alt="Six avatars whose rim highlights turn to follow the pointer as it moves across them" width="100%">
5
+ [npm](https://www.npmjs.com/package/@exegia/specular) · [live examples](https://emmanuel-defreitas.github.io/specular/) · [MIT](LICENSE)
14
6
 
15
- <sub>Six discs, one window listener. Recorded from <a href="examples/vite-react"><code>examples/vite-react</code></a>.</sub>
16
-
17
- </div>
7
+ ## Install
18
8
 
19
- ---
9
+ ```bash
10
+ bun add @exegia/specular
11
+ ```
20
12
 
21
- Declare a surface, get a utility family that draws a two-layer inset "bezel"
22
- one axis at a time, a tailwind-merge config that keeps those utilities alive
23
- in `cn()`, and React primitives that light the surface with the pointer.
13
+ All entry points below are included in this one package. The package declares Tailwind CSS **4.1–4.x** as a peer dependency and Node.js **22.18+** as its engine. Set up Tailwind in your app before using the generated utility classes.
24
14
 
25
- ## Install
15
+ Install these optional peers only for the features you use, if your app does not already have them:
26
16
 
27
17
  ```bash
28
- npm i @exegia/specular # or: bun add / pnpm add / yarn add
18
+ # For @exegia/specular/react
19
+ bun add react@^19
20
+
21
+ # For @exegia/specular/merge
22
+ bun add tailwind-merge@^3
29
23
  ```
30
24
 
31
- | export | contents |
32
- |---|---|
33
- | `@exegia/specular` | `defineSurfaces` + types — the shared module both consumers import |
34
- | `@exegia/specular/plugin` | `createPlugin(surfaces)` — Tailwind v4 plugin factory, pure JS |
35
- | `@exegia/specular/merge` | `mergeConfig(surfaces)`, `createCn(surfaces)` — tailwind-merge |
36
- | `@exegia/specular/react` | `usePointerLight`, `<Rim>`, `measureRimTone`, `toneToAlphas`, `useRimTone` |
25
+ ## Setup
37
26
 
38
- Peers: `tailwindcss@^4.1 <5`; `react@^19` and `tailwind-merge@^3` only if you
39
- use `/react` or `/merge`. No runtime dependencies.
27
+ Choose the effect you want; you do not need every entry point.
40
28
 
41
- ## Setup
29
+ | I want to… | What to set up |
30
+ | --- | --- |
31
+ | Add inset highlights/shadows with classes such as `bezel-base` | Define surfaces and register the Tailwind plugin: steps 1–3 below. React is not required. |
32
+ | Merge Specular classes in a `cn()` helper | Also pass the same surfaces to `createCn`: step 4. |
33
+ | Put a rim overlay on an avatar or image | Use `<Rim>` from `/react`. No surface config or Specular plugin is required. See [React effects](#exegiaspecularreact--react-effects). |
34
+ | Make a rim follow the pointer | Combine `usePointerLight(ref)` with `<Rim angle={angle}>`. |
35
+ | Make an inset bezel follow the pointer | Combine the Tailwind setup with `usePointerLight` and `litVars`. |
42
36
 
43
- One config, two consumers. It has to be a JS module: `@plugin` options are
44
- flat scalars only, and tailwind-merge runs in the browser.
37
+ ### 1. Define your surfaces
38
+
39
+ A **surface** is a named collection of shadow layers. Its name becomes the utility prefix: `bezel` below creates `bezel-*` classes. This is your app's configuration; Specular does not ship a default surface preset.
40
+
41
+ Create these two files at your project root:
45
42
 
46
43
  ```ts
47
- // bezel.config.ts
44
+ // specular.config.ts
48
45
  import { defineSurfaces } from "@exegia/specular"
49
46
 
50
47
  export const surfaces = defineSurfaces({
@@ -54,205 +51,228 @@ export const surfaces = defineSurfaces({
54
51
  dim: { color: "#000", y: -1, blur: 2, alpha: 15, dark: { y: -8, alpha: 20 } },
55
52
  },
56
53
  },
57
- card: {
58
- layers: {
59
- lit: { inset: true, color: "#fff", y: 1, alpha: 60 },
60
- drop: { inset: false, color: "#000", y: 4, blur: 8, alpha: 15 },
61
- },
62
- },
63
54
  })
64
55
  ```
65
56
 
66
- ```js
67
- // tailwind.plugin.js
57
+ ```ts
58
+ // specular.plugin.ts
68
59
  import { createPlugin } from "@exegia/specular/plugin"
69
- import { surfaces } from "./bezel.config"
60
+ import { surfaces } from "./specular.config.ts"
61
+
70
62
  export default createPlugin(surfaces)
71
63
  ```
72
64
 
65
+ `defineSurfaces` validates the names and preserves TypeScript types. It does not generate CSS. `createPlugin` turns that configuration into Tailwind utilities.
66
+
67
+ ### 2. Register the plugin in your CSS
68
+
69
+ In your app's Tailwind stylesheet:
70
+
73
71
  ```css
72
+ /* src/app.css */
74
73
  @import "tailwindcss";
75
- @plugin "./tailwind.plugin.js";
74
+ @plugin "../specular.plugin.ts";
75
+ ```
76
+
77
+ The plugin path is relative to this CSS file. Make sure your app loads this stylesheet.
78
+
79
+ **Register your local plugin file, not `@plugin "@exegia/specular/plugin"`.** The package exports a factory that needs your `surfaces`; the local file calls it and exports the configured plugin. There is no additional Specular stylesheet to import.
80
+
81
+ ### 3. Use the generated classes
82
+
83
+ ```tsx
84
+ <button className="rounded-lg bg-stone-200 px-4 py-2 bezel-base active:bezel-lit/20 active:bezel-dim/40">
85
+ Press me
86
+ </button>
76
87
  ```
77
88
 
89
+ `bezel-base` applies all configured layers. Utilities let you change individual properties:
90
+
91
+ | Class | Effect |
92
+ | --- | --- |
93
+ | `bezel-lit/60` | Set the `lit` layer's alpha to 60%. |
94
+ | `bezel-lit-t-2` | Light the top edge with a 2px inset offset. |
95
+ | `bezel-dim-b-1` | Set a 1px offset from the bottom edge. |
96
+ | `bezel-lit-blur-4` | Set the `lit` layer's blur to 4px. |
97
+ | `bezel-spread-1` | Set every layer's spread to 1px. |
98
+
99
+ Each modifier utility also applies the surface's full shadow, so `bezel-base` is useful for the preset but is not required alongside a modifier. Bare layer classes such as `bezel-lit` keep the configured alpha.
100
+
101
+ The `dark` values activate under a `.dark` ancestor by default, for example `<html class="dark">`. Your app controls that class. Change a surface's `darkSelector` to use another selector or `@media (prefers-color-scheme: dark)`.
102
+
103
+ ### 4. Optional: configure `cn()`
104
+
105
+ Use this if you merge dynamic classes with tailwind-merge. Static `className` strings do not need it.
106
+
78
107
  ```ts
79
- // lib/utils.ts
108
+ // src/lib/cn.ts
80
109
  import { createCn } from "@exegia/specular/merge"
81
- import { surfaces } from "./bezel.config"
110
+ import { surfaces } from "../../specular.config.ts"
111
+
82
112
  export const cn = createCn(surfaces)
83
113
  ```
84
114
 
85
- Then: `<div class="bezel-lit bezel-dim rounded-full" />`. The `dark` blocks
86
- land in a `.dark { --bezel-lit-y: … }` token block, so the element carries no
87
- `dark:` classes. Set `darkSelector` per surface for other strategies
88
- (`"@media (prefers-color-scheme: dark)"` wraps `:root`).
115
+ ```tsx
116
+ <button className={cn("bezel-base bezel-lit/60", pressed && "bezel-lit/20")}>
117
+ Press me
118
+ </button>
119
+ ```
120
+
121
+ **`createCn(surfaces)` requires your surface config.** There is no zero-argument default or separate options object. Create the helper once, using the same config as the plugin, then call `cn(...)` throughout your app. It merges normal Tailwind classes as well as Specular classes.
89
122
 
90
- ### Layer config
123
+ ## Exports, by purpose
91
124
 
92
- ```ts
93
- type LayerConfig = {
94
- inset?: boolean // default true; false = outer drop layer
95
- color?: string // default "#fff" for the first layer, "#000" after
96
- x?: number | string // px number or any CSS length; default 0
97
- y?: number | string
98
- blur?: number | string // default 0
99
- spread?: number | string // default 0 — the knob that turns a hairline into a ring
100
- alpha?: number // 0-100, default 100
101
- dark?: Partial<Omit<LayerConfig, "inset" | "dark">>
125
+ ### `@exegia/specular` — shared configuration and inline styles
126
+
127
+ | Export | What it provides |
128
+ | --- | --- |
129
+ | `defineSurfaces(config)` | Validates your surface/layer names and returns the typed config shared by the plugin and merge helper. |
130
+ | `litVars(angle, depth?, surface?)` | Returns inline CSS variables that turn a surface's `lit` layer toward an angle. Defaults: depth `2.5` pixels, surface name `"card"`. |
131
+ | `Surfaces`, `SurfaceConfig`, `LayerConfig`, `Length`, `LitVars` | Types for configuration and inline styles. |
132
+
133
+ Layer settings are optional: inset defaults to `true`, color to white for the first layer and black for later layers, offsets/blur/spread to `0`, and alpha to `100` (percent). Supply offsets or blur to make an inset layer visible. The base class defaults to `<surface>-base`.
134
+
135
+ For a pointer-following **inset bezel**, use the configured surface name and a layer named `lit`:
136
+
137
+ ```tsx
138
+ "use client"
139
+
140
+ import { useRef } from "react"
141
+ import { litVars } from "@exegia/specular"
142
+ import { usePointerLight } from "@exegia/specular/react"
143
+
144
+ export function LitButton() {
145
+ const ref = useRef<HTMLButtonElement>(null)
146
+ const angle = usePointerLight(ref)
147
+
148
+ return (
149
+ <button
150
+ ref={ref}
151
+ className="rounded-lg bg-stone-200 px-4 py-2 bezel-base"
152
+ style={litVars(angle, 2.5, "bezel")}
153
+ >
154
+ Move your pointer
155
+ </button>
156
+ )
102
157
  }
103
158
  ```
104
159
 
105
- Layer order in the map is paint order in `box-shadow`. `defineSurfaces` throws
106
- on a surface named after a core Tailwind root (`shadow`, `inset-shadow`,
107
- `ring`, `blur`, …), on names with `/`, `:`, whitespace or uppercase, and on a
108
- layer named `blur`, `spread`, `base` or `color`.
109
-
110
- > Dark mode is not the light values dimmed. On a dark page the white highlight
111
- > has to all but vanish (5% against 80%) while the shadow grows long and soft
112
- > (8px against 1px), or the disc reads as a lit dome on black.
113
-
114
- ## Utilities
115
-
116
- For a surface `S` and a layer `L`. Every utility also emits the base
117
- `box-shadow`, so any single class stands alone.
118
-
119
- | utility | writes |
120
- |---|---|
121
- | `S-base` (or `baseClass`) | `box-shadow: var(--tw-S-shadow)` |
122
- | `S-L` | the base `box-shadow` only: layer `L` at its configured alpha, so the `dark` preset applies |
123
- | `S-L/40`, `S-L/[0.4]` | `--tw-S-L-alpha: 40` (fractions are scaled; `/80` ≡ `/[0.8]`); `/100` is explicit full strength |
124
- | `S-L-t-N` | `--tw-S-L-y: calc(N * 1px)` — light enters from the top, pushes the layer down |
125
- | `S-L-b-N` | `--tw-S-L-y: calc(N * -1px)` |
126
- | `S-L-l-N` / `S-L-r-N` | `--tw-S-L-x` |
127
- | `S-L-blur-N`, `S-L-spread-N` | `--tw-S-L-blur`, `--tw-S-L-spread` |
128
- | `S-L-color-red-500`, `S-L-color-[#fff]` | `--tw-S-L-color` |
129
- | `S-blur-N`, `S-spread-N` | every layer at once |
130
-
131
- `N` is a bare integer, a decimal, or `[<length>]`. `-t-`/`-b-` write the same
132
- variable, as do `-l-`/`-r-`: pick one per layer per axis. `-t-[2]` passes
133
- through as the unitless `2`, which is invalid in `box-shadow` — core behaves
134
- the same.
135
-
136
- ### How it works: three tiers
137
-
138
- | tier | name | set by | inherits |
139
- |---|---|---|---|
140
- | composed | `--tw-S-shadow` | universal selector in `@layer base` | set everywhere |
141
- | instance | `--tw-S-L-{x,y,blur,spread,color,alpha}` | utilities; `@property { syntax: "*"; inherits: false }`, no `initial-value` | no |
142
- | preset | `--S-L-{…}` | the `dark` block, or your CSS | yes |
143
-
144
- Each component is `var(--tw-S-L-y, var(--S-L-y, <default>))`: the utility's
145
- value if set on this element, else the inherited preset, else the config
146
- default. Registering the instance tier without an `initial-value` gives it
147
- the guaranteed-invalid value, so `var()` falls through; `inherits: false`
148
- stops a nested bezel from picking up its ancestor's offsets. Verified in
149
- Chromium 152 (a registered `--x: 20px` on the parent leaves the child at the
150
- fallback; an unregistered one leaks). Not yet verified in WebKit — if it
151
- ever leaks there, drop the `@property` block and everything else still works.
152
-
153
- ## `cn()` and why the merge config ships here
154
-
155
- `twMerge("inset-shadow-lit-t-2 inset-shadow-dim-b-1")` with stock
156
- tailwind-merge returns `"inset-shadow-dim-b-1"`: any custom utility whose
157
- name shares a prefix with a core group is filed under that group and only the
158
- last survives. That shipped once — the CSS compiled, a `toContain` test
159
- passed on the survivor, and the lit layer never reached the screen.
160
-
161
- `mergeConfig(surfaces)` generates one class group per custom property,
162
- bidirectional conflicts with core `shadow`/`inset-shadow`, all-layer groups
163
- that displace their per-layer twins, and the full cross-surface matrix (two
164
- surfaces on one element means one `box-shadow` wins outright). Group ids are
165
- a template-literal union, so a typo in your own `conflictingClassGroups` is a
166
- compile error.
167
-
168
- `createCn` accepts what tailwind-merge accepts (strings, arrays, falsy). For
169
- clsx's object form: `extendTailwindMerge(mergeConfig(surfaces))(clsx(...))`.
170
-
171
- ## React
172
-
173
- Every entry carries `"use client"`. SSR renders the static emboss; nothing
174
- touches `window` at module scope.
160
+ This uses the Tailwind plugin but does not need `<Rim>`.
161
+
162
+ ### `@exegia/specular/plugin` — generate Tailwind CSS
163
+
164
+ `createPlugin(surfaces, options?)` is the build-time factory used in setup step 1. It generates the surface utilities and dark-mode presets. It does not handle pointer movement or class merging, and it does not require React.
165
+
166
+ The `surfaces` argument is required; the second argument is optional. By default, the plugin also supplies `<Rim>` dark-mode tokens under `.dark`: `hi: 0.18`, `lo: 0.4`. You can customize those independently of your surfaces:
167
+
168
+ ```ts
169
+ export default createPlugin(surfaces, {
170
+ rim: { darkSelector: ".dark", dark: { hi: 0.18, lo: 0.4 } },
171
+ })
172
+ ```
173
+
174
+ Use `{ rim: false }` to omit those tokens. `PluginOptions` and `RimTokens` are available as types.
175
+
176
+ ### `@exegia/specular/merge` — merge class names correctly
177
+
178
+ | Export | When to use it |
179
+ | --- | --- |
180
+ | `createCn(surfaces)` | You want a ready-to-use `cn(...)` function that understands the configured utilities and their conflicts. |
181
+ | `mergeConfig(surfaces)` | You already customize tailwind-merge and want to incorporate Specular's class groups into that setup. Returns configuration, not a merging function. |
182
+
183
+ Both require the same surfaces passed to the plugin. They do not generate CSS or register the plugin. `createCn` accepts strings, arrays, and falsy values; it does **not** accept clsx-style objects.
184
+
185
+ The lower-level equivalent is:
186
+
187
+ ```ts
188
+ import { extendTailwindMerge } from "tailwind-merge"
189
+ import { mergeConfig } from "@exegia/specular/merge"
190
+ import { surfaces } from "./specular.config.ts"
191
+
192
+ export const cn = extendTailwindMerge(mergeConfig(surfaces))
193
+ ```
194
+
195
+ If your existing helper uses `clsx`, pass its output to this configured merger. `BezelGroupId` and `BezelMergeConfig` are available as types.
196
+
197
+ ### `@exegia/specular/react` — React effects
198
+
199
+ **`<Rim>` draws the effect; `usePointerLight` provides movement.** They are independent: a rim can stay still, and the hook can drive a bezel or your own styles.
200
+
201
+ | Export | What it provides | Required setup |
202
+ | --- | --- | --- |
203
+ | `<Rim>` | A decorative gradient overlay, useful above images that would cover an inset shadow. Defaults to angle `0`. | A positioned parent and a border radius on the rim. |
204
+ | `usePointerLight(ref, options?)` | The pointer's angle around the referenced element, in degrees clockwise from the top. Renders nothing. | Attach the ref to the element you want to light. Options are optional. |
205
+ | `useRimTone(src, enabled?)` | Measures image-edge brightness as React state (`0`–`1`, or `null` while unavailable). | An image URL; enabled by default. |
206
+ | `measureRimTone(src)` | The same image measurement as a promise, for manual use instead of the hook. | Browser image/canvas support. |
207
+ | `toneToAlphas(tone, ends?)` | Converts brightness to `{ hi, lo }` props for `<Rim>`. | A numeric tone; custom endpoints are optional. |
208
+ | `useReducedMotion()` | Tracks the user's reduced-motion preference for your own effects. | No arguments. Already used internally by the pointer hook and rim. |
209
+
210
+ #### A pointer-following rim
211
+
212
+ This example needs React 19 and ordinary Tailwind utilities, but **no Specular plugin, surfaces, or `cn()` setup**:
175
213
 
176
214
  ```tsx
177
- import { usePointerLight, Rim, useRimTone, toneToAlphas } from "@exegia/specular/react"
215
+ "use client"
216
+
217
+ import { useRef } from "react"
218
+ import { Rim, usePointerLight } from "@exegia/specular/react"
219
+
220
+ export function Orb() {
221
+ const ref = useRef<HTMLDivElement>(null)
222
+ const angle = usePointerLight(ref)
178
223
 
179
- function Avatar({ src }: { src: string }) {
180
- const ref = useRef<HTMLSpanElement>(null)
181
- const angle = usePointerLight(ref) // raw bearing, 0 = 12 o'clock
182
- const tone = useRimTone(src) // 0–1 rim lightness, or null
183
- const alphas = tone === null ? undefined : toneToAlphas(tone)
184
224
  return (
185
- <span ref={ref} className="relative size-16 rounded-full bezel-lit bezel-dim">
186
- <img src={src} className="size-full rounded-full" />
187
- <Rim angle={angle * 0.8} hi={alphas?.hi} lo={alphas?.lo} className="rounded-full" />
188
- </span>
225
+ <div ref={ref} className="relative size-20 rounded-full bg-stone-300">
226
+ <Rim angle={angle} className="rounded-full" />
227
+ </div>
189
228
  )
190
229
  }
191
230
  ```
192
231
 
193
- - **`usePointerLight(ref, options?)`** — one shared window listener and one
194
- rAF per frame for every subscriber on the page; 1.5° epsilon; shortest-arc
195
- unwrapping so `rotate()` never spins the long way round; no subscription
196
- under `prefers-reduced-motion`. Damping is yours (`angle * 0.8`).
197
- `{ mode: "var", property: "--light-angle" }` writes `<angle>deg` on the
198
- element with no re-render — the escape hatch for lists.
199
- - **`<Rim>`** — the masked conic sweep, `rotate()` as the animated property,
200
- radius-relative mask so it reads the same at 32px and 64px. Radius is
201
- yours: pass `rounded-full`. Alphas come from `--rim-hi-a` / `--rim-lo-a`
202
- (light 1 / 0.2 baked in; the plugin emits the dark 0.18 / 0.4 block).
203
- - **`measureRimTone(src)`** — Rec. 709 luma over the outer band of a 16×16
204
- sample; `null` on SSR, decode error or a host without CORS headers;
205
- memoised per `src`, a `null` is evicted so a later mount retries.
206
- - **`toneToAlphas(t, ends?)`** — `{ hi: 0.7 + 0.3t, lo: 0.6 − 0.4t }`,
207
- retunable through `ends`.
208
-
209
- ## Things worth knowing
210
-
211
- - **An inset shadow paints under the root's children.** A `size-full` image
212
- hides it. That is why `<Rim>` is a separate overlay, and why a fallback
213
- must stay transparent for the emboss to show.
214
- - **The family owns `box-shadow`.** Never pair a surface with `shadow-*` or
215
- `ring-*`; `cn()` deletes one, and raw class strings leave the stylesheet to
216
- pick an arbitrary winner.
217
- - **`S-blur-N` and `S-L-blur-N` write the same var.** In the stylesheet (and
218
- in `@apply`) the winner is Tailwind's sort order, not class order. `cn()`
219
- resolves it: all-layer displaces per-layer.
220
- - **Browser floor: `color-mix()`** (Chrome 111, Safari 16.2, Firefox 113).
221
- Tailwind's fallback branch drops the alpha, so below that the surface
222
- paints opaque. Accepted: all three are 2023.
223
- - **`@apply` of a surface inside `@layer components` works.**
224
- - **Opposite edges of one layer overwrite; they do not add.** Two layers is
225
- the way to light two edges.
226
-
227
- ## Migrating an existing `inset-shadow-*` family
228
-
229
- A surface named `inset-shadow` with `baseClass: "inset-shadow-bezel"`,
230
- `allowCoreCollision: true` and layers `lit` / `dim` reproduces the class API
231
- exactly (`inset-shadow-lit-t-3 inset-shadow-lit/80 …`), and the merge config
232
- handles the core collision. The tests cover this shape class by class.
233
-
234
- ## Example app
235
-
236
- `examples/vite-react` is a Vite + React 19 + Tailwind v4 page that walks
237
- through every surface, offset, alpha, blur and colour utility, the `glow`
238
- hover halo, the pointer-lit rims and the `cn()` merge. It runs against the
239
- library in this checkout:
232
+ For a static rim, omit the ref and hook and render `<Rim className="rounded-full" />`. The component positions itself absolutely; the parent needs `position: relative`. Its effect uses inline styles, so you can also supply sizing, background, and border radius with your own CSS instead of Tailwind.
240
233
 
241
- ```bash
242
- make example-dev # build the library, install the example, start Vite
243
- make example-build # production build, what CI runs
234
+ `<Rim>` accepts `hi` and `lo` strength overrides from `0` to `1`. Without overrides, it reads `--rim-hi-a` and `--rim-lo-a`, falling back to `1` and `0.2`. The Specular plugin supplies dark-mode values; without it, set the CSS variables or props yourself if you want theme-specific strengths.
235
+
236
+ `usePointerLight(ref, { damping: 0.4 })` makes tracking more gradual; the default is `1`. Set `enabled: false` to stop tracking. For custom CSS effects, `{ mode: "var" }` writes `--light-angle` on the referenced element instead of returning an angle or rerendering on pointer changes; `property` can override that variable name. The hook stops subscribing under reduced motion, and `<Rim>` disables its rotation transition.
237
+
238
+ In apps with React Server Components, put components that call these hooks in a `"use client"` file, as shown above.
239
+
240
+ #### Optional: adapt a rim to an image
241
+
242
+ Tone helpers adjust highlight/shadow strength to the image's brightness. They are not required for pointer tracking or for `<Rim>` to render.
243
+
244
+ ```tsx
245
+ "use client"
246
+
247
+ import { useRef } from "react"
248
+ import { Rim, toneToAlphas, usePointerLight, useRimTone } from "@exegia/specular/react"
249
+
250
+ export function LitAvatar({ src, alt }: { src: string; alt: string }) {
251
+ const ref = useRef<HTMLDivElement>(null)
252
+ const angle = usePointerLight(ref)
253
+ const tone = useRimTone(src)
254
+ const alphas = tone === null ? {} : toneToAlphas(tone)
255
+
256
+ return (
257
+ <div ref={ref} className="relative size-20 rounded-full">
258
+ <img src={src} alt={alt} className="size-full rounded-full object-cover" />
259
+ <Rim angle={angle} {...alphas} className="rounded-full" />
260
+ </div>
261
+ )
262
+ }
244
263
  ```
245
264
 
246
- See [`examples/vite-react/README.md`](examples/vite-react/README.md) for what
247
- each section shows.
265
+ `useRimTone` wraps `measureRimTone`; choose one, not both. Measurement returns `null` if the image cannot be sampled, including when a remote host does not allow CORS. The image can still display, and this example keeps the rim's default strengths until a tone is available.
266
+
267
+ `toneToAlphas` defaults to `{ hi: 0.7, lo: 0.6 }` for a black image edge and `{ hi: 1, lo: 0.2 }` for a white edge, interpolating between them. These explicit props override the rim's theme tokens.
268
+
269
+ The React entry point also exports `PointerLightOptions`, `RimProps`, `ToneAlphas`, and `ToneEnds` types, plus `ANGLE_EPSILON`, `RIM_TRANSITION`, and `DEFAULT_TONE_ENDS` constants for advanced use.
248
270
 
249
271
  ## Development
250
272
 
251
273
  ```bash
252
- make ci # lint (tsc), build, test, smoke
253
- make help # every target, including the release pipeline
274
+ make ci
275
+ make example-dev
254
276
  ```
255
277
 
256
- Tests run on `node --test` with no framework. The Tailwind harness compiles
257
- in memory in under a second. See `.github/WORKFLOW.md` for branching and
258
- releases.
278
+ The [example site](examples/vite-react) is the complete visual reference.
package/dist/index.d.ts CHANGED
@@ -43,6 +43,22 @@ export type SurfaceConfig = {
43
43
  };
44
44
  export type Surfaces = Record<string, SurfaceConfig>;
45
45
  export declare function defineSurfaces<const T extends Surfaces>(surfaces: T): T;
46
+ /** Instance-tier custom properties `litVars()` writes: `--tw-{surface}-lit-x/y`. */
47
+ export type LitVars = {
48
+ [k: `--tw-${string}-lit-x`]: string;
49
+ } & {
50
+ [k: `--tw-${string}-lit-y`]: string;
51
+ };
52
+ /**
53
+ * Instance-tier vars that turn a surface's lit edge toward a pointer bearing.
54
+ *
55
+ * Bearing 0 = 12 o'clock, clockwise. The offset is (-sin, +cos) · depth: an
56
+ * inset layer offset exposes the band OPPOSITE the offset, so a bearing of 0
57
+ * (cursor above) writes (−0, +depth) — the top edge lights up. Pair with
58
+ * `usePointerLight(ref)` and spread into `style`. `surface` must match the
59
+ * surface name in your `defineSurfaces` config.
60
+ */
61
+ export declare function litVars(angle: number, depth?: number, surface?: string): LitVars;
46
62
  export type ResolvedLayer = {
47
63
  name: string;
48
64
  inset: boolean;
package/dist/index.js CHANGED
@@ -40,6 +40,22 @@ export function defineSurfaces(surfaces) {
40
40
  }
41
41
  return surfaces;
42
42
  }
43
+ /**
44
+ * Instance-tier vars that turn a surface's lit edge toward a pointer bearing.
45
+ *
46
+ * Bearing 0 = 12 o'clock, clockwise. The offset is (-sin, +cos) · depth: an
47
+ * inset layer offset exposes the band OPPOSITE the offset, so a bearing of 0
48
+ * (cursor above) writes (−0, +depth) — the top edge lights up. Pair with
49
+ * `usePointerLight(ref)` and spread into `style`. `surface` must match the
50
+ * surface name in your `defineSurfaces` config.
51
+ */
52
+ export function litVars(angle, depth = 2.5, surface = "card") {
53
+ const rad = (angle * Math.PI) / 180;
54
+ return {
55
+ [`--tw-${surface}-lit-x`]: `${(-Math.sin(rad) * depth).toFixed(2)}px`,
56
+ [`--tw-${surface}-lit-y`]: `${(Math.cos(rad) * depth).toFixed(2)}px`,
57
+ };
58
+ }
43
59
  export const LAYER_PROPS = ["x", "y", "blur", "spread", "color", "alpha"];
44
60
  export function length(v, fallback = "0") {
45
61
  if (v === undefined)
@@ -18,7 +18,7 @@ export declare function bearingFrom(cx: number, cy: number, x: number, y: number
18
18
  */
19
19
  export declare function unwrap(last: number, bearing: number): number | null;
20
20
  /** Register a target; the listener exists only while the set is non-empty. */
21
- export declare function subscribe(target: () => Element | null, emit: (angle: number) => void): () => void;
21
+ export declare function subscribe(target: () => Element | null, emit: (angle: number) => void, damping?: number): () => void;
22
22
  /** For tests. */
23
23
  export declare const subscriberCount: () => number;
24
24
  /** `false` on the server and on first render; tracks the media query after. */
@@ -26,10 +26,12 @@ export declare function useReducedMotion(): boolean;
26
26
  export type PointerLightOptions = {
27
27
  mode?: "state";
28
28
  enabled?: boolean;
29
+ damping?: number;
29
30
  } | {
30
31
  mode: "var";
31
32
  property?: string;
32
33
  enabled?: boolean;
34
+ damping?: number;
33
35
  };
34
36
  /**
35
37
  * `mode: "state"` (default) returns the raw bearing and re-renders on change.
@@ -41,9 +43,11 @@ export type PointerLightOptions = {
41
43
  export declare function usePointerLight(ref: RefObject<Element | null>, options?: {
42
44
  mode?: "state";
43
45
  enabled?: boolean;
46
+ damping?: number;
44
47
  }): number;
45
48
  export declare function usePointerLight(ref: RefObject<Element | null>, options: {
46
49
  mode: "var";
47
50
  property?: string;
48
51
  enabled?: boolean;
52
+ damping?: number;
49
53
  }): void;
@@ -39,6 +39,7 @@ function flush() {
39
39
  frame = 0;
40
40
  if (!pointer)
41
41
  return;
42
+ let unsettled = false;
42
43
  for (const s of subscribers) {
43
44
  const el = s.target();
44
45
  if (!el)
@@ -46,12 +47,39 @@ function flush() {
46
47
  const rect = el.getBoundingClientRect();
47
48
  if (rect.width === 0)
48
49
  continue;
49
- const next = unwrap(s.last, bearingFrom(rect.left + rect.width / 2, rect.top + rect.height / 2, pointer.x, pointer.y));
50
- if (next === null)
50
+ const bearing = bearingFrom(rect.left + rect.width / 2, rect.top + rect.height / 2, pointer.x, pointer.y);
51
+ if (s.damping >= 1) {
52
+ const next = unwrap(s.last, bearing);
53
+ if (next === null)
54
+ continue;
55
+ s.last = next;
56
+ s.emit(next);
51
57
  continue;
52
- s.last = next;
53
- s.emit(next);
58
+ }
59
+ // Damped: ease along the shortest arc each frame, self-scheduling until
60
+ // the residual is under a degree. `last` stays continuous.
61
+ if (Number.isNaN(s.last)) {
62
+ s.last = bearing;
63
+ s.emit(bearing);
64
+ continue;
65
+ }
66
+ let d = bearing - (((s.last % 360) + 360) % 360);
67
+ if (d > 180)
68
+ d -= 360;
69
+ if (d < -180)
70
+ d += 360;
71
+ if (Math.abs(d) <= ANGLE_EPSILON) {
72
+ s.last = bearing;
73
+ continue;
74
+ }
75
+ const moved = s.last + d * s.damping;
76
+ s.last = moved;
77
+ s.emit(moved);
78
+ if (Math.abs(d) * (1 - s.damping) > 0.5)
79
+ unsettled = true;
54
80
  }
81
+ if (unsettled && frame === 0)
82
+ frame = requestAnimationFrame(flush);
55
83
  }
56
84
  function onMove(event) {
57
85
  pointer = { x: event.clientX, y: event.clientY };
@@ -59,8 +87,8 @@ function onMove(event) {
59
87
  frame = requestAnimationFrame(flush);
60
88
  }
61
89
  /** Register a target; the listener exists only while the set is non-empty. */
62
- export function subscribe(target, emit) {
63
- const sub = { target, last: Number.NaN, emit };
90
+ export function subscribe(target, emit, damping = 1) {
91
+ const sub = { target, last: Number.NaN, emit, damping };
64
92
  if (subscribers.size === 0)
65
93
  window.addEventListener("pointermove", onMove, { passive: true });
66
94
  subscribers.add(sub);
@@ -96,6 +124,7 @@ export function usePointerLight(ref, options = {}) {
96
124
  const reduce = useReducedMotion();
97
125
  const enabled = options.enabled ?? true;
98
126
  const mode = options.mode ?? "state";
127
+ const damping = options.damping ?? 1;
99
128
  const property = options.mode === "var" ? (options.property ?? "--light-angle") : null;
100
129
  useEffect(() => {
101
130
  if (!enabled || reduce)
@@ -103,7 +132,7 @@ export function usePointerLight(ref, options = {}) {
103
132
  const emit = property === null
104
133
  ? setAngle
105
134
  : (a) => ref.current?.style.setProperty(property, `${a}deg`);
106
- return subscribe(() => ref.current, emit);
107
- }, [enabled, reduce, property, ref]);
135
+ return subscribe(() => ref.current, emit, damping);
136
+ }, [enabled, reduce, property, ref, damping]);
108
137
  return mode === "state" ? angle : undefined;
109
138
  }
package/dist/rim.d.ts CHANGED
@@ -22,5 +22,5 @@ export type RimProps = Omit<HTMLAttributes<HTMLSpanElement>, "children"> & {
22
22
  lo?: number;
23
23
  };
24
24
  /** transitions.dev `--duration-quick` / `--ease-smooth-out`. */
25
- export declare const RIM_TRANSITION = "transform 150ms cubic-bezier(0.22, 1, 0.36, 1)";
25
+ export declare const RIM_TRANSITION = "transform 350ms cubic-bezier(0.22, 1, 0.36, 1)";
26
26
  export declare function Rim({ angle, hi, lo, style, ...rest }: RimProps): import("react").JSX.Element;
package/dist/rim.js CHANGED
@@ -4,7 +4,7 @@ import { useReducedMotion } from "./pointer-light.js";
4
4
  const GRADIENT = "conic-gradient(from 180deg, var(--rim-lo) 0%, transparent 24%, transparent 28%, var(--rim-hi) 50%, transparent 72%, transparent 76%, var(--rim-lo) 100%)";
5
5
  const MASK = "radial-gradient(farthest-side, transparent 78%, #000 92%)";
6
6
  /** transitions.dev `--duration-quick` / `--ease-smooth-out`. */
7
- export const RIM_TRANSITION = "transform 150ms cubic-bezier(0.22, 1, 0.36, 1)";
7
+ export const RIM_TRANSITION = "transform 350ms cubic-bezier(0.22, 1, 0.36, 1)";
8
8
  export function Rim({ angle = 0, hi, lo, style, ...rest }) {
9
9
  const reduce = useReducedMotion();
10
10
  const vars = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exegia/specular",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/emmanuel-defreitas/specular.git"