@exegia/specular 0.1.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 +236 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +85 -0
- package/dist/merge.d.ts +31 -0
- package/dist/merge.js +63 -0
- package/dist/plugin.d.ts +52 -0
- package/dist/plugin.js +94 -0
- package/dist/pointer-light.d.ts +49 -0
- package/dist/pointer-light.js +109 -0
- package/dist/react.d.ts +3 -0
- package/dist/react.js +4 -0
- package/dist/rim.d.ts +26 -0
- package/dist/rim.js +30 -0
- package/dist/tone.d.ts +44 -0
- package/dist/tone.js +123 -0
- package/package.json +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# @exegia/specular
|
|
2
|
+
|
|
3
|
+
Specular lighting for Tailwind v4 + React: a highlight that tracks the pointer.
|
|
4
|
+
Declare a surface, get a utility
|
|
5
|
+
family that draws a two-layer inset "bezel" one axis at a time, a
|
|
6
|
+
tailwind-merge config that keeps those utilities alive in `cn()`, and React
|
|
7
|
+
primitives that light the surface with the pointer.
|
|
8
|
+
|
|
9
|
+
| export | contents |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `@exegia/specular` | `defineSurfaces` + types — the shared module both consumers import |
|
|
12
|
+
| `@exegia/specular/plugin` | `createPlugin(surfaces)` — Tailwind v4 plugin factory, pure JS |
|
|
13
|
+
| `@exegia/specular/merge` | `mergeConfig(surfaces)`, `createCn(surfaces)` — tailwind-merge |
|
|
14
|
+
| `@exegia/specular/react` | `usePointerLight`, `<Rim>`, `measureRimTone`, `toneToAlphas`, `useRimTone` |
|
|
15
|
+
|
|
16
|
+
Peers: `tailwindcss@^4.1 <5`; `react@^19` and `tailwind-merge@^3` only if you
|
|
17
|
+
use `/react` or `/merge`. No runtime dependencies.
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
One config, two consumers. It has to be a JS module: `@plugin` options are
|
|
22
|
+
flat scalars only, and tailwind-merge runs in the browser.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// bezel.config.ts
|
|
26
|
+
import { defineSurfaces } from "@exegia/specular"
|
|
27
|
+
|
|
28
|
+
export const surfaces = defineSurfaces({
|
|
29
|
+
bezel: {
|
|
30
|
+
layers: {
|
|
31
|
+
lit: { color: "#fff", y: 2, blur: 3, alpha: 80, dark: { y: 1, blur: 1, alpha: 5 } },
|
|
32
|
+
dim: { color: "#000", y: -1, blur: 2, alpha: 15, dark: { y: -8, alpha: 20 } },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
card: {
|
|
36
|
+
layers: {
|
|
37
|
+
lit: { inset: true, color: "#fff", y: 1, alpha: 60 },
|
|
38
|
+
drop: { inset: false, color: "#000", y: 4, blur: 8, alpha: 15 },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
// tailwind.plugin.js
|
|
46
|
+
import { createPlugin } from "@exegia/specular/plugin"
|
|
47
|
+
import { surfaces } from "./bezel.config"
|
|
48
|
+
export default createPlugin(surfaces)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```css
|
|
52
|
+
@import "tailwindcss";
|
|
53
|
+
@plugin "./tailwind.plugin.js";
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// lib/utils.ts
|
|
58
|
+
import { createCn } from "@exegia/specular/merge"
|
|
59
|
+
import { surfaces } from "./bezel.config"
|
|
60
|
+
export const cn = createCn(surfaces)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Then: `<div class="bezel-lit bezel-dim rounded-full" />`. The `dark` blocks
|
|
64
|
+
land in a `.dark { --bezel-lit-y: … }` token block, so the element carries no
|
|
65
|
+
`dark:` classes. Set `darkSelector` per surface for other strategies
|
|
66
|
+
(`"@media (prefers-color-scheme: dark)"` wraps `:root`).
|
|
67
|
+
|
|
68
|
+
### Layer config
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
type LayerConfig = {
|
|
72
|
+
inset?: boolean // default true; false = outer drop layer
|
|
73
|
+
color?: string // default "#fff" for the first layer, "#000" after
|
|
74
|
+
x?: number | string // px number or any CSS length; default 0
|
|
75
|
+
y?: number | string
|
|
76
|
+
blur?: number | string // default 0
|
|
77
|
+
spread?: number | string // default 0 — the knob that turns a hairline into a ring
|
|
78
|
+
alpha?: number // 0-100, default 100
|
|
79
|
+
dark?: Partial<Omit<LayerConfig, "inset" | "dark">>
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Layer order in the map is paint order in `box-shadow`. `defineSurfaces` throws
|
|
84
|
+
on a surface named after a core Tailwind root (`shadow`, `inset-shadow`,
|
|
85
|
+
`ring`, `blur`, …), on names with `/`, `:`, whitespace or uppercase, and on a
|
|
86
|
+
layer named `blur`, `spread`, `base` or `color`.
|
|
87
|
+
|
|
88
|
+
> Dark mode is not the light values dimmed. On a dark page the white highlight
|
|
89
|
+
> has to all but vanish (5% against 80%) while the shadow grows long and soft
|
|
90
|
+
> (8px against 1px), or the disc reads as a lit dome on black.
|
|
91
|
+
|
|
92
|
+
## Utilities
|
|
93
|
+
|
|
94
|
+
For a surface `S` and a layer `L`. Every utility also emits the base
|
|
95
|
+
`box-shadow`, so any single class stands alone.
|
|
96
|
+
|
|
97
|
+
| utility | writes |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `S-base` (or `baseClass`) | `box-shadow: var(--tw-S-shadow)` |
|
|
100
|
+
| `S-L` | `--tw-S-L-alpha: 100` |
|
|
101
|
+
| `S-L/40`, `S-L/[0.4]` | `--tw-S-L-alpha: 40` (fractions are scaled; `/80` ≡ `/[0.8]`) |
|
|
102
|
+
| `S-L-t-N` | `--tw-S-L-y: calc(N * 1px)` — light enters from the top, pushes the layer down |
|
|
103
|
+
| `S-L-b-N` | `--tw-S-L-y: calc(N * -1px)` |
|
|
104
|
+
| `S-L-l-N` / `S-L-r-N` | `--tw-S-L-x` |
|
|
105
|
+
| `S-L-blur-N`, `S-L-spread-N` | `--tw-S-L-blur`, `--tw-S-L-spread` |
|
|
106
|
+
| `S-L-color-red-500`, `S-L-color-[#fff]` | `--tw-S-L-color` |
|
|
107
|
+
| `S-blur-N`, `S-spread-N` | every layer at once |
|
|
108
|
+
|
|
109
|
+
`N` is a bare integer, a decimal, or `[<length>]`. `-t-`/`-b-` write the same
|
|
110
|
+
variable, as do `-l-`/`-r-`: pick one per layer per axis. `-t-[2]` passes
|
|
111
|
+
through as the unitless `2`, which is invalid in `box-shadow` — core behaves
|
|
112
|
+
the same.
|
|
113
|
+
|
|
114
|
+
### How it works: three tiers
|
|
115
|
+
|
|
116
|
+
| tier | name | set by | inherits |
|
|
117
|
+
|---|---|---|---|
|
|
118
|
+
| composed | `--tw-S-shadow` | universal selector in `@layer base` | set everywhere |
|
|
119
|
+
| instance | `--tw-S-L-{x,y,blur,spread,color,alpha}` | utilities; `@property { syntax: "*"; inherits: false }`, no `initial-value` | no |
|
|
120
|
+
| preset | `--S-L-{…}` | the `dark` block, or your CSS | yes |
|
|
121
|
+
|
|
122
|
+
Each component is `var(--tw-S-L-y, var(--S-L-y, <default>))`: the utility's
|
|
123
|
+
value if set on this element, else the inherited preset, else the config
|
|
124
|
+
default. Registering the instance tier without an `initial-value` gives it
|
|
125
|
+
the guaranteed-invalid value, so `var()` falls through; `inherits: false`
|
|
126
|
+
stops a nested bezel from picking up its ancestor's offsets. Verified in
|
|
127
|
+
Chromium 152 (a registered `--x: 20px` on the parent leaves the child at the
|
|
128
|
+
fallback; an unregistered one leaks). Not yet verified in WebKit — if it
|
|
129
|
+
ever leaks there, drop the `@property` block and everything else still works.
|
|
130
|
+
|
|
131
|
+
## `cn()` and why the merge config ships here
|
|
132
|
+
|
|
133
|
+
`twMerge("inset-shadow-lit-t-2 inset-shadow-dim-b-1")` with stock
|
|
134
|
+
tailwind-merge returns `"inset-shadow-dim-b-1"`: any custom utility whose
|
|
135
|
+
name shares a prefix with a core group is filed under that group and only the
|
|
136
|
+
last survives. That shipped once — the CSS compiled, a `toContain` test
|
|
137
|
+
passed on the survivor, and the lit layer never reached the screen.
|
|
138
|
+
|
|
139
|
+
`mergeConfig(surfaces)` generates one class group per custom property,
|
|
140
|
+
bidirectional conflicts with core `shadow`/`inset-shadow`, all-layer groups
|
|
141
|
+
that displace their per-layer twins, and the full cross-surface matrix (two
|
|
142
|
+
surfaces on one element means one `box-shadow` wins outright). Group ids are
|
|
143
|
+
a template-literal union, so a typo in your own `conflictingClassGroups` is a
|
|
144
|
+
compile error.
|
|
145
|
+
|
|
146
|
+
`createCn` accepts what tailwind-merge accepts (strings, arrays, falsy). For
|
|
147
|
+
clsx's object form: `extendTailwindMerge(mergeConfig(surfaces))(clsx(...))`.
|
|
148
|
+
|
|
149
|
+
## React
|
|
150
|
+
|
|
151
|
+
Every entry carries `"use client"`. SSR renders the static emboss; nothing
|
|
152
|
+
touches `window` at module scope.
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
import { usePointerLight, Rim, useRimTone, toneToAlphas } from "@exegia/specular/react"
|
|
156
|
+
|
|
157
|
+
function Avatar({ src }: { src: string }) {
|
|
158
|
+
const ref = useRef<HTMLSpanElement>(null)
|
|
159
|
+
const angle = usePointerLight(ref) // raw bearing, 0 = 12 o'clock
|
|
160
|
+
const tone = useRimTone(src) // 0–1 rim lightness, or null
|
|
161
|
+
const alphas = tone === null ? undefined : toneToAlphas(tone)
|
|
162
|
+
return (
|
|
163
|
+
<span ref={ref} className="relative size-16 rounded-full bezel-lit bezel-dim">
|
|
164
|
+
<img src={src} className="size-full rounded-full" />
|
|
165
|
+
<Rim angle={angle * 0.8} hi={alphas?.hi} lo={alphas?.lo} className="rounded-full" />
|
|
166
|
+
</span>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
- **`usePointerLight(ref, options?)`** — one shared window listener and one
|
|
172
|
+
rAF per frame for every subscriber on the page; 1.5° epsilon; shortest-arc
|
|
173
|
+
unwrapping so `rotate()` never spins the long way round; no subscription
|
|
174
|
+
under `prefers-reduced-motion`. Damping is yours (`angle * 0.8`).
|
|
175
|
+
`{ mode: "var", property: "--light-angle" }` writes `<angle>deg` on the
|
|
176
|
+
element with no re-render — the escape hatch for lists.
|
|
177
|
+
- **`<Rim>`** — the masked conic sweep, `rotate()` as the animated property,
|
|
178
|
+
radius-relative mask so it reads the same at 32px and 64px. Radius is
|
|
179
|
+
yours: pass `rounded-full`. Alphas come from `--rim-hi-a` / `--rim-lo-a`
|
|
180
|
+
(light 1 / 0.2 baked in; the plugin emits the dark 0.18 / 0.4 block).
|
|
181
|
+
- **`measureRimTone(src)`** — Rec. 709 luma over the outer band of a 16×16
|
|
182
|
+
sample; `null` on SSR, decode error or a host without CORS headers;
|
|
183
|
+
memoised per `src`, a `null` is evicted so a later mount retries.
|
|
184
|
+
- **`toneToAlphas(t, ends?)`** — `{ hi: 0.7 + 0.3t, lo: 0.6 − 0.4t }`,
|
|
185
|
+
retunable through `ends`.
|
|
186
|
+
|
|
187
|
+
## Things worth knowing
|
|
188
|
+
|
|
189
|
+
- **An inset shadow paints under the root's children.** A `size-full` image
|
|
190
|
+
hides it. That is why `<Rim>` is a separate overlay, and why a fallback
|
|
191
|
+
must stay transparent for the emboss to show.
|
|
192
|
+
- **The family owns `box-shadow`.** Never pair a surface with `shadow-*` or
|
|
193
|
+
`ring-*`; `cn()` deletes one, and raw class strings leave the stylesheet to
|
|
194
|
+
pick an arbitrary winner.
|
|
195
|
+
- **`S-blur-N` and `S-L-blur-N` write the same var.** In the stylesheet (and
|
|
196
|
+
in `@apply`) the winner is Tailwind's sort order, not class order. `cn()`
|
|
197
|
+
resolves it: all-layer displaces per-layer.
|
|
198
|
+
- **Browser floor: `color-mix()`** (Chrome 111, Safari 16.2, Firefox 113).
|
|
199
|
+
Tailwind's fallback branch drops the alpha, so below that the surface
|
|
200
|
+
paints opaque. Accepted: all three are 2023.
|
|
201
|
+
- **`@apply` of a surface inside `@layer components` works.**
|
|
202
|
+
- **Opposite edges of one layer overwrite; they do not add.** Two layers is
|
|
203
|
+
the way to light two edges.
|
|
204
|
+
|
|
205
|
+
## Migrating an existing `inset-shadow-*` family
|
|
206
|
+
|
|
207
|
+
A surface named `inset-shadow` with `baseClass: "inset-shadow-bezel"`,
|
|
208
|
+
`allowCoreCollision: true` and layers `lit` / `dim` reproduces the class API
|
|
209
|
+
exactly (`inset-shadow-lit-t-3 inset-shadow-lit/80 …`), and the merge config
|
|
210
|
+
handles the core collision. The tests cover this shape class by class.
|
|
211
|
+
|
|
212
|
+
## Example app
|
|
213
|
+
|
|
214
|
+
`examples/vite-react` is a Vite + React 19 + Tailwind v4 page that walks
|
|
215
|
+
through every surface, offset, alpha, blur and colour utility, the `glow`
|
|
216
|
+
hover halo, the pointer-lit rims and the `cn()` merge. It runs against the
|
|
217
|
+
library in this checkout:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
make example-dev # build the library, install the example, start Vite
|
|
221
|
+
make example-build # production build, what CI runs
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
See [`examples/vite-react/README.md`](examples/vite-react/README.md) for what
|
|
225
|
+
each section shows.
|
|
226
|
+
|
|
227
|
+
## Development
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
make ci # lint (tsc), build, test, smoke
|
|
231
|
+
make help # every target, including the release pipeline
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Tests run on `node --test` with no framework. The Tailwind harness compiles
|
|
235
|
+
in memory in under a second. See `.github/WORKFLOW.md` for branching and
|
|
236
|
+
releases.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineSurfaces()` — the shared module.
|
|
3
|
+
*
|
|
4
|
+
* Both the Tailwind plugin (`@exegia/specular/plugin`) and the tailwind-merge
|
|
5
|
+
* generator (`@exegia/specular/merge`) consume the same surface config. It has
|
|
6
|
+
* to be a JS module because `@plugin` options are flat scalars only and
|
|
7
|
+
* tailwind-merge runs in the browser where `@plugin` options do not exist.
|
|
8
|
+
* One source of truth, two consumers.
|
|
9
|
+
*/
|
|
10
|
+
export type Length = number | string;
|
|
11
|
+
export type LayerConfig = {
|
|
12
|
+
/** `false` = an outer drop layer. Default `true`. */
|
|
13
|
+
inset?: boolean;
|
|
14
|
+
/** Default `#fff` for the first layer, `#000` after. */
|
|
15
|
+
color?: string;
|
|
16
|
+
/** px when a number, any CSS length as a string. Default 0. */
|
|
17
|
+
x?: Length;
|
|
18
|
+
y?: Length;
|
|
19
|
+
blur?: Length;
|
|
20
|
+
/** The knob that turns a hairline into a ring. Default 0. */
|
|
21
|
+
spread?: Length;
|
|
22
|
+
/** 0–100. Default 100. */
|
|
23
|
+
alpha?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Dark-mode preset, emitted under `darkSelector`.
|
|
26
|
+
*
|
|
27
|
+
* Dark mode is not the light values dimmed. On a dark page the white
|
|
28
|
+
* highlight has to all but vanish (5% against 80%) while the shadow grows
|
|
29
|
+
* long and soft (8px against 1px), or the disc reads as a lit dome on black.
|
|
30
|
+
*/
|
|
31
|
+
dark?: Partial<Omit<LayerConfig, "inset" | "dark">>;
|
|
32
|
+
};
|
|
33
|
+
export type SurfaceConfig = {
|
|
34
|
+
/** Paint order in `box-shadow` is map order. */
|
|
35
|
+
layers: Record<string, LayerConfig>;
|
|
36
|
+
/** Default `${surface}-base`. */
|
|
37
|
+
baseClass?: string;
|
|
38
|
+
/** Where the `dark` presets land. A selector, or an at-rule prelude
|
|
39
|
+
* (`@media (prefers-color-scheme: dark)`) which wraps `:root`. Default `.dark`. */
|
|
40
|
+
darkSelector?: string;
|
|
41
|
+
/** Only for migrating an existing `inset-shadow-*` family in place. */
|
|
42
|
+
allowCoreCollision?: boolean;
|
|
43
|
+
};
|
|
44
|
+
export type Surfaces = Record<string, SurfaceConfig>;
|
|
45
|
+
export declare function defineSurfaces<const T extends Surfaces>(surfaces: T): T;
|
|
46
|
+
export type ResolvedLayer = {
|
|
47
|
+
name: string;
|
|
48
|
+
inset: boolean;
|
|
49
|
+
color: string;
|
|
50
|
+
x: string;
|
|
51
|
+
y: string;
|
|
52
|
+
blur: string;
|
|
53
|
+
spread: string;
|
|
54
|
+
alpha: string;
|
|
55
|
+
dark: Partial<Record<"color" | "x" | "y" | "blur" | "spread" | "alpha", string>>;
|
|
56
|
+
};
|
|
57
|
+
export type ResolvedSurface = {
|
|
58
|
+
name: string;
|
|
59
|
+
baseClass: string;
|
|
60
|
+
darkSelector: string;
|
|
61
|
+
layers: ResolvedLayer[];
|
|
62
|
+
};
|
|
63
|
+
export declare const LAYER_PROPS: readonly ["x", "y", "blur", "spread", "color", "alpha"];
|
|
64
|
+
export type LayerProp = (typeof LAYER_PROPS)[number];
|
|
65
|
+
export declare function length(v: Length | undefined, fallback?: string): string;
|
|
66
|
+
export declare function resolveSurfaces(surfaces: Surfaces): ResolvedSurface[];
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineSurfaces()` — the shared module.
|
|
3
|
+
*
|
|
4
|
+
* Both the Tailwind plugin (`@exegia/specular/plugin`) and the tailwind-merge
|
|
5
|
+
* generator (`@exegia/specular/merge`) consume the same surface config. It has
|
|
6
|
+
* to be a JS module because `@plugin` options are flat scalars only and
|
|
7
|
+
* tailwind-merge runs in the browser where `@plugin` options do not exist.
|
|
8
|
+
* One source of truth, two consumers.
|
|
9
|
+
*/
|
|
10
|
+
/** Tailwind roots our utilities would shadow — and that tailwind-merge would
|
|
11
|
+
* fold ours into (§6). */
|
|
12
|
+
const CORE_ROOTS = new Set([
|
|
13
|
+
"shadow",
|
|
14
|
+
"inset-shadow",
|
|
15
|
+
"ring",
|
|
16
|
+
"inset-ring",
|
|
17
|
+
"blur",
|
|
18
|
+
"drop-shadow",
|
|
19
|
+
"text-shadow",
|
|
20
|
+
"outline",
|
|
21
|
+
]);
|
|
22
|
+
/** Layer names that would collide with the all-layer / base utilities. */
|
|
23
|
+
const RESERVED_LAYERS = new Set(["blur", "spread", "base", "color"]);
|
|
24
|
+
const BAD_NAME = /[/:\s]|[A-Z]/;
|
|
25
|
+
export function defineSurfaces(surfaces) {
|
|
26
|
+
for (const [surface, config] of Object.entries(surfaces)) {
|
|
27
|
+
if (BAD_NAME.test(surface))
|
|
28
|
+
throw new Error(`bezel: surface "${surface}" may not contain "/", ":", whitespace or uppercase`);
|
|
29
|
+
if (CORE_ROOTS.has(surface) && !config.allowCoreCollision)
|
|
30
|
+
throw new Error(`bezel: surface "${surface}" collides with a core Tailwind root; set allowCoreCollision: true only to migrate an existing family in place`);
|
|
31
|
+
const layers = Object.keys(config.layers);
|
|
32
|
+
if (layers.length === 0)
|
|
33
|
+
throw new Error(`bezel: surface "${surface}" declares no layers`);
|
|
34
|
+
for (const layer of layers) {
|
|
35
|
+
if (BAD_NAME.test(layer))
|
|
36
|
+
throw new Error(`bezel: layer "${surface}.${layer}" may not contain "/", ":", whitespace or uppercase`);
|
|
37
|
+
if (RESERVED_LAYERS.has(layer))
|
|
38
|
+
throw new Error(`bezel: layer "${surface}.${layer}" is reserved (blur, spread, base, color)`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return surfaces;
|
|
42
|
+
}
|
|
43
|
+
export const LAYER_PROPS = ["x", "y", "blur", "spread", "color", "alpha"];
|
|
44
|
+
export function length(v, fallback = "0") {
|
|
45
|
+
if (v === undefined)
|
|
46
|
+
return fallback;
|
|
47
|
+
if (typeof v === "number")
|
|
48
|
+
return v === 0 ? "0" : `${v}px`;
|
|
49
|
+
return v;
|
|
50
|
+
}
|
|
51
|
+
export function resolveSurfaces(surfaces) {
|
|
52
|
+
return Object.entries(surfaces).map(([name, config]) => ({
|
|
53
|
+
name,
|
|
54
|
+
baseClass: config.baseClass ?? `${name}-base`,
|
|
55
|
+
darkSelector: config.darkSelector ?? ".dark",
|
|
56
|
+
layers: Object.entries(config.layers).map(([layer, l], i) => {
|
|
57
|
+
const dark = {};
|
|
58
|
+
if (l.dark) {
|
|
59
|
+
if (l.dark.color !== undefined)
|
|
60
|
+
dark.color = l.dark.color;
|
|
61
|
+
if (l.dark.x !== undefined)
|
|
62
|
+
dark.x = length(l.dark.x);
|
|
63
|
+
if (l.dark.y !== undefined)
|
|
64
|
+
dark.y = length(l.dark.y);
|
|
65
|
+
if (l.dark.blur !== undefined)
|
|
66
|
+
dark.blur = length(l.dark.blur);
|
|
67
|
+
if (l.dark.spread !== undefined)
|
|
68
|
+
dark.spread = length(l.dark.spread);
|
|
69
|
+
if (l.dark.alpha !== undefined)
|
|
70
|
+
dark.alpha = String(l.dark.alpha);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
name: layer,
|
|
74
|
+
inset: l.inset ?? true,
|
|
75
|
+
color: l.color ?? (i === 0 ? "#fff" : "#000"),
|
|
76
|
+
x: length(l.x),
|
|
77
|
+
y: length(l.y),
|
|
78
|
+
blur: length(l.blur),
|
|
79
|
+
spread: length(l.spread),
|
|
80
|
+
alpha: String(l.alpha ?? 100),
|
|
81
|
+
dark,
|
|
82
|
+
};
|
|
83
|
+
}),
|
|
84
|
+
}));
|
|
85
|
+
}
|
package/dist/merge.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-merge config generator.
|
|
3
|
+
*
|
|
4
|
+
* `twMerge("inset-shadow-lit-t-2 inset-shadow-dim-b-1")` with stock
|
|
5
|
+
* tailwind-merge returns `"inset-shadow-dim-b-1"`: every unknown class that
|
|
6
|
+
* shares a prefix with a core group is filed under that group, which holds
|
|
7
|
+
* one `box-shadow`, and only the last survives. Utilities and merge config
|
|
8
|
+
* therefore ship as one artifact from one config.
|
|
9
|
+
*
|
|
10
|
+
* One class group per custom property. Every group displaces core `shadow`
|
|
11
|
+
* and `inset-shadow` and is displaced by them; all-layer groups displace
|
|
12
|
+
* their per-layer twins; and the full N×N cross-surface matrix is generated,
|
|
13
|
+
* because two surfaces on one element means one `box-shadow` wins outright.
|
|
14
|
+
*/
|
|
15
|
+
import { type ClassNameValue, type ConfigExtension, type DefaultClassGroupIds, type DefaultThemeGroupIds } from "tailwind-merge";
|
|
16
|
+
import { type LayerProp, type Surfaces } from "./index.ts";
|
|
17
|
+
/** Template-literal union of every group id, so a typo in a consumer's
|
|
18
|
+
* `conflictingClassGroups` is a compile error. */
|
|
19
|
+
export type BezelGroupId<T extends Surfaces> = {
|
|
20
|
+
[S in keyof T & string]: `${S}-base` | `${S}-blur` | `${S}-spread` | {
|
|
21
|
+
[L in keyof T[S]["layers"] & string]: `${S}-${L}-${LayerProp}`;
|
|
22
|
+
}[keyof T[S]["layers"] & string];
|
|
23
|
+
}[keyof T & string];
|
|
24
|
+
export type BezelMergeConfig<T extends Surfaces> = ConfigExtension<DefaultClassGroupIds | BezelGroupId<T>, DefaultThemeGroupIds>;
|
|
25
|
+
export declare function mergeConfig<const T extends Surfaces>(surfaces: T): BezelMergeConfig<T>;
|
|
26
|
+
/**
|
|
27
|
+
* A ready `cn`. Accepts what tailwind-merge accepts: strings, arrays, and
|
|
28
|
+
* falsy values — not clsx's object form. For that, run clsx first:
|
|
29
|
+
* `extendTailwindMerge(mergeConfig(surfaces))(clsx(...))`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createCn<const T extends Surfaces>(surfaces: T): (...inputs: ClassNameValue[]) => string;
|
package/dist/merge.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-merge config generator.
|
|
3
|
+
*
|
|
4
|
+
* `twMerge("inset-shadow-lit-t-2 inset-shadow-dim-b-1")` with stock
|
|
5
|
+
* tailwind-merge returns `"inset-shadow-dim-b-1"`: every unknown class that
|
|
6
|
+
* shares a prefix with a core group is filed under that group, which holds
|
|
7
|
+
* one `box-shadow`, and only the last survives. Utilities and merge config
|
|
8
|
+
* therefore ship as one artifact from one config.
|
|
9
|
+
*
|
|
10
|
+
* One class group per custom property. Every group displaces core `shadow`
|
|
11
|
+
* and `inset-shadow` and is displaced by them; all-layer groups displace
|
|
12
|
+
* their per-layer twins; and the full N×N cross-surface matrix is generated,
|
|
13
|
+
* because two surfaces on one element means one `box-shadow` wins outright.
|
|
14
|
+
*/
|
|
15
|
+
import { extendTailwindMerge, validators, } from "tailwind-merge";
|
|
16
|
+
import { LAYER_PROPS, resolveSurfaces } from "./index.js";
|
|
17
|
+
const { isAny } = validators;
|
|
18
|
+
export function mergeConfig(surfaces) {
|
|
19
|
+
const resolved = resolveSurfaces(surfaces);
|
|
20
|
+
const classGroups = {};
|
|
21
|
+
const conflicts = {};
|
|
22
|
+
const own = new Map();
|
|
23
|
+
for (const s of resolved) {
|
|
24
|
+
const ids = [`${s.name}-base`, `${s.name}-blur`, `${s.name}-spread`];
|
|
25
|
+
classGroups[`${s.name}-base`] = [s.baseClass];
|
|
26
|
+
classGroups[`${s.name}-blur`] = [{ [s.name]: [{ blur: [isAny] }] }];
|
|
27
|
+
classGroups[`${s.name}-spread`] = [{ [s.name]: [{ spread: [isAny] }] }];
|
|
28
|
+
for (const l of s.layers) {
|
|
29
|
+
const n = `${s.name}-${l.name}`;
|
|
30
|
+
classGroups[`${n}-x`] = [{ [n]: [{ l: [isAny], r: [isAny] }] }];
|
|
31
|
+
classGroups[`${n}-y`] = [{ [n]: [{ t: [isAny], b: [isAny] }] }];
|
|
32
|
+
classGroups[`${n}-blur`] = [{ [n]: [{ blur: [isAny] }] }];
|
|
33
|
+
classGroups[`${n}-spread`] = [{ [n]: [{ spread: [isAny] }] }];
|
|
34
|
+
classGroups[`${n}-color`] = [{ [n]: [{ color: [isAny] }] }];
|
|
35
|
+
// The bare class and its `/40` modifier.
|
|
36
|
+
classGroups[`${n}-alpha`] = [n];
|
|
37
|
+
ids.push(...LAYER_PROPS.map((p) => `${n}-${p}`));
|
|
38
|
+
}
|
|
39
|
+
own.set(s.name, ids);
|
|
40
|
+
}
|
|
41
|
+
const all = [...own.values()].flat();
|
|
42
|
+
for (const s of resolved) {
|
|
43
|
+
const mine = own.get(s.name) ?? [];
|
|
44
|
+
const foreign = all.filter((g) => !mine.includes(g));
|
|
45
|
+
for (const g of mine)
|
|
46
|
+
conflicts[g] = ["shadow", "inset-shadow", ...foreign];
|
|
47
|
+
for (const p of ["blur", "spread"])
|
|
48
|
+
conflicts[`${s.name}-${p}`]?.push(...s.layers.map((l) => `${s.name}-${l.name}-${p}`));
|
|
49
|
+
}
|
|
50
|
+
conflicts.shadow = all;
|
|
51
|
+
conflicts["inset-shadow"] = all;
|
|
52
|
+
// Built as plain records; the group ids are typed at the boundary.
|
|
53
|
+
return { extend: { classGroups, conflictingClassGroups: conflicts } };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A ready `cn`. Accepts what tailwind-merge accepts: strings, arrays, and
|
|
57
|
+
* falsy values — not clsx's object form. For that, run clsx first:
|
|
58
|
+
* `extendTailwindMerge(mergeConfig(surfaces))(clsx(...))`.
|
|
59
|
+
*/
|
|
60
|
+
export function createCn(surfaces) {
|
|
61
|
+
const merge = extendTailwindMerge(mergeConfig(surfaces));
|
|
62
|
+
return (...inputs) => merge(...inputs);
|
|
63
|
+
}
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tailwind v4 plugin factory. Pure JS, no CSS file: the whole utility family
|
|
3
|
+
* for every surface comes out of `createPlugin(surfaces)`.
|
|
4
|
+
*
|
|
5
|
+
* // tailwind.plugin.js
|
|
6
|
+
* import { createPlugin } from "@exegia/specular/plugin"
|
|
7
|
+
* import { surfaces } from "./bezel.config"
|
|
8
|
+
* export default createPlugin(surfaces)
|
|
9
|
+
*
|
|
10
|
+
* // app.css
|
|
11
|
+
* @plugin "./tailwind.plugin.js";
|
|
12
|
+
*
|
|
13
|
+
* Three tiers of custom property make presets and dark mode work with no
|
|
14
|
+
* `dark:` classes on the element:
|
|
15
|
+
*
|
|
16
|
+
* composed --tw-{S}-shadow universal selector, @layer base
|
|
17
|
+
* instance --tw-{S}-{L}-{prop} utilities; @property { syntax: "*"; inherits: false }
|
|
18
|
+
* preset --{S}-{L}-{prop} darkSelector block / consumer CSS; inherits
|
|
19
|
+
*
|
|
20
|
+
* Each component in the composed string is `var(instance, var(preset, default))`.
|
|
21
|
+
* The instance tier is registered WITHOUT an initial-value so its initial
|
|
22
|
+
* value is the guaranteed-invalid value and `var()` falls through to the
|
|
23
|
+
* preset; `inherits: false` keeps a nested surface from picking up its
|
|
24
|
+
* ancestor's offsets (verified in Chromium, see README).
|
|
25
|
+
*/
|
|
26
|
+
import type { PluginAPI } from "tailwindcss/plugin";
|
|
27
|
+
import { type ResolvedSurface, type Surfaces } from "./index.ts";
|
|
28
|
+
export type RimTokens = {
|
|
29
|
+
hi: number;
|
|
30
|
+
lo: number;
|
|
31
|
+
};
|
|
32
|
+
export type PluginOptions = {
|
|
33
|
+
/**
|
|
34
|
+
* `<Rim>` reads `--rim-hi-a` / `--rim-lo-a`. Light defaults are the
|
|
35
|
+
* fallbacks baked into the component; the dark block is emitted here so a
|
|
36
|
+
* consumer never writes `dark:` classes on the rim. `false` emits nothing.
|
|
37
|
+
*/
|
|
38
|
+
rim?: {
|
|
39
|
+
darkSelector?: string;
|
|
40
|
+
dark?: RimTokens;
|
|
41
|
+
} | false;
|
|
42
|
+
};
|
|
43
|
+
export declare const RIM_DARK_DEFAULTS: RimTokens;
|
|
44
|
+
/**
|
|
45
|
+
* `/80` and `/[0.8]` both mean 80%. Tailwind hands `/[0.8]` over as `0.8`,
|
|
46
|
+
* which `calc(… * 1%)` would read as 0.8%, so a fraction is scaled up.
|
|
47
|
+
*/
|
|
48
|
+
export declare function normalizeAlpha(raw: string): string;
|
|
49
|
+
/** The whole `box-shadow` for one surface, every component a var chain. */
|
|
50
|
+
export declare function composedShadow(s: ResolvedSurface): string;
|
|
51
|
+
export declare function createPlugin(surfaces: Surfaces, options?: PluginOptions): ({ addBase, addUtilities, matchUtilities, theme }: PluginAPI) => void;
|
|
52
|
+
export default createPlugin;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { LAYER_PROPS, resolveSurfaces, } from "./index.js";
|
|
2
|
+
/** Five selectors, not fewer: `file:bezel-lit-t-2` resolves to
|
|
3
|
+
* `::file-selector-button`, which must see the hoisted var too. */
|
|
4
|
+
const UNIVERSAL = "*, ::before, ::after, ::backdrop, ::file-selector-button";
|
|
5
|
+
export const RIM_DARK_DEFAULTS = { hi: 0.18, lo: 0.4 };
|
|
6
|
+
const BARE = /^\d+(\.\d+)?$/;
|
|
7
|
+
const BARE_NEG = /^calc\(\d+(\.\d+)? \* -1px\)$/;
|
|
8
|
+
/** `-t-2` → `calc(2 * 1px)`, `-t-1.5` → `calc(1.5 * 1px)`; `[0.5rem]` passes through. */
|
|
9
|
+
const bare = (u) => (BARE.test(u.value) ? `calc(${u.value} * 1px)` : undefined);
|
|
10
|
+
/** The opposite edge of the same axis. */
|
|
11
|
+
const bareNeg = (u) => (BARE.test(u.value) ? `calc(${u.value} * -1px)` : undefined);
|
|
12
|
+
const negate = (v) => (BARE_NEG.test(v) ? v : `calc(${v} * -1)`);
|
|
13
|
+
/**
|
|
14
|
+
* `/80` and `/[0.8]` both mean 80%. Tailwind hands `/[0.8]` over as `0.8`,
|
|
15
|
+
* which `calc(… * 1%)` would read as 0.8%, so a fraction is scaled up.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeAlpha(raw) {
|
|
18
|
+
const v = raw.replace(/^\[|\]$/g, "");
|
|
19
|
+
if (/^0?\.\d+$/.test(v))
|
|
20
|
+
return String(Math.round(parseFloat(v) * 10000) / 100);
|
|
21
|
+
return v;
|
|
22
|
+
}
|
|
23
|
+
const instanceVar = (s, l, p) => `--tw-${s}-${l}-${p}`;
|
|
24
|
+
const presetVar = (s, l, p) => `--${s}-${l}-${p}`;
|
|
25
|
+
function chain(s, l, p) {
|
|
26
|
+
return `var(${instanceVar(s, l.name, p)}, var(${presetVar(s, l.name, p)}, ${l[p]}))`;
|
|
27
|
+
}
|
|
28
|
+
/** The whole `box-shadow` for one surface, every component a var chain. */
|
|
29
|
+
export function composedShadow(s) {
|
|
30
|
+
return s.layers
|
|
31
|
+
.map((l) => {
|
|
32
|
+
const c = (p) => chain(s.name, l, p);
|
|
33
|
+
return `${l.inset ? "inset " : ""}${c("x")} ${c("y")} ${c("blur")} ${c("spread")} color-mix(in srgb, ${c("color")} calc(${c("alpha")} * 1%), transparent)`;
|
|
34
|
+
})
|
|
35
|
+
.join(", ");
|
|
36
|
+
}
|
|
37
|
+
/** A selector wraps the declarations; an at-rule prelude wraps `:root`. */
|
|
38
|
+
function darkBlock(selector, decls) {
|
|
39
|
+
return selector.startsWith("@") ? { [selector]: { ":root": decls } } : { [selector]: decls };
|
|
40
|
+
}
|
|
41
|
+
export function createPlugin(surfaces, options = {}) {
|
|
42
|
+
const resolved = resolveSurfaces(surfaces);
|
|
43
|
+
const rim = options.rim === false ? null : { darkSelector: ".dark", dark: RIM_DARK_DEFAULTS, ...options.rim };
|
|
44
|
+
return function bezel({ addBase, addUtilities, matchUtilities, theme }) {
|
|
45
|
+
if (rim) {
|
|
46
|
+
addBase(darkBlock(rim.darkSelector, { "--rim-hi-a": String(rim.dark.hi), "--rim-lo-a": String(rim.dark.lo) }));
|
|
47
|
+
}
|
|
48
|
+
for (const s of resolved) {
|
|
49
|
+
const base = { "box-shadow": `var(--tw-${s.name}-shadow)` };
|
|
50
|
+
// Composed tier, hoisted once. Tailwind splits the color-mix() into a
|
|
51
|
+
// plain fallback plus an @supports branch; doing this per utility
|
|
52
|
+
// would duplicate that pair per utility per surface.
|
|
53
|
+
addBase({ [UNIVERSAL]: { [`--tw-${s.name}-shadow`]: composedShadow(s) } });
|
|
54
|
+
// Instance tier. No initial-value on purpose (see the header).
|
|
55
|
+
const registrations = {};
|
|
56
|
+
for (const l of s.layers)
|
|
57
|
+
for (const p of LAYER_PROPS)
|
|
58
|
+
registrations[`@property ${instanceVar(s.name, l.name, p)}`] = { syntax: '"*"', inherits: "false" };
|
|
59
|
+
addBase(registrations);
|
|
60
|
+
// Preset tier: the dark block.
|
|
61
|
+
const dark = {};
|
|
62
|
+
for (const l of s.layers)
|
|
63
|
+
for (const [p, v] of Object.entries(l.dark))
|
|
64
|
+
dark[presetVar(s.name, l.name, p)] = v;
|
|
65
|
+
if (Object.keys(dark).length > 0)
|
|
66
|
+
addBase(darkBlock(s.darkSelector, dark));
|
|
67
|
+
addUtilities({ [`.${s.baseClass}`]: base });
|
|
68
|
+
// `values` is typed as a string record; the bare-value hook is the one
|
|
69
|
+
// documented exception (`NamedUtilityValue` is not exported).
|
|
70
|
+
const lengths = (neg) => ({
|
|
71
|
+
type: ["number", "length"],
|
|
72
|
+
values: { __BARE_VALUE__: neg ? bareNeg : bare },
|
|
73
|
+
});
|
|
74
|
+
for (const l of s.layers) {
|
|
75
|
+
const n = `${s.name}-${l.name}`;
|
|
76
|
+
const v = (p) => instanceVar(s.name, l.name, p);
|
|
77
|
+
const set = (p, f = (x) => x) => (value) => ({ ...base, [v(p)]: f(value) });
|
|
78
|
+
// Bare = full alpha; `/40` and `/[0.4]` = 40.
|
|
79
|
+
matchUtilities({ [n]: (value, { modifier }) => ({ ...base, [v("alpha")]: normalizeAlpha(modifier ?? value) }) }, { values: { DEFAULT: "100" }, modifiers: "any" });
|
|
80
|
+
// The letter names the edge the light enters from.
|
|
81
|
+
matchUtilities({ [`${n}-t`]: set("y"), [`${n}-l`]: set("x"), [`${n}-blur`]: set("blur"), [`${n}-spread`]: set("spread") }, lengths(false));
|
|
82
|
+
matchUtilities({ [`${n}-b`]: set("y", negate), [`${n}-r`]: set("x", negate) }, lengths(true));
|
|
83
|
+
matchUtilities({ [`${n}-color`]: set("color") }, { type: ["color"], values: theme("colors") });
|
|
84
|
+
}
|
|
85
|
+
// All layers at once.
|
|
86
|
+
const all = (p) => (value) => ({
|
|
87
|
+
...base,
|
|
88
|
+
...Object.fromEntries(s.layers.map((l) => [instanceVar(s.name, l.name, p), value])),
|
|
89
|
+
});
|
|
90
|
+
matchUtilities({ [`${s.name}-blur`]: all("blur"), [`${s.name}-spread`]: all("spread") }, lengths(false));
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export default createPlugin;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pointer → bearing, shared across every lit surface on the page.
|
|
3
|
+
*
|
|
4
|
+
* One window `pointermove` listener and one rAF per frame flush a registry
|
|
5
|
+
* of subscribers, so fifty avatars are one handler, not fifty. Each
|
|
6
|
+
* subscriber keeps its own unwrapped angle so `rotate()` never spins the
|
|
7
|
+
* long way round.
|
|
8
|
+
*/
|
|
9
|
+
import { type RefObject } from "react";
|
|
10
|
+
/** Below this the write is skipped: a rim cannot show a sub-degree change. */
|
|
11
|
+
export declare const ANGLE_EPSILON = 1.5;
|
|
12
|
+
/** Bearing of (x, y) from (cx, cy): 0 = 12 o'clock, clockwise grows. */
|
|
13
|
+
export declare function bearingFrom(cx: number, cy: number, x: number, y: number): number;
|
|
14
|
+
/**
|
|
15
|
+
* Step from `last` toward `bearing` along the shortest arc. The result is
|
|
16
|
+
* continuous (it may exceed 360 or go negative). `null` when the move is
|
|
17
|
+
* under `ANGLE_EPSILON`. A NaN `last` means "no previous value".
|
|
18
|
+
*/
|
|
19
|
+
export declare function unwrap(last: number, bearing: number): number | null;
|
|
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;
|
|
22
|
+
/** For tests. */
|
|
23
|
+
export declare const subscriberCount: () => number;
|
|
24
|
+
/** `false` on the server and on first render; tracks the media query after. */
|
|
25
|
+
export declare function useReducedMotion(): boolean;
|
|
26
|
+
export type PointerLightOptions = {
|
|
27
|
+
mode?: "state";
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
} | {
|
|
30
|
+
mode: "var";
|
|
31
|
+
property?: string;
|
|
32
|
+
enabled?: boolean;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* `mode: "state"` (default) returns the raw bearing and re-renders on change.
|
|
36
|
+
* `mode: "var"` writes `<property>: <angle>deg` (default `--light-angle`) on
|
|
37
|
+
* the element with `style.setProperty` and never re-renders — the escape
|
|
38
|
+
* hatch for lists. Damping stays with the consumer: `rotate(angle * 0.8)`.
|
|
39
|
+
* Reduced motion: no subscription, `0`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function usePointerLight(ref: RefObject<Element | null>, options?: {
|
|
42
|
+
mode?: "state";
|
|
43
|
+
enabled?: boolean;
|
|
44
|
+
}): number;
|
|
45
|
+
export declare function usePointerLight(ref: RefObject<Element | null>, options: {
|
|
46
|
+
mode: "var";
|
|
47
|
+
property?: string;
|
|
48
|
+
enabled?: boolean;
|
|
49
|
+
}): void;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* Pointer → bearing, shared across every lit surface on the page.
|
|
4
|
+
*
|
|
5
|
+
* One window `pointermove` listener and one rAF per frame flush a registry
|
|
6
|
+
* of subscribers, so fifty avatars are one handler, not fifty. Each
|
|
7
|
+
* subscriber keeps its own unwrapped angle so `rotate()` never spins the
|
|
8
|
+
* long way round.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useState } from "react";
|
|
11
|
+
/** Below this the write is skipped: a rim cannot show a sub-degree change. */
|
|
12
|
+
export const ANGLE_EPSILON = 1.5;
|
|
13
|
+
/** Bearing of (x, y) from (cx, cy): 0 = 12 o'clock, clockwise grows. */
|
|
14
|
+
export function bearingFrom(cx, cy, x, y) {
|
|
15
|
+
// atan2 measures from 3 o'clock anticlockwise-positive in screen space.
|
|
16
|
+
return ((Math.atan2(y - cy, x - cx) * 180) / Math.PI + 90 + 360) % 360;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Step from `last` toward `bearing` along the shortest arc. The result is
|
|
20
|
+
* continuous (it may exceed 360 or go negative). `null` when the move is
|
|
21
|
+
* under `ANGLE_EPSILON`. A NaN `last` means "no previous value".
|
|
22
|
+
*/
|
|
23
|
+
export function unwrap(last, bearing) {
|
|
24
|
+
if (Number.isNaN(last))
|
|
25
|
+
return bearing;
|
|
26
|
+
let delta = bearing - (((last % 360) + 360) % 360);
|
|
27
|
+
if (delta > 180)
|
|
28
|
+
delta -= 360;
|
|
29
|
+
if (delta < -180)
|
|
30
|
+
delta += 360;
|
|
31
|
+
if (Math.abs(delta) < ANGLE_EPSILON)
|
|
32
|
+
return null;
|
|
33
|
+
return last + delta;
|
|
34
|
+
}
|
|
35
|
+
const subscribers = new Set();
|
|
36
|
+
let frame = 0;
|
|
37
|
+
let pointer = null;
|
|
38
|
+
function flush() {
|
|
39
|
+
frame = 0;
|
|
40
|
+
if (!pointer)
|
|
41
|
+
return;
|
|
42
|
+
for (const s of subscribers) {
|
|
43
|
+
const el = s.target();
|
|
44
|
+
if (!el)
|
|
45
|
+
continue;
|
|
46
|
+
const rect = el.getBoundingClientRect();
|
|
47
|
+
if (rect.width === 0)
|
|
48
|
+
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)
|
|
51
|
+
continue;
|
|
52
|
+
s.last = next;
|
|
53
|
+
s.emit(next);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function onMove(event) {
|
|
57
|
+
pointer = { x: event.clientX, y: event.clientY };
|
|
58
|
+
if (frame === 0)
|
|
59
|
+
frame = requestAnimationFrame(flush);
|
|
60
|
+
}
|
|
61
|
+
/** 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 };
|
|
64
|
+
if (subscribers.size === 0)
|
|
65
|
+
window.addEventListener("pointermove", onMove, { passive: true });
|
|
66
|
+
subscribers.add(sub);
|
|
67
|
+
return () => {
|
|
68
|
+
subscribers.delete(sub);
|
|
69
|
+
if (subscribers.size === 0) {
|
|
70
|
+
window.removeEventListener("pointermove", onMove);
|
|
71
|
+
if (frame !== 0)
|
|
72
|
+
cancelAnimationFrame(frame);
|
|
73
|
+
frame = 0;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** For tests. */
|
|
78
|
+
export const subscriberCount = () => subscribers.size;
|
|
79
|
+
const REDUCE = "(prefers-reduced-motion: reduce)";
|
|
80
|
+
/** `false` on the server and on first render; tracks the media query after. */
|
|
81
|
+
export function useReducedMotion() {
|
|
82
|
+
const [reduce, setReduce] = useState(false);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (typeof window.matchMedia !== "function")
|
|
85
|
+
return;
|
|
86
|
+
const mq = window.matchMedia(REDUCE);
|
|
87
|
+
const update = () => setReduce(mq.matches);
|
|
88
|
+
update();
|
|
89
|
+
mq.addEventListener("change", update);
|
|
90
|
+
return () => mq.removeEventListener("change", update);
|
|
91
|
+
}, []);
|
|
92
|
+
return reduce;
|
|
93
|
+
}
|
|
94
|
+
export function usePointerLight(ref, options = {}) {
|
|
95
|
+
const [angle, setAngle] = useState(0);
|
|
96
|
+
const reduce = useReducedMotion();
|
|
97
|
+
const enabled = options.enabled ?? true;
|
|
98
|
+
const mode = options.mode ?? "state";
|
|
99
|
+
const property = options.mode === "var" ? (options.property ?? "--light-angle") : null;
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!enabled || reduce)
|
|
102
|
+
return;
|
|
103
|
+
const emit = property === null
|
|
104
|
+
? setAngle
|
|
105
|
+
: (a) => ref.current?.style.setProperty(property, `${a}deg`);
|
|
106
|
+
return subscribe(() => ref.current, emit);
|
|
107
|
+
}, [enabled, reduce, property, ref]);
|
|
108
|
+
return mode === "state" ? angle : undefined;
|
|
109
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { usePointerLight, useReducedMotion, ANGLE_EPSILON, type PointerLightOptions } from "./pointer-light.ts";
|
|
2
|
+
export { Rim, RIM_TRANSITION, type RimProps } from "./rim.tsx";
|
|
3
|
+
export { measureRimTone, toneToAlphas, useRimTone, DEFAULT_TONE_ENDS, type ToneAlphas, type ToneEnds, } from "./tone.ts";
|
package/dist/react.js
ADDED
package/dist/rim.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The masked conic sweep that rotates toward the pointer.
|
|
3
|
+
*
|
|
4
|
+
* <Rim angle={angle * 0.8} hi={0.7} lo={0.4} className="rounded-full" />
|
|
5
|
+
*
|
|
6
|
+
* An inset shadow paints under the root's children, so a `size-full` image
|
|
7
|
+
* hides the emboss completely; this overlay sits above the image instead.
|
|
8
|
+
* The gradient starts at 6 o'clock with the highlight peaking at 12, so
|
|
9
|
+
* `rotate(angle)` puts the highlight exactly `angle` degrees clockwise from
|
|
10
|
+
* 12. Rotation is the animated property, not the gradient's `from` angle:
|
|
11
|
+
* `transform` transitions, a gradient angle does not. The mask is
|
|
12
|
+
* radius-relative so the rim reads the same at 32px and 64px. Radius is the
|
|
13
|
+
* consumer's: pass `rounded-full` or `rounded-[20px]`.
|
|
14
|
+
*/
|
|
15
|
+
import type { HTMLAttributes } from "react";
|
|
16
|
+
export type RimProps = Omit<HTMLAttributes<HTMLSpanElement>, "children"> & {
|
|
17
|
+
/** Degrees clockwise from 12 o'clock. Apply damping before passing. */
|
|
18
|
+
angle?: number;
|
|
19
|
+
/** Highlight alpha, 0–1. Unset = `--rim-hi-a` (light 1, dark 0.18 via the plugin). */
|
|
20
|
+
hi?: number;
|
|
21
|
+
/** Shadow alpha, 0–1. Unset = `--rim-lo-a` (light 0.2, dark 0.4 via the plugin). */
|
|
22
|
+
lo?: number;
|
|
23
|
+
};
|
|
24
|
+
/** transitions.dev `--duration-quick` / `--ease-smooth-out`. */
|
|
25
|
+
export declare const RIM_TRANSITION = "transform 150ms cubic-bezier(0.22, 1, 0.36, 1)";
|
|
26
|
+
export declare function Rim({ angle, hi, lo, style, ...rest }: RimProps): import("react").JSX.Element;
|
package/dist/rim.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useReducedMotion } from "./pointer-light.js";
|
|
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
|
+
const MASK = "radial-gradient(farthest-side, transparent 78%, #000 92%)";
|
|
6
|
+
/** transitions.dev `--duration-quick` / `--ease-smooth-out`. */
|
|
7
|
+
export const RIM_TRANSITION = "transform 150ms cubic-bezier(0.22, 1, 0.36, 1)";
|
|
8
|
+
export function Rim({ angle = 0, hi, lo, style, ...rest }) {
|
|
9
|
+
const reduce = useReducedMotion();
|
|
10
|
+
const vars = {
|
|
11
|
+
"--rim-hi": "color-mix(in oklab, white calc(var(--rim-hi-a, 1) * 50%), transparent)",
|
|
12
|
+
"--rim-lo": "color-mix(in oklab, black calc(var(--rim-lo-a, 0.2) * 50%), transparent)",
|
|
13
|
+
};
|
|
14
|
+
if (hi !== undefined)
|
|
15
|
+
vars["--rim-hi-a"] = String(hi);
|
|
16
|
+
if (lo !== undefined)
|
|
17
|
+
vars["--rim-lo-a"] = String(lo);
|
|
18
|
+
return (_jsx("span", { "aria-hidden": "true", "data-slot": "rim", ...rest, style: {
|
|
19
|
+
position: "absolute",
|
|
20
|
+
inset: 0,
|
|
21
|
+
pointerEvents: "none",
|
|
22
|
+
background: GRADIENT,
|
|
23
|
+
maskImage: MASK,
|
|
24
|
+
WebkitMaskImage: MASK,
|
|
25
|
+
transition: reduce ? "none" : RIM_TRANSITION,
|
|
26
|
+
transform: `rotate(${angle}deg)`,
|
|
27
|
+
...vars,
|
|
28
|
+
...style,
|
|
29
|
+
} }));
|
|
30
|
+
}
|
package/dist/tone.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** 16² px is enough to average a rim. */
|
|
2
|
+
export declare const TONE_SAMPLE_SIZE = 16;
|
|
3
|
+
/** Pixels this far (in radii) from the centre count as rim — the band the
|
|
4
|
+
* bezel sits on. Matches the ring's mask, which fades in from ~78%. */
|
|
5
|
+
export declare const TONE_RIM_RADIUS = 0.6;
|
|
6
|
+
/**
|
|
7
|
+
* Rec. 709 luma, 0 (black) → 1 (white), averaged over the pixels outside
|
|
8
|
+
* `TONE_RIM_RADIUS × radius` of a `size × size` RGBA buffer. Pure, so the
|
|
9
|
+
* maths is testable without a canvas.
|
|
10
|
+
*/
|
|
11
|
+
export declare function rimLuma(data: ArrayLike<number>, size?: number): number | null;
|
|
12
|
+
/** Exported for tests. A `null` result is evicted so a later mount retries. */
|
|
13
|
+
export declare const toneCache: Map<string, Promise<number | null>>;
|
|
14
|
+
/**
|
|
15
|
+
* Average perceived lightness of an image's outer band, or `null` when it
|
|
16
|
+
* cannot be measured: no DOM/canvas (SSR), a decode failure, or a host
|
|
17
|
+
* without CORS headers (a tainted canvas throws on `getImageData`). The
|
|
18
|
+
* image's own `<img>` is untouched, so a non-CORS host still displays.
|
|
19
|
+
* Memoised per `src`; a `null` is not kept.
|
|
20
|
+
*/
|
|
21
|
+
export declare function measureRimTone(src: string): Promise<number | null>;
|
|
22
|
+
export type ToneAlphas = {
|
|
23
|
+
hi: number;
|
|
24
|
+
lo: number;
|
|
25
|
+
};
|
|
26
|
+
/** Alphas at tone 0 (a black rim) and tone 1 (a white rim); linear between. */
|
|
27
|
+
export type ToneEnds = {
|
|
28
|
+
dark: ToneAlphas;
|
|
29
|
+
light: ToneAlphas;
|
|
30
|
+
};
|
|
31
|
+
/** Tuned by eye on a dark and a light photo in both themes. */
|
|
32
|
+
export declare const DEFAULT_TONE_ENDS: ToneEnds;
|
|
33
|
+
/**
|
|
34
|
+
* Against a dark rim the white highlight glares, so it eases off and the
|
|
35
|
+
* shadow pushes up; against a pale rim the reverse. `tone` is clamped to
|
|
36
|
+
* [0, 1]. Defaults: `{ hi: 0.7 + 0.3t, lo: 0.6 − 0.4t }`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function toneToAlphas(tone: number, ends?: ToneEnds): ToneAlphas;
|
|
39
|
+
/**
|
|
40
|
+
* `measureRimTone` as state. Resets to `null` whenever `src` changes so a
|
|
41
|
+
* stale tone never lights the new image for a frame; a slow sample that
|
|
42
|
+
* lands after a change is dropped. Pass `enabled = status === "loaded"`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function useRimTone(src: string | undefined, enabled?: boolean): number | null;
|
package/dist/tone.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* Image tone sampling: how light is the band the rim overlays?
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
/** 16² px is enough to average a rim. */
|
|
7
|
+
export const TONE_SAMPLE_SIZE = 16;
|
|
8
|
+
/** Pixels this far (in radii) from the centre count as rim — the band the
|
|
9
|
+
* bezel sits on. Matches the ring's mask, which fades in from ~78%. */
|
|
10
|
+
export const TONE_RIM_RADIUS = 0.6;
|
|
11
|
+
/**
|
|
12
|
+
* Rec. 709 luma, 0 (black) → 1 (white), averaged over the pixels outside
|
|
13
|
+
* `TONE_RIM_RADIUS × radius` of a `size × size` RGBA buffer. Pure, so the
|
|
14
|
+
* maths is testable without a canvas.
|
|
15
|
+
*/
|
|
16
|
+
export function rimLuma(data, size = TONE_SAMPLE_SIZE) {
|
|
17
|
+
const centre = (size - 1) / 2;
|
|
18
|
+
const rim = TONE_RIM_RADIUS * centre;
|
|
19
|
+
let sum = 0;
|
|
20
|
+
let count = 0;
|
|
21
|
+
for (let y = 0; y < size; y++) {
|
|
22
|
+
for (let x = 0; x < size; x++) {
|
|
23
|
+
const dx = x - centre;
|
|
24
|
+
const dy = y - centre;
|
|
25
|
+
if (dx * dx + dy * dy < rim * rim)
|
|
26
|
+
continue;
|
|
27
|
+
const o = (y * size + x) * 4;
|
|
28
|
+
const r = data[o] ?? 0;
|
|
29
|
+
const g = data[o + 1] ?? 0;
|
|
30
|
+
const b = data[o + 2] ?? 0;
|
|
31
|
+
sum += (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
32
|
+
count++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return count === 0 ? null : sum / count;
|
|
36
|
+
}
|
|
37
|
+
/** Exported for tests. A `null` result is evicted so a later mount retries. */
|
|
38
|
+
export const toneCache = new Map();
|
|
39
|
+
/**
|
|
40
|
+
* Average perceived lightness of an image's outer band, or `null` when it
|
|
41
|
+
* cannot be measured: no DOM/canvas (SSR), a decode failure, or a host
|
|
42
|
+
* without CORS headers (a tainted canvas throws on `getImageData`). The
|
|
43
|
+
* image's own `<img>` is untouched, so a non-CORS host still displays.
|
|
44
|
+
* Memoised per `src`; a `null` is not kept.
|
|
45
|
+
*/
|
|
46
|
+
export function measureRimTone(src) {
|
|
47
|
+
if (!src)
|
|
48
|
+
return Promise.resolve(null);
|
|
49
|
+
const cached = toneCache.get(src);
|
|
50
|
+
if (cached)
|
|
51
|
+
return cached;
|
|
52
|
+
const promise = new Promise((resolve) => {
|
|
53
|
+
if (typeof document === "undefined" || typeof Image === "undefined") {
|
|
54
|
+
resolve(null);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const image = new Image();
|
|
58
|
+
image.crossOrigin = "anonymous";
|
|
59
|
+
image.decoding = "async";
|
|
60
|
+
image.onerror = () => resolve(null);
|
|
61
|
+
image.onload = () => {
|
|
62
|
+
try {
|
|
63
|
+
const size = TONE_SAMPLE_SIZE;
|
|
64
|
+
const canvas = document.createElement("canvas");
|
|
65
|
+
canvas.width = size;
|
|
66
|
+
canvas.height = size;
|
|
67
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
68
|
+
if (!context) {
|
|
69
|
+
resolve(null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
context.drawImage(image, 0, 0, size, size);
|
|
73
|
+
resolve(rimLuma(context.getImageData(0, 0, size, size).data, size));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
resolve(null);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
image.src = src;
|
|
80
|
+
});
|
|
81
|
+
toneCache.set(src, promise);
|
|
82
|
+
void promise.then((tone) => {
|
|
83
|
+
if (tone === null)
|
|
84
|
+
toneCache.delete(src);
|
|
85
|
+
});
|
|
86
|
+
return promise;
|
|
87
|
+
}
|
|
88
|
+
/** Tuned by eye on a dark and a light photo in both themes. */
|
|
89
|
+
export const DEFAULT_TONE_ENDS = { dark: { hi: 0.7, lo: 0.6 }, light: { hi: 1, lo: 0.2 } };
|
|
90
|
+
/**
|
|
91
|
+
* Against a dark rim the white highlight glares, so it eases off and the
|
|
92
|
+
* shadow pushes up; against a pale rim the reverse. `tone` is clamped to
|
|
93
|
+
* [0, 1]. Defaults: `{ hi: 0.7 + 0.3t, lo: 0.6 − 0.4t }`.
|
|
94
|
+
*/
|
|
95
|
+
export function toneToAlphas(tone, ends = DEFAULT_TONE_ENDS) {
|
|
96
|
+
const t = Math.min(1, Math.max(0, tone));
|
|
97
|
+
return {
|
|
98
|
+
hi: ends.dark.hi + (ends.light.hi - ends.dark.hi) * t,
|
|
99
|
+
lo: ends.dark.lo + (ends.light.lo - ends.dark.lo) * t,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* `measureRimTone` as state. Resets to `null` whenever `src` changes so a
|
|
104
|
+
* stale tone never lights the new image for a frame; a slow sample that
|
|
105
|
+
* lands after a change is dropped. Pass `enabled = status === "loaded"`.
|
|
106
|
+
*/
|
|
107
|
+
export function useRimTone(src, enabled = true) {
|
|
108
|
+
const [tone, setTone] = useState(null);
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
setTone(null);
|
|
111
|
+
if (!enabled || !src)
|
|
112
|
+
return;
|
|
113
|
+
let cancelled = false;
|
|
114
|
+
void measureRimTone(src).then((t) => {
|
|
115
|
+
if (!cancelled)
|
|
116
|
+
setTone(t);
|
|
117
|
+
});
|
|
118
|
+
return () => {
|
|
119
|
+
cancelled = true;
|
|
120
|
+
};
|
|
121
|
+
}, [src, enabled]);
|
|
122
|
+
return tone;
|
|
123
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@exegia/specular",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/emmanuel-defreitas/specular.git"
|
|
7
|
+
},
|
|
8
|
+
"description": "Specular lighting for Tailwind v4 + React: pointer-tracking rim highlights, two-layer inset bezels as utilities, and a tailwind-merge config that keeps them alive in cn().",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./plugin": {
|
|
23
|
+
"types": "./dist/plugin.d.ts",
|
|
24
|
+
"import": "./dist/plugin.js"
|
|
25
|
+
},
|
|
26
|
+
"./merge": {
|
|
27
|
+
"types": "./dist/merge.d.ts",
|
|
28
|
+
"import": "./dist/merge.js"
|
|
29
|
+
},
|
|
30
|
+
"./react": {
|
|
31
|
+
"types": "./dist/react.d.ts",
|
|
32
|
+
"import": "./dist/react.js"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.build.json",
|
|
38
|
+
"lint": "tsc --noEmit",
|
|
39
|
+
"test": "node --test \"src/**/*.test.ts\"",
|
|
40
|
+
"smoke": "node --test src/plugin.smoke.test.ts"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"react": "^19",
|
|
44
|
+
"tailwind-merge": "^3",
|
|
45
|
+
"tailwindcss": "^4.1 <5"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"tailwind-merge": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"react": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=22.18"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^24.13.3",
|
|
60
|
+
"@types/react": "^19.2.18",
|
|
61
|
+
"react": "^19.2.8",
|
|
62
|
+
"tailwind-merge": "^3.6.0",
|
|
63
|
+
"tailwindcss": "^4.3.3",
|
|
64
|
+
"typescript": "~5.9"
|
|
65
|
+
}
|
|
66
|
+
}
|