@exegia/specular 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +219 -192
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,50 +1,61 @@
1
1
  <div align="center">
2
2
 
3
- <img src="assets/logo.svg" alt="" width="132" height="132">
3
+ <img src="assets/logo.svg" alt="Specular reflection mark" width="96" height="96">
4
4
 
5
5
  # @exegia/specular
6
6
 
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.**
7
+ Pointer-following rim highlights and inset bezel utilities for Tailwind v4 + React.
10
8
 
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)
9
+ [npm](https://www.npmjs.com/package/@exegia/specular) · [live examples](https://emmanuel-defreitas.github.io/specular/) · [MIT](LICENSE)
12
10
 
13
- <img src="assets/demo.gif" alt="Six avatars whose rim highlights turn to follow the pointer as it moves across them" width="100%">
11
+ <a href="https://emmanuel-defreitas.github.io/specular/">
12
+ <img src="assets/demo.gif" alt="Six avatars with rim highlights following the pointer" width="100%">
13
+ </a>
14
14
 
15
- <sub>Six discs, one window listener. Recorded from <a href="examples/vite-react"><code>examples/vite-react</code></a>.</sub>
15
+ <sub>Six discs, one window listener. Explore the interactive presentation on the <a href="https://emmanuel-defreitas.github.io/specular/">example site</a>.</sub>
16
16
 
17
17
  </div>
18
18
 
19
19
  ---
20
20
 
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.
24
-
25
21
  ## Install
26
22
 
27
23
  ```bash
28
- npm i @exegia/specular # or: bun add / pnpm add / yarn add
24
+ bun add @exegia/specular
29
25
  ```
30
26
 
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` |
27
+ 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.
28
+
29
+ Install these optional peers only for the features you use, if your app does not already have them:
30
+
31
+ ```bash
32
+ # For @exegia/specular/react
33
+ bun add react@^19
37
34
 
38
- Peers: `tailwindcss@^4.1 <5`; `react@^19` and `tailwind-merge@^3` only if you
39
- use `/react` or `/merge`. No runtime dependencies.
35
+ # For @exegia/specular/merge
36
+ bun add tailwind-merge@^3
37
+ ```
40
38
 
41
39
  ## Setup
42
40
 
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.
41
+ Choose the effect you want; you do not need every entry point.
42
+
43
+ | I want to… | What to set up |
44
+ | --- | --- |
45
+ | 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. |
46
+ | Merge Specular classes in a `cn()` helper | Also pass the same surfaces to `createCn`: step 4. |
47
+ | 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). |
48
+ | Make a rim follow the pointer | Combine `usePointerLight(ref)` with `<Rim angle={angle}>`. |
49
+ | Make an inset bezel follow the pointer | Combine the Tailwind setup with `usePointerLight` and `litVars`. |
50
+
51
+ ### 1. Define your surfaces
52
+
53
+ 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.
54
+
55
+ Create these two files at your project root:
45
56
 
46
57
  ```ts
47
- // bezel.config.ts
58
+ // specular.config.ts
48
59
  import { defineSurfaces } from "@exegia/specular"
49
60
 
50
61
  export const surfaces = defineSurfaces({
@@ -54,212 +65,228 @@ export const surfaces = defineSurfaces({
54
65
  dim: { color: "#000", y: -1, blur: 2, alpha: 15, dark: { y: -8, alpha: 20 } },
55
66
  },
56
67
  },
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
68
  })
64
69
  ```
65
70
 
66
- ```js
67
- // tailwind.plugin.js
71
+ ```ts
72
+ // specular.plugin.ts
68
73
  import { createPlugin } from "@exegia/specular/plugin"
69
- import { surfaces } from "./bezel.config"
74
+ import { surfaces } from "./specular.config.ts"
75
+
70
76
  export default createPlugin(surfaces)
71
77
  ```
72
78
 
79
+ `defineSurfaces` validates the names and preserves TypeScript types. It does not generate CSS. `createPlugin` turns that configuration into Tailwind utilities.
80
+
81
+ ### 2. Register the plugin in your CSS
82
+
83
+ In your app's Tailwind stylesheet:
84
+
73
85
  ```css
86
+ /* src/app.css */
74
87
  @import "tailwindcss";
75
- @plugin "./tailwind.plugin.js";
88
+ @plugin "../specular.plugin.ts";
89
+ ```
90
+
91
+ The plugin path is relative to this CSS file. Make sure your app loads this stylesheet.
92
+
93
+ **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.
94
+
95
+ ### 3. Use the generated classes
96
+
97
+ ```tsx
98
+ <button className="rounded-lg bg-stone-200 px-4 py-2 bezel-base active:bezel-lit/20 active:bezel-dim/40">
99
+ Press me
100
+ </button>
76
101
  ```
77
102
 
103
+ `bezel-base` applies all configured layers. Utilities let you change individual properties:
104
+
105
+ | Class | Effect |
106
+ | --- | --- |
107
+ | `bezel-lit/60` | Set the `lit` layer's alpha to 60%. |
108
+ | `bezel-lit-t-2` | Light the top edge with a 2px inset offset. |
109
+ | `bezel-dim-b-1` | Set a 1px offset from the bottom edge. |
110
+ | `bezel-lit-blur-4` | Set the `lit` layer's blur to 4px. |
111
+ | `bezel-spread-1` | Set every layer's spread to 1px. |
112
+
113
+ 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.
114
+
115
+ 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)`.
116
+
117
+ ### 4. Optional: configure `cn()`
118
+
119
+ Use this if you merge dynamic classes with tailwind-merge. Static `className` strings do not need it.
120
+
78
121
  ```ts
79
- // lib/utils.ts
122
+ // src/lib/cn.ts
80
123
  import { createCn } from "@exegia/specular/merge"
81
- import { surfaces } from "./bezel.config"
124
+ import { surfaces } from "../../specular.config.ts"
125
+
82
126
  export const cn = createCn(surfaces)
83
127
  ```
84
128
 
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`).
129
+ ```tsx
130
+ <button className={cn("bezel-base bezel-lit/60", pressed && "bezel-lit/20")}>
131
+ Press me
132
+ </button>
133
+ ```
89
134
 
90
- ### Layer config
135
+ **`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.
91
136
 
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">>
137
+ ## Exports, by purpose
138
+
139
+ ### `@exegia/specular` — shared configuration and inline styles
140
+
141
+ | Export | What it provides |
142
+ | --- | --- |
143
+ | `defineSurfaces(config)` | Validates your surface/layer names and returns the typed config shared by the plugin and merge helper. |
144
+ | `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"`. |
145
+ | `Surfaces`, `SurfaceConfig`, `LayerConfig`, `Length`, `LitVars` | Types for configuration and inline styles. |
146
+
147
+ 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`.
148
+
149
+ For a pointer-following **inset bezel**, use the configured surface name and a layer named `lit`:
150
+
151
+ ```tsx
152
+ "use client"
153
+
154
+ import { useRef } from "react"
155
+ import { litVars } from "@exegia/specular"
156
+ import { usePointerLight } from "@exegia/specular/react"
157
+
158
+ export function LitButton() {
159
+ const ref = useRef<HTMLButtonElement>(null)
160
+ const angle = usePointerLight(ref)
161
+
162
+ return (
163
+ <button
164
+ ref={ref}
165
+ className="rounded-lg bg-stone-200 px-4 py-2 bezel-base"
166
+ style={litVars(angle, 2.5, "bezel")}
167
+ >
168
+ Move your pointer
169
+ </button>
170
+ )
102
171
  }
103
172
  ```
104
173
 
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.
174
+ This uses the Tailwind plugin but does not need `<Rim>`.
175
+
176
+ ### `@exegia/specular/plugin` — generate Tailwind CSS
177
+
178
+ `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.
179
+
180
+ 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:
181
+
182
+ ```ts
183
+ export default createPlugin(surfaces, {
184
+ rim: { darkSelector: ".dark", dark: { hi: 0.18, lo: 0.4 } },
185
+ })
186
+ ```
187
+
188
+ Use `{ rim: false }` to omit those tokens. `PluginOptions` and `RimTokens` are available as types.
189
+
190
+ ### `@exegia/specular/merge` — merge class names correctly
191
+
192
+ | Export | When to use it |
193
+ | --- | --- |
194
+ | `createCn(surfaces)` | You want a ready-to-use `cn(...)` function that understands the configured utilities and their conflicts. |
195
+ | `mergeConfig(surfaces)` | You already customize tailwind-merge and want to incorporate Specular's class groups into that setup. Returns configuration, not a merging function. |
196
+
197
+ 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.
198
+
199
+ The lower-level equivalent is:
200
+
201
+ ```ts
202
+ import { extendTailwindMerge } from "tailwind-merge"
203
+ import { mergeConfig } from "@exegia/specular/merge"
204
+ import { surfaces } from "./specular.config.ts"
205
+
206
+ export const cn = extendTailwindMerge(mergeConfig(surfaces))
207
+ ```
208
+
209
+ If your existing helper uses `clsx`, pass its output to this configured merger. `BezelGroupId` and `BezelMergeConfig` are available as types.
210
+
211
+ ### `@exegia/specular/react` — React effects
212
+
213
+ **`<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.
214
+
215
+ | Export | What it provides | Required setup |
216
+ | --- | --- | --- |
217
+ | `<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. |
218
+ | `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. |
219
+ | `useRimTone(src, enabled?)` | Measures image-edge brightness as React state (`0`–`1`, or `null` while unavailable). | An image URL; enabled by default. |
220
+ | `measureRimTone(src)` | The same image measurement as a promise, for manual use instead of the hook. | Browser image/canvas support. |
221
+ | `toneToAlphas(tone, ends?)` | Converts brightness to `{ hi, lo }` props for `<Rim>`. | A numeric tone; custom endpoints are optional. |
222
+ | `useReducedMotion()` | Tracks the user's reduced-motion preference for your own effects. | No arguments. Already used internally by the pointer hook and rim. |
223
+
224
+ #### A pointer-following rim
225
+
226
+ This example needs React 19 and ordinary Tailwind utilities, but **no Specular plugin, surfaces, or `cn()` setup**:
175
227
 
176
228
  ```tsx
177
- import { usePointerLight, Rim, useRimTone, toneToAlphas } from "@exegia/specular/react"
229
+ "use client"
230
+
231
+ import { useRef } from "react"
232
+ import { Rim, usePointerLight } from "@exegia/specular/react"
233
+
234
+ export function Orb() {
235
+ const ref = useRef<HTMLDivElement>(null)
236
+ const angle = usePointerLight(ref)
178
237
 
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
238
  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>
239
+ <div ref={ref} className="relative size-20 rounded-full bg-stone-300">
240
+ <Rim angle={angle} className="rounded-full" />
241
+ </div>
189
242
  )
190
243
  }
191
244
  ```
192
245
 
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:
246
+ 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
247
 
241
- ```bash
242
- make example-dev # build the library, install the example, start Vite
243
- make example-build # production build, what CI runs
244
- ```
248
+ `<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.
245
249
 
246
- A focused `demo/` app shows just the apply steps and the hover behaviour
247
- (cards lift, glow buttons, press sink, pointer-following light and rims):
250
+ `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.
248
251
 
249
- ```bash
250
- make demo-dev # build the library, install the demo, start Vite
252
+ In apps with React Server Components, put components that call these hooks in a `"use client"` file, as shown above.
253
+
254
+ #### Optional: adapt a rim to an image
255
+
256
+ Tone helpers adjust highlight/shadow strength to the image's brightness. They are not required for pointer tracking or for `<Rim>` to render.
257
+
258
+ ```tsx
259
+ "use client"
260
+
261
+ import { useRef } from "react"
262
+ import { Rim, toneToAlphas, usePointerLight, useRimTone } from "@exegia/specular/react"
263
+
264
+ export function LitAvatar({ src, alt }: { src: string; alt: string }) {
265
+ const ref = useRef<HTMLDivElement>(null)
266
+ const angle = usePointerLight(ref)
267
+ const tone = useRimTone(src)
268
+ const alphas = tone === null ? {} : toneToAlphas(tone)
269
+
270
+ return (
271
+ <div ref={ref} className="relative size-20 rounded-full">
272
+ <img src={src} alt={alt} className="size-full rounded-full object-cover" />
273
+ <Rim angle={angle} {...alphas} className="rounded-full" />
274
+ </div>
275
+ )
276
+ }
251
277
  ```
252
278
 
253
- See [`examples/vite-react/README.md`](examples/vite-react/README.md) for what
254
- each section shows.
279
+ `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.
280
+
281
+ `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.
282
+
283
+ 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.
255
284
 
256
285
  ## Development
257
286
 
258
287
  ```bash
259
- make ci # lint (tsc), build, test, smoke
260
- make help # every target, including the release pipeline
288
+ make ci
289
+ make example-dev
261
290
  ```
262
291
 
263
- Tests run on `node --test` with no framework. The Tailwind harness compiles
264
- in memory in under a second. See `.github/WORKFLOW.md` for branching and
265
- releases.
292
+ The [example site](examples/vite-react) is the complete visual reference.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exegia/specular",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/emmanuel-defreitas/specular.git"