@claralight-design/react 0.0.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 (45) hide show
  1. package/dist/index.d.ts +12 -0
  2. package/dist/index.js +12 -0
  3. package/dist/lib/anchored.d.ts +152 -0
  4. package/dist/lib/anchored.js +821 -0
  5. package/dist/lib/anchored.js.map +1 -0
  6. package/dist/lib/morph.js +484 -0
  7. package/dist/lib/morph.js.map +1 -0
  8. package/dist/lib/squircle.d.ts +95 -0
  9. package/dist/lib/squircle.js +220 -0
  10. package/dist/lib/squircle.js.map +1 -0
  11. package/dist/lib/utils.d.ts +30 -0
  12. package/dist/lib/utils.js +76 -0
  13. package/dist/lib/utils.js.map +1 -0
  14. package/dist/ui/button.d.ts +40 -0
  15. package/dist/ui/button.js +104 -0
  16. package/dist/ui/button.js.map +1 -0
  17. package/dist/ui/card.d.ts +54 -0
  18. package/dist/ui/card.js +82 -0
  19. package/dist/ui/card.js.map +1 -0
  20. package/dist/ui/dialog.d.ts +82 -0
  21. package/dist/ui/dialog.js +129 -0
  22. package/dist/ui/dialog.js.map +1 -0
  23. package/dist/ui/input.d.ts +34 -0
  24. package/dist/ui/input.js +60 -0
  25. package/dist/ui/input.js.map +1 -0
  26. package/dist/ui/popover.d.ts +67 -0
  27. package/dist/ui/popover.js +93 -0
  28. package/dist/ui/popover.js.map +1 -0
  29. package/dist/ui/scroll-area.d.ts +110 -0
  30. package/dist/ui/scroll-area.js +125 -0
  31. package/dist/ui/scroll-area.js.map +1 -0
  32. package/dist/ui/select.d.ts +113 -0
  33. package/dist/ui/select.js +213 -0
  34. package/dist/ui/select.js.map +1 -0
  35. package/dist/ui/tooltip.d.ts +108 -0
  36. package/dist/ui/tooltip.js +142 -0
  37. package/dist/ui/tooltip.js.map +1 -0
  38. package/package.json +72 -0
  39. package/styles/anchored.css +115 -0
  40. package/styles/base.css +309 -0
  41. package/styles/fonts/README.md +63 -0
  42. package/styles/index.css +28 -0
  43. package/styles/scroll-area.css +299 -0
  44. package/styles/theme.css +540 -0
  45. package/styles/tooltip.css +156 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"morph.js","names":[],"sources":["../../src/lib/morph.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n createContext,\n type ReactNode,\n type RefCallback,\n type RefObject,\n useContext,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n} from \"react\";\nimport { tokenNumber } from \"@/lib/utils\";\n\n/**\n * The ClaraLight surface morph.\n *\n * A floating layer does not fade in and it does not pop: it **grows out of the\n * rectangle that opened it**. The layer is laid out at its final size, and the\n * projective transform below carries that box from the source rectangle onto\n * itself, so the fill, the 1px outline, the shadow and the content cross the\n * movement as one object.\n *\n * ## Why a quad, and not a scale\n *\n * A scale about one corner reads as a photograph being enlarged: opposite edges\n * stay parallel, so nothing about the movement says where the layer came from or\n * where it is going. Here the four corners travel on **four different curves** —\n * the further a corner has to go, the earlier it leaves and the longer it takes\n * to arrive — so the quad stops being a rectangle on the way across and reads as\n * a surface turning in space. That is the whole effect; the matrix only keeps the\n * four interpolated corners coherent with each other.\n *\n * The corner curves are the Flutter dialog's, kept verbatim so that the two\n * implementations are one animation rather than two that resemble each other:\n *\n * x1 = 0.35 - 0.20r y1 = 0.85r\n * x2 = 0.45 - 0.20r y2 = 1\n *\n * with `r` the corner's share of the longest journey. At `r = 1` the curve\n * leaves at slope 5.7 and lands softly; at `r = 0` it waits, then catches up at\n * slope 1.8. Every curve is monotonic and ends at 1, so a corner never travels\n * past its target and the layer is only ever *downscaled* on the way in — which\n * matters on Safari, where Lisse's `clip-path` raster is cached at layout size.\n *\n * ## One progress, two legs\n *\n * The content's opacity is not a separate transition. It is read from the same\n * progress the geometry is, `easeOut(t / 0.35)`, which is what lets a dismissal\n * be *watched*: the layer stays opaque through the first two thirds of the way\n * back and collapses visibly into its trigger. On the entrance's own `ease-out`\n * clock instead, the layer is empty before the geometry has moved a fifth of the\n * way, and the exit reads as a fade with no animation in it at all — the corner\n * curves are asymmetric on purpose, so playing them backwards holds the shape\n * near its target until late. A dismissal starts that fade slightly early; see\n * `REVEAL_LEAD`, which is about the frame it is removed on rather than taste.\n *\n * ## Where the transform goes\n *\n * On the wrapper that carries the shape, never on the shape itself: Lisse paints\n * the outline and the shadow as SVG overlays that are siblings of the clipped\n * element, so a transform below the wrapper would move the fill out from under\n * its own border. Same reason as the entrance in `base.css`, which is why this\n * file hands that entrance its geometry instead of adding to it.\n *\n * ## Resting state\n *\n * Both rectangles are expressed relative to the layer's **own resting box**, so\n * `progress = 1` is exactly the identity matrix. Nothing has to agree about\n * pixels later: the inline geometry is dropped once the entrance lands and the\n * CSS state describes the same frame, and reduced motion — which never runs this\n * file at all — is the same layer with no transform present.\n *\n * `cl-morph` is the switch between the two, and it is declared in the markup\n * rather than set from the effect that drives this file: changing the computed\n * `scale` is what starts the CSS transition, so a component that reported itself\n * as morphed a frame late would animate the nudge it was replacing.\n * ## Cost\n *\n * One layout read per leg, then one property write per frame: no measurement, no\n * React render and no layout in the loop. Four corners, four cubic inversions\n * and a 3x3 solve per frame is nothing; what the transform costs is on the\n * compositor, which is where `backdrop-filter` lives. See the dialog's own note\n * on the frost.\n */\n\nexport interface MorphPoint {\n x: number;\n y: number;\n}\n\nexport interface MorphRect extends MorphPoint {\n width: number;\n height: number;\n}\n\nexport interface MorphBox {\n width: number;\n height: number;\n}\n\n/** Top-left, top-right, bottom-left, bottom-right — the order the matrix solves in. */\nexport type MorphQuad = [MorphPoint, MorphPoint, MorphPoint, MorphPoint];\n\n/** A `matrix3d()` argument list, in the order CSS reads it. */\nexport type MorphMatrix = [\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n number,\n];\n\nconst useIsomorphicLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\n/**\n * How small a layer starts when it has no source to grow from.\n *\n * The Flutter dialog does the same thing when its trigger has been disposed\n * between opening and dismissing: a layer with nowhere to go back to shrinks\n * towards its own centre rather than being sent somewhere it never came from.\n */\nconst ORPHAN_SCALE = 0.3;\n\n/** The identity, in `matrix3d()` argument order. */\nconst IDENTITY: MorphMatrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];\n\n/**\n * How far into a leg the content is fully revealed, and how it gets there.\n *\n * The Flutter dialog's `easeOut(t / 0.35)`, kept with the corner curves because\n * it is the same construction: a progress the whole layer shares. The select\n * fades its list on a curve of its own (`pow(t, 0.6)`, on a controller with a\n * duration of its own); this is the one the two surfaces agree on.\n *\n * `Curves.easeOut` in Flutter is `Cubic(0, 0, 0.58, 1)`, which is not the\n * `--ease-cl-out` the design system uses elsewhere — the morph's legs are its own\n * numbers, and only the durations come from tokens.\n */\nconst REVEAL = 0.35;\nconst REVEAL_CURVE = { x1: 0, y1: 0, x2: 0.58, y2: 1 } as const;\n\n/**\n * How far ahead of the geometry the content is gone on a dismissal.\n *\n * Base UI unmounts the layer when the exit sentinel's animation ends, and that end\n * lands in the same rendering update as this file's last frame — earlier in it, in\n * fact, because animation events are dispatched before animation frame callbacks.\n * Whatever that final frame writes is therefore never on screen, and a dismissal's\n * last tenth is its fastest-moving part: the corners hold near the target and then\n * collapse, so the layer would be removed while still plainly visible — measured\n * at 33% opacity, mid-collapse. Starting the fade a frame and a half early leaves\n * the last thing on screen already empty, and puts the disappearance where nothing\n * is moving.\n *\n * Only a dismissal: an entrance has nothing to race, and keeps the Flutter fade\n * exactly as it is.\n */\nconst REVEAL_LEAD = 0.15;\n\n/** A cubic Bézier on one axis, with `P0 = 0` and `P3 = 1`. */\nconst axis = (a: number, b: number, u: number) => {\n const m = 1 - u;\n return 3 * m * m * u * a + 3 * m * u * u * b + u * u * u;\n};\n\n/**\n * `cubic-bezier(x1, y1, x2, y2)` evaluated at `x`, the way CSS evaluates it.\n *\n * The curve is parametric: `x` is not the parameter, so `x` has to be inverted\n * before `y` can be read. Newton converges in a few steps for the control points\n * this file uses, where `x(u)` is monotonic; the bracket keeps a caller's own\n * parameters from stepping off the curve when a slope goes flat.\n */\nfunction bezier(x1: number, y1: number, x2: number, y2: number, x: number): number {\n if (x <= 0) return 0;\n if (x >= 1) return 1;\n let low = 0;\n let high = 1;\n let u = x;\n for (let step = 0; step < 8; step++) {\n const error = axis(x1, x2, u) - x;\n if (Math.abs(error) < 1e-6) break;\n if (error > 0) high = u;\n else low = u;\n const slope = 3 * (1 - u) ** 2 * x1 + 6 * (1 - u) * u * (x2 - x1) + 3 * u ** 2 * (1 - x2);\n if (slope === 0) break;\n u = Math.min(high, Math.max(low, u - error / slope));\n }\n return axis(y1, y2, u);\n}\n\n/**\n * Where one corner is at `progress`, along the straight line from the source\n * rectangle to the target one.\n *\n * The corners do not share a curve, and that is the effect: a corner with a long\n * way to travel leaves early and eases into place, one that barely moves waits\n * and snaps in behind it. Four straight lines on four clocks is what turns a\n * rectangle into a trapezoid on the way across.\n */\nfunction corner(ratio: number, progress: number): number {\n const r = Math.min(1, Math.max(0, ratio));\n return bezier(0.35 - 0.2 * r, 0.85 * r, 0.45 - 0.2 * r, 1, progress);\n}\n\n/**\n * The quad a layer covers at `progress`, in its own resting coordinates.\n *\n * `source` is the rectangle that opened it, relative to the same box. `null`\n * means there is nothing to grow out of, and the layer starts as a small centred\n * rect instead.\n */\nexport function morphQuad(box: MorphBox, source: MorphRect | null, progress: number): MorphQuad {\n const target: MorphQuad = [\n { x: 0, y: 0 },\n { x: box.width, y: 0 },\n { x: 0, y: box.height },\n { x: box.width, y: box.height },\n ];\n const inset = (1 - ORPHAN_SCALE) / 2;\n const from: MorphQuad = source\n ? [\n { x: source.x, y: source.y },\n { x: source.x + source.width, y: source.y },\n { x: source.x, y: source.y + source.height },\n { x: source.x + source.width, y: source.y + source.height },\n ]\n : [\n { x: box.width * inset, y: box.height * inset },\n { x: box.width * (1 - inset), y: box.height * inset },\n { x: box.width * inset, y: box.height * (1 - inset) },\n { x: box.width * (1 - inset), y: box.height * (1 - inset) },\n ];\n\n const clamped = Math.min(1, Math.max(0, progress));\n const journey = (index: 0 | 1 | 2 | 3) =>\n Math.hypot(target[index].x - from[index].x, target[index].y - from[index].y);\n const longest = Math.max(journey(0), journey(1), journey(2), journey(3), 1);\n const landed = (index: 0 | 1 | 2 | 3) => {\n const arrived = corner(journey(index) / longest, clamped);\n return {\n x: from[index].x + (target[index].x - from[index].x) * arrived,\n y: from[index].y + (target[index].y - from[index].y) * arrived,\n };\n };\n\n return [landed(0), landed(1), landed(2), landed(3)];\n}\n\n/**\n * The projective transform that carries a `box` onto `quad`, in `matrix3d()`\n * argument order.\n *\n * This is the Flutter dialog's `_computeHomography`, and the transposition is\n * the one thing that can not be copied literally. Flutter writes its matrix for\n * column vectors — `v' = M·v`, so its translation sits in the last *column* —\n * while CSS transforms a row vector, `(x, y, 0, 1)·M`, and takes its arguments\n * column by column. The two arrays are transposes of each other: the translation\n * ends up at 12..14 in both, which is exactly why the mistake survives a visual\n * check, but the perspective terms are Flutter's `storage[3]` and `storage[7]`\n * and CSS's `args[3]` and `args[7]` — the pair that makes the quad a trapezoid\n * instead of a parallelogram.\n *\n * A quad that has collapsed to a line or a point has no inverse; the perspective\n * terms stay zero and the corners are placed affinely rather than the layer\n * disappearing.\n */\nexport function homographyMatrix(box: MorphBox, quad: MorphQuad): MorphMatrix {\n const [p1, p2, p3, p4] = quad;\n const { width, height } = box;\n if (width <= 0 || height <= 0) return [...IDENTITY];\n\n const a = (p2.x - p4.x) * width;\n const b = (p3.x - p4.x) * height;\n const d = (p2.y - p4.y) * width;\n const e = (p3.y - p4.y) * height;\n const c = p4.x - p2.x - p3.x + p1.x;\n const f = p4.y - p2.y - p3.y + p1.y;\n\n const determinant = a * e - b * d;\n let m30 = 0;\n let m31 = 0;\n if (Math.abs(determinant) > 1e-6) {\n m30 = (c * e - b * f) / determinant;\n m31 = (a * f - c * d) / determinant;\n }\n\n const m00 = (p2.x - p1.x + m30 * width * p2.x) / width;\n const m10 = (p2.y - p1.y + m30 * width * p2.y) / width;\n const m01 = (p3.x - p1.x + m31 * height * p3.x) / height;\n const m11 = (p3.y - p1.y + m31 * height * p3.y) / height;\n\n return [\n flat(m00),\n flat(m10),\n 0,\n flat(m30),\n flat(m01),\n flat(m11),\n 0,\n flat(m31),\n 0,\n 0,\n 1,\n 0,\n p1.x,\n p1.y,\n 0,\n 1,\n ];\n}\n\n/** The transform a layer at `progress` should carry, ready to serialise. */\nexport function morphMatrix(\n box: MorphBox,\n source: MorphRect | null,\n progress: number,\n): MorphMatrix {\n return homographyMatrix(box, morphQuad(box, source, progress));\n}\n\n/* ---------------------------------------------------------------------------\n * The layer\n * ------------------------------------------------------------------------- */\n\nexport interface MorphSource {\n /** The element the layer grows out of. Written by the trigger, read on demand. */\n anchor: RefObject<HTMLElement | null>;\n /** Whether the layer is open, owned by the component that owns the state. */\n open: boolean;\n}\n\nconst MorphSourceContext = createContext<MorphSource | null>(null);\n\n/**\n * Publishes the trigger to the layer it opens, and the open state with it.\n *\n * A ref rather than state: the anchor is already in the DOM by the time a layer\n * needs it, and a render in between would only delay the first measured frame.\n * The value is memoised on `open` alone, so a parent re-render does not detach\n * the ref the trigger registered.\n */\nexport function MorphSourceProvider({ open, children }: { open: boolean; children: ReactNode }) {\n const anchor = useRef<HTMLElement | null>(null);\n const source = useMemo(() => ({ anchor, open }), [open]);\n return <MorphSourceContext.Provider value={source}>{children}</MorphSourceContext.Provider>;\n}\n\nexport function useMorphSource(): MorphSource | null {\n return useContext(MorphSourceContext);\n}\n\n/**\n * Registers the element its layer should grow out of.\n *\n * Returns `null` outside a provider, so a component used without one degrades\n * to the plain CSS entrance instead of failing.\n */\nexport function useMorphAnchor(): RefCallback<HTMLElement> | null {\n const anchor = useContext(MorphSourceContext)?.anchor ?? null;\n return useMemo(() => {\n if (!anchor) return null;\n return (element: HTMLElement | null) => {\n anchor.current = element;\n return () => {\n if (anchor.current === element) anchor.current = null;\n };\n };\n }, [anchor]);\n}\n\nexport interface SurfaceMorphOptions {\n /** The wrapper that carries the layer. The transform is written here. */\n surface: HTMLElement | null;\n /** Resolved at the start of every leg, so a source that moved is followed. */\n anchor?: RefObject<HTMLElement | null> | null;\n /** Whether the layer is open. */\n open: boolean;\n}\n\ninterface Geometry {\n /** The layer's own box, at rest, in viewport coordinates. */\n box: MorphRect;\n /** The rectangle it grows out of, in viewport coordinates. */\n source: MorphRect | null;\n}\n\ninterface Runtime {\n /**\n * The exit sentinel's animation, when the layer has one: the clock Base UI\n * unmounts on, read rather than duplicated.\n *\n * Looked for per frame instead of read once, because a CSS animation is created\n * in the rendering update rather than by a style recalculation: on the frame a\n * dismissal starts, the popup already carries `data-ending-style` and the\n * animation that matches it does not exist yet.\n */\n clock: Animation | null;\n /** The exit sentinel, which is the element the clock will be attached to. */\n sentinel: HTMLElement | null;\n view: Window | null;\n element: HTMLElement | null;\n anchor: RefObject<HTMLElement | null> | null;\n /** Where the layer is now, 0 at the source, 1 at rest. */\n progress: number;\n target: 0 | 1;\n /** Measured once per leg; `null` until a leg finds a source to grow from. */\n geometry: Geometry | null;\n frame: number;\n from: number;\n started: number;\n duration: number;\n enter: number;\n exit: number;\n}\n\n/** Six decimals is past the point a transform is distinguishable and short enough to write. */\nconst round = (value: number) => Number(value.toFixed(6));\n\n/** `-0` is not a different transform, and it is noise in a serialised matrix. */\nconst flat = (value: number) => (value === 0 ? 0 : value);\n\nconst rectOf = (rect: DOMRect): MorphRect => ({\n x: rect.x,\n y: rect.y,\n width: rect.width,\n height: rect.height,\n});\n\n/** The element a layer should grow out of, or `null` when it is no longer there. */\nfunction readSource(anchor: RefObject<HTMLElement | null> | null): MorphRect | null {\n const element = anchor?.current;\n return element?.isConnected ? rectOf(element.getBoundingClientRect()) : null;\n}\n\n/**\n * The exit sentinel, or `null` when the layer has none.\n *\n * The class is part of `base.css`'s own contract — the non-visual animation Base\n * UI can await while the wrapper moves — so it is looked up rather than assumed,\n * and a surface without one keeps the duration token as its clock.\n */\nfunction exitSentinel(element: HTMLElement): HTMLElement | null {\n return element.querySelector(\".cl-exit-sentinel\");\n}\n\n/**\n * The layer's own box and its source, both at rest.\n *\n * The transform is lifted for the read and put straight back: a homography\n * cannot be subtracted out of a rect, and the resting box is the only honest\n * measurement of where the layer belongs. Nothing else runs in between, so the\n * browser paints the frame with the transform in place.\n */\nfunction measure(state: Runtime): Geometry | null {\n const element = state.element;\n if (!element) return null;\n const authored = element.style.transform;\n element.style.transform = \"none\";\n const box = element.getBoundingClientRect();\n element.style.transform = authored;\n return { box: rectOf(box), source: readSource(state.anchor) };\n}\n\n/**\n * Drops the inline geometry, leaving the CSS state to describe the same frame.\n *\n * Opacity goes with it: at rest the CSS value is the same one — the morph's\n * neutralisation sets it to 1 — so nothing is left behind that a consumer's own\n * styles could not override.\n */\nfunction release(element: HTMLElement | null): void {\n element?.style.removeProperty(\"transform\");\n element?.style.removeProperty(\"transform-origin\");\n element?.style.removeProperty(\"opacity\");\n}\n\nfunction paint(state: Runtime): void {\n const element = state.element;\n const geometry = state.geometry;\n if (!element || !geometry) return;\n const { box, source } = geometry;\n const local = source\n ? { x: source.x - box.x, y: source.y - box.y, width: source.width, height: source.height }\n : null;\n const matrix = morphMatrix(box, local, state.progress).map(round);\n const leading = state.clock ? REVEAL_LEAD : 0;\n const revealed = Math.min(1, Math.max(0, (state.progress - leading) / REVEAL));\n element.style.transform = `matrix3d(${matrix.join(\",\")})`;\n element.style.opacity = String(\n round(bezier(REVEAL_CURVE.x1, REVEAL_CURVE.y1, REVEAL_CURVE.x2, REVEAL_CURVE.y2, revealed)),\n );\n}\n\nfunction stop(state: Runtime): void {\n if (state.frame && state.view) state.view.cancelAnimationFrame(state.frame);\n state.frame = 0;\n state.clock = null;\n state.sentinel = null;\n}\n\nfunction step(state: Runtime): void {\n state.frame = 0;\n const view = state.view;\n if (!view) return;\n state.clock ??= state.sentinel?.getAnimations()[0] ?? null;\n // `null` is the animation's before phase, which is this leg's first frame\n // rather than its last: Base UI's animation is created when the state lands and\n // starts on the frame after it.\n const elapsed = state.clock\n ? (state.clock.effect?.getComputedTiming().progress ?? 0)\n : (view.performance.now() - state.started) / state.duration;\n const ratio = Math.min(1, Math.max(0, elapsed));\n state.progress = state.from + (state.target - state.from) * ratio;\n paint(state);\n if (ratio < 1) {\n state.frame = view.requestAnimationFrame(() => step(state));\n return;\n }\n state.progress = state.target;\n state.clock = null;\n state.sentinel = null;\n // At rest the matrix is exactly the identity, so the inline geometry can go.\n // A dismissal keeps it instead: the frame it reached is the frame Base UI\n // unmounts on, and snapping back to full size for it would be a flash.\n if (state.target === 1) release(state.element);\n}\n\n/**\n * Begin a leg towards `target`, from wherever the layer actually is.\n *\n * The duration is the leg's own token scaled by the distance left, so a layer\n * caught mid-flight reverses quickly instead of replaying a full entrance\n * backwards. A dismissal mostly does not use it at all: see `clock` below.\n */\nfunction start(state: Runtime, target: 0 | 1): void {\n const { element, view } = state;\n if (!element || !view) return;\n\n if (state.frame) {\n // A leg already in flight: the box has not moved, but the source may have —\n // a dismissal re-resolves its trigger, which is how a trigger that moved\n // while the layer was open stays connected to it.\n if (state.geometry) state.geometry = { ...state.geometry, source: readSource(state.anchor) };\n } else {\n // A fresh leg measures both, and the tokens with it: a fresh leg is the only\n // place the durations can have changed since the last one.\n const computed = view.getComputedStyle(element);\n state.enter = tokenNumber(computed.getPropertyValue(\"--cl-duration-morph\")) ?? state.enter;\n state.exit = tokenNumber(computed.getPropertyValue(\"--cl-duration-surface\")) ?? state.exit;\n state.geometry = measure(state);\n }\n\n if (!state.geometry) return;\n\n state.from = state.progress;\n state.target = target;\n state.duration = Math.max(\n 1,\n (target === 1 ? state.enter : state.exit) * Math.abs(target - state.progress),\n );\n state.started = view.performance.now();\n // A dismissal is on Base UI's clock: the layer is unmounted when the sentinel's\n // animation ends. That animation starts a frame after the state change, which\n // is close enough for a hold and not close enough for the last tenth of a\n // collapse, where the corner curves are moving fastest — measured, a leg run on\n // its own timer was still at 54% opacity when the layer was removed. Reading the\n // animation's own progress makes the two one clock: the reverse leg reaches the\n // source rectangle exactly as the sentinel finishes, however the frame lands.\n state.sentinel = target === 0 ? exitSentinel(element) : null;\n state.clock = null;\n element.style.transformOrigin = \"0 0\";\n // Painted here, not on the first frame: the layout effect that calls this runs\n // before the browser paints, so a layer that waited for its first rAF would be\n // shown at full size for one frame.\n paint(state);\n state.frame = view.requestAnimationFrame(() => step(state));\n}\n\n/**\n * Drive a layer's morph.\n *\n * The effect runs on mount and whenever `open` changes, and either starts a leg\n * or retargets the one in flight. Everything else — measurement, painting,\n * settling — happens outside React; a layer that re-rendered per frame would put\n * the reconciler inside the animation for no gain, which is the same reason the\n * anchored tail writes into the DOM directly.\n */\nexport function useSurfaceMorph({ surface, anchor, open }: SurfaceMorphOptions): void {\n const runtime = useRef<Runtime>({\n view: null,\n element: null,\n anchor: null,\n progress: 0,\n target: 1,\n geometry: null,\n frame: 0,\n clock: null,\n sentinel: null,\n from: 0,\n started: 0,\n duration: 0,\n enter: 380,\n exit: 160,\n });\n\n useIsomorphicLayoutEffect(() => {\n const state = runtime.current;\n if (state.element !== surface) {\n // A new wrapper is a new layer: it starts at its source.\n stop(state);\n state.element = surface;\n state.progress = 0;\n state.target = 1;\n state.geometry = null;\n }\n if (!surface) {\n stop(state);\n return;\n }\n const view = surface.ownerDocument.defaultView;\n if (!view) return;\n state.view = view;\n state.anchor = anchor ?? null;\n\n const reduced = view.matchMedia?.(\"(prefers-reduced-motion: reduce)\");\n if (reduced?.matches) {\n // Reduced motion keeps the layer's opacity leg, which is the CSS\n // entrance's, and drops the geometry entirely.\n stop(state);\n release(surface);\n return;\n }\n\n const target = open ? 1 : 0;\n if (state.target !== target || (state.frame === 0 && state.progress !== target))\n start(state, target);\n\n // A system setting can change while the layer is on screen, and a morph that\n // carried on through it would be the one thing reduced motion is asking for.\n const onPreference = () => {\n if (!reduced?.matches) return;\n stop(state);\n release(surface);\n };\n reduced?.addEventListener?.(\"change\", onPreference);\n return () => reduced?.removeEventListener?.(\"change\", onPreference);\n }, [surface, anchor, open]);\n\n useEffect(() => () => stop(runtime.current), []);\n}\n"],"mappings":"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"}
@@ -0,0 +1,95 @@
1
+ import { ComponentProps, ReactNode, Ref } from "react";
2
+ //#region src/lib/squircle.d.ts
3
+ /**
4
+ * ClaraLight's smooth-corner primitive.
5
+ *
6
+ * ## Why this exists instead of `corner-shape`
7
+ *
8
+ * Every ClaraLight corner is a smooth corner. On the web there are two ways
9
+ * to draw that, and they are **different curve families that do not coincide**:
10
+ *
11
+ * `corner-shape: superellipse()` a true Lame superellipse, `squircle` == n=4.
12
+ * Chromium 139+ only; Safari and Firefox drop
13
+ * it at parse time.
14
+ * Figma corner smoothing cubic Bezier shoulders bracketing a circular
15
+ * arc, `p = (1 + xi) * R`, residual arc
16
+ * `90 * (1 - xi)` degrees.
17
+ *
18
+ * ClaraLight follows the design, the design lives in Figma, and there the
19
+ * second construction is the one drawn — it is also the same family as Apple's
20
+ * `cornerCurve = .continuous`. So `corner-shape` is deliberately unused, and
21
+ * `@lisse/react` emits this path in every engine instead, which means Safari,
22
+ * Firefox and Chromium agree pixel for pixel rather than approximately.
23
+ *
24
+ * ## Why a hook and an explicit wrapper
25
+ *
26
+ * Lisse renders borders and shadows as **SVG overlays appended to an anchor
27
+ * element**, because `clip-path` clips the element's own painted border and box
28
+ * shadow to nothing. That anchor has to be a positioned box that tightly wraps
29
+ * the shape, and `<SmoothCorners>` would otherwise invent one — which would
30
+ * leave a `Card` no longer a direct grid or flex child.
31
+ *
32
+ * Owning the wrapper here keeps both halves addressable and their roles
33
+ * explicit: `wrapperClassName` is layout, `className` is appearance. It also
34
+ * lets a caller promote the wrapper into whatever layout slot the component
35
+ * already occupied.
36
+ *
37
+ * ## The focus-ring problem
38
+ *
39
+ * `clip-path` crops `outline` too, so `:focus-visible` rings cannot live on the
40
+ * clipped element. Components that need a ring put it on an ancestor instead —
41
+ * see `button.tsx`. Surfaces like `Card` and the dialog panel draw the focus
42
+ * inside themselves, so they are unaffected.
43
+ */
44
+ /** Corner radius tokens declared as `--radius-*` in theme.css. */
45
+ export type RadiusToken = "control" | "medium" | "panel" | "sheet" | "dialog" | "capsule";
46
+ export interface SquircleProps extends ComponentProps<"div"> {
47
+ /** Which `--radius-*` token shapes the corners. */
48
+ radius: RadiusToken;
49
+ /**
50
+ * Figma's `xi`. `0` is a plain circular arc identical to `border-radius`;
51
+ * `1` removes the arc entirely and leaves only Beziers. Defaults to
52
+ * `--cl-corner-smoothing`, which is Figma's own default.
53
+ */
54
+ smoothing?: number;
55
+ /**
56
+ * Classes for the wrapper: grid/flex placement, transforms, and the focus
57
+ * ring. This element — not the clipped one — participates in the caller's
58
+ * layout, and it is also what has to move when the shape moves.
59
+ */
60
+ wrapperClassName?: string;
61
+ /**
62
+ * The wrapper, which is the element a caller's transform belongs on — and the
63
+ * only way to observe the box the shape is clipped to. `ref` is the shape's,
64
+ * because that is what a caller interacts with; this is the box around it.
65
+ */
66
+ wrapperRef?: Ref<HTMLDivElement> | undefined;
67
+ /**
68
+ * Put the ClaraLight focus ring on the wrapper.
69
+ *
70
+ * The ring cannot live on the shape: `clip-path` crops `outline` to nothing.
71
+ * Measured, a 2px ring at 2px offset goes from 436 lit pixels to 0. So the
72
+ * ring goes on the wrapper and is driven by `:has(> [data-cl-squircle]
73
+ * :focus-visible)` in `base.css`, which keeps the browser's keyboard/pointer
74
+ * heuristic and preserves the 2px offset the design asks for.
75
+ *
76
+ * Off by default because surfaces do not take focus.
77
+ */
78
+ ring?: boolean | "field";
79
+ /**
80
+ * Merge the clipped element's props onto a single child instead of rendering
81
+ * a `div` around `children`.
82
+ *
83
+ * Needed because the clipped element is often a primitive that must own its
84
+ * own tag — Base UI's `Button`, `Input` and `Select.Trigger` all render the
85
+ * real interactive element and carry its behaviour. Wrapping them in a `div`
86
+ * would leave the interactive element inside an inert box.
87
+ */
88
+ asChild?: boolean;
89
+ /** Classes for the shape itself: fill, border, shadow, padding. */
90
+ className?: string;
91
+ children?: ReactNode;
92
+ }
93
+ export declare function Squircle({ radius, smoothing, wrapperClassName, wrapperRef, ring, asChild, className, children, ref: forwardedRef, style, ...props }: SquircleProps): import("react").JSX.Element;
94
+ //#endregion
95
+ //# sourceMappingURL=squircle.d.ts.map
@@ -0,0 +1,220 @@
1
+ "use client";
2
+ import { cn } from "./utils.js";
3
+ import { parseBorder, parseBoxShadow } from "@lisse/core";
4
+ import { Fragment, cloneElement, isValidElement, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
5
+ import { useSmoothCorners } from "@lisse/react";
6
+ import { jsx } from "react/jsx-runtime";
7
+ //#region src/lib/squircle.tsx
8
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
9
+ function Squircle({ radius, smoothing, wrapperClassName, wrapperRef, ring = false, asChild = false, className, children, ref: forwardedRef, style, ...props }) {
10
+ const [element, setElement] = useState(null);
11
+ const ref = useMemo(() => ({ current: element }), [element]);
12
+ const internalWrapperRef = useRef(null);
13
+ const mergedWrapperRef = useMemo(() => composeRefs(internalWrapperRef, wrapperRef), [wrapperRef]);
14
+ const border = `var(--radius-${radius})`;
15
+ const child = asChild ? getShapeChild(children) : void 0;
16
+ const childProps = child?.props;
17
+ const childRef = childProps?.ref;
18
+ const mergedRef = useMemo(() => composeRefs(setElement, forwardedRef, childRef), [forwardedRef, childRef]);
19
+ const mergedStyle = {
20
+ ...style,
21
+ ...childProps?.style
22
+ };
23
+ const renderedRadius = mergedStyle.borderRadius ?? border;
24
+ const measuredRadius = typeof renderedRadius === "number" ? `${renderedRadius}px` : renderedRadius;
25
+ const appearance = useAppearance(ref, measuredRadius, smoothing, mergedStyle);
26
+ useSmoothCorners(ref, appearance.corners, {
27
+ wrapperRef: internalWrapperRef,
28
+ autoEffects: false,
29
+ effects: appearance.effects,
30
+ skipShadowHandle: !appearance.effects.shadow,
31
+ fallbackBorderRadius: measuredRadius
32
+ });
33
+ const shapeProps = {
34
+ ...props,
35
+ ref: mergedRef,
36
+ "data-cl-squircle": radius,
37
+ style: {
38
+ ...mergedStyle,
39
+ borderRadius: renderedRadius
40
+ },
41
+ className: cn(className, childProps?.className)
42
+ };
43
+ return /* @__PURE__ */ jsx("div", {
44
+ ref: mergedWrapperRef,
45
+ style: { "--cl-squircle-radius": measuredRadius },
46
+ className: cn("relative", ring === true && "cl-squircle-root", ring === "field" && "cl-squircle-root cl-squircle-field", wrapperClassName),
47
+ children: child ? cloneElement(child, shapeProps) : /* @__PURE__ */ jsx("div", {
48
+ ...shapeProps,
49
+ children
50
+ })
51
+ });
52
+ }
53
+ function getShapeChild(child) {
54
+ if (!isValidElement(child) || child.type === Fragment) throw new Error("Squircle: `asChild` expects exactly one non-Fragment React element.");
55
+ return child;
56
+ }
57
+ /** React 19: a cleanup-returning callback must not also receive null. */
58
+ function composeRefs(...refs) {
59
+ return (node) => {
60
+ const cleanups = refs.map((ref) => {
61
+ if (typeof ref === "function") {
62
+ const cleanup = ref(node);
63
+ return typeof cleanup === "function" ? cleanup : () => ref(null);
64
+ }
65
+ if (ref) {
66
+ ref.current = node;
67
+ return () => {
68
+ ref.current = null;
69
+ };
70
+ }
71
+ });
72
+ return () => {
73
+ for (const cleanup of cleanups) cleanup?.();
74
+ };
75
+ };
76
+ }
77
+ const hiddenStyles = {
78
+ "border-top-color": "transparent",
79
+ "border-right-color": "transparent",
80
+ "border-bottom-color": "transparent",
81
+ "border-left-color": "transparent",
82
+ "box-shadow": "none"
83
+ };
84
+ /**
85
+ * Read the actual shape's cascade, not documentElement: local themes, rem/calc
86
+ * radii, inline tokens and nested light/dark scopes all work. Observers and
87
+ * interaction/resize events resample on changes; there is no frame polling.
88
+ */
89
+ function useAppearance(ref, border, smoothing, style) {
90
+ const [appearance, setAppearance] = useState({
91
+ corners: {
92
+ radius: 0,
93
+ smoothing: 0
94
+ },
95
+ effects: {}
96
+ });
97
+ const latest = useRef({
98
+ border,
99
+ smoothing,
100
+ style
101
+ });
102
+ latest.current = {
103
+ border,
104
+ smoothing,
105
+ style
106
+ };
107
+ const syncRef = useRef(null);
108
+ useIsomorphicLayoutEffect(() => {
109
+ const element = ref.current;
110
+ if (!element) return;
111
+ const view = element.ownerDocument.defaultView;
112
+ if (!view) return;
113
+ const saved = /* @__PURE__ */ new Map();
114
+ let previousStyle = "";
115
+ const restore = () => {
116
+ for (const [property, source] of saved) if (element.style.getPropertyValue(property) === hiddenStyles[property] && element.style.getPropertyPriority(property) === "important") element.style.setProperty(property, source.value, source.priority);
117
+ saved.clear();
118
+ };
119
+ const observer = typeof view.MutationObserver === "function" ? new view.MutationObserver(() => sync()) : void 0;
120
+ const resize = typeof view.ResizeObserver === "function" ? new view.ResizeObserver(() => sync()) : void 0;
121
+ const scheme = view.matchMedia?.("(prefers-color-scheme: dark)");
122
+ const sync = () => {
123
+ const transition = element.style.getPropertyValue("transition");
124
+ const transitionPriority = element.style.getPropertyPriority("transition");
125
+ element.style.setProperty("transition", "none", "important");
126
+ restore();
127
+ const current = latest.current;
128
+ const authored = JSON.stringify(current.style);
129
+ if (authored !== previousStyle) {
130
+ if (previousStyle !== "") {
131
+ const source = element.ownerDocument.createElement("div").style;
132
+ for (const [key, value] of Object.entries(current.style)) if (/^border.*(?:Color)?$/.test(key) && !/Width|Radius/.test(key)) source.setProperty(key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`), String(value));
133
+ source.boxShadow = current.style.boxShadow ?? "";
134
+ for (const property of Object.keys(hiddenStyles)) element.style.setProperty(property, source.getPropertyValue(property));
135
+ }
136
+ previousStyle = authored;
137
+ }
138
+ const oldRadius = element.style.borderTopLeftRadius;
139
+ element.style.borderTopLeftRadius = current.border;
140
+ const computed = view.getComputedStyle(element);
141
+ const radiusValue = computed.borderTopLeftRadius;
142
+ const radius = radiusValue.endsWith("%") ? Number.parseFloat(radiusValue) * Math.min(element.clientWidth, element.clientHeight) / 100 : Number.parseFloat(radiusValue);
143
+ const tokenSmoothing = Number.parseFloat(computed.getPropertyValue("--cl-corner-smoothing"));
144
+ const effects = {
145
+ innerBorder: parseBorder(element, computed),
146
+ ...parseBoxShadow(computed.boxShadow)
147
+ };
148
+ element.style.borderTopLeftRadius = oldRadius;
149
+ if (element.style.clipPath) element.style.borderRadius = "";
150
+ const next = {
151
+ corners: {
152
+ radius: Number.isFinite(radius) ? Math.max(0, radius) : 0,
153
+ smoothing: Math.min(1, Math.max(0, current.smoothing ?? (Number.isFinite(tokenSmoothing) ? tokenSmoothing : 0)))
154
+ },
155
+ effects
156
+ };
157
+ for (const property of Object.keys(hiddenStyles)) if (property === "box-shadow" ? effects.shadow || effects.innerShadow : effects.innerBorder) {
158
+ saved.set(property, {
159
+ value: element.style.getPropertyValue(property),
160
+ priority: element.style.getPropertyPriority(property)
161
+ });
162
+ element.style.setProperty(property, hiddenStyles[property], "important");
163
+ }
164
+ element.style.setProperty("transition", transition, transitionPriority);
165
+ observer?.takeRecords();
166
+ setAppearance((previous) => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);
167
+ };
168
+ const onInteraction = (event) => {
169
+ if (event.type === "transitionend" || event.type === "animationend") {
170
+ if (event.target !== element) return;
171
+ } else if (event.type === "pointerover" || event.type === "pointerout") {
172
+ const related = event.relatedTarget;
173
+ if (related instanceof view.Node && element.contains(related)) return;
174
+ }
175
+ sync();
176
+ };
177
+ const events = [
178
+ "pointerover",
179
+ "pointerout",
180
+ "focusin",
181
+ "focusout",
182
+ "transitionend",
183
+ "animationend"
184
+ ];
185
+ const cleanup = () => {
186
+ observer?.disconnect();
187
+ resize?.disconnect();
188
+ for (const event of events) element.removeEventListener(event, onInteraction);
189
+ view.removeEventListener("resize", sync);
190
+ scheme?.removeEventListener?.("change", sync);
191
+ syncRef.current = null;
192
+ restore();
193
+ };
194
+ try {
195
+ syncRef.current = sync;
196
+ observer?.observe(element, { attributes: true });
197
+ for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) observer?.observe(ancestor, {
198
+ attributes: true,
199
+ attributeFilter: ["class", "style"]
200
+ });
201
+ for (const event of events) element.addEventListener(event, onInteraction);
202
+ view.addEventListener("resize", sync);
203
+ scheme?.addEventListener?.("change", sync);
204
+ resize?.observe(element);
205
+ sync();
206
+ return cleanup;
207
+ } catch (error) {
208
+ cleanup();
209
+ throw error;
210
+ }
211
+ }, [ref]);
212
+ useIsomorphicLayoutEffect(() => {
213
+ syncRef.current?.();
214
+ });
215
+ return appearance;
216
+ }
217
+ //#endregion
218
+ export { Squircle, composeRefs };
219
+
220
+ //# sourceMappingURL=squircle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"squircle.js","names":[],"sources":["../../src/lib/squircle.tsx"],"sourcesContent":["\"use client\";\n\nimport { parseBorder, parseBoxShadow } from \"@lisse/core\";\nimport { type EffectsConfig, useSmoothCorners } from \"@lisse/react\";\nimport {\n type ComponentProps,\n type CSSProperties,\n cloneElement,\n Fragment,\n isValidElement,\n type ReactNode,\n type Ref,\n type RefCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * ClaraLight's smooth-corner primitive.\n *\n * ## Why this exists instead of `corner-shape`\n *\n * Every ClaraLight corner is a smooth corner. On the web there are two ways\n * to draw that, and they are **different curve families that do not coincide**:\n *\n * `corner-shape: superellipse()` a true Lame superellipse, `squircle` == n=4.\n * Chromium 139+ only; Safari and Firefox drop\n * it at parse time.\n * Figma corner smoothing cubic Bezier shoulders bracketing a circular\n * arc, `p = (1 + xi) * R`, residual arc\n * `90 * (1 - xi)` degrees.\n *\n * ClaraLight follows the design, the design lives in Figma, and there the\n * second construction is the one drawn — it is also the same family as Apple's\n * `cornerCurve = .continuous`. So `corner-shape` is deliberately unused, and\n * `@lisse/react` emits this path in every engine instead, which means Safari,\n * Firefox and Chromium agree pixel for pixel rather than approximately.\n *\n * ## Why a hook and an explicit wrapper\n *\n * Lisse renders borders and shadows as **SVG overlays appended to an anchor\n * element**, because `clip-path` clips the element's own painted border and box\n * shadow to nothing. That anchor has to be a positioned box that tightly wraps\n * the shape, and `<SmoothCorners>` would otherwise invent one — which would\n * leave a `Card` no longer a direct grid or flex child.\n *\n * Owning the wrapper here keeps both halves addressable and their roles\n * explicit: `wrapperClassName` is layout, `className` is appearance. It also\n * lets a caller promote the wrapper into whatever layout slot the component\n * already occupied.\n *\n * ## The focus-ring problem\n *\n * `clip-path` crops `outline` too, so `:focus-visible` rings cannot live on the\n * clipped element. Components that need a ring put it on an ancestor instead —\n * see `button.tsx`. Surfaces like `Card` and the dialog panel draw the focus\n * inside themselves, so they are unaffected.\n */\n\n/** Corner radius tokens declared as `--radius-*` in theme.css. */\nexport type RadiusToken = \"control\" | \"medium\" | \"panel\" | \"sheet\" | \"dialog\" | \"capsule\";\n\nconst useIsomorphicLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport interface SquircleProps extends ComponentProps<\"div\"> {\n /** Which `--radius-*` token shapes the corners. */\n radius: RadiusToken;\n /**\n * Figma's `xi`. `0` is a plain circular arc identical to `border-radius`;\n * `1` removes the arc entirely and leaves only Beziers. Defaults to\n * `--cl-corner-smoothing`, which is Figma's own default.\n */\n smoothing?: number;\n /**\n * Classes for the wrapper: grid/flex placement, transforms, and the focus\n * ring. This element — not the clipped one — participates in the caller's\n * layout, and it is also what has to move when the shape moves.\n */\n wrapperClassName?: string;\n /**\n * The wrapper, which is the element a caller's transform belongs on — and the\n * only way to observe the box the shape is clipped to. `ref` is the shape's,\n * because that is what a caller interacts with; this is the box around it.\n */\n wrapperRef?: Ref<HTMLDivElement> | undefined;\n /**\n * Put the ClaraLight focus ring on the wrapper.\n *\n * The ring cannot live on the shape: `clip-path` crops `outline` to nothing.\n * Measured, a 2px ring at 2px offset goes from 436 lit pixels to 0. So the\n * ring goes on the wrapper and is driven by `:has(> [data-cl-squircle]\n * :focus-visible)` in `base.css`, which keeps the browser's keyboard/pointer\n * heuristic and preserves the 2px offset the design asks for.\n *\n * Off by default because surfaces do not take focus.\n */\n ring?: boolean | \"field\";\n /**\n * Merge the clipped element's props onto a single child instead of rendering\n * a `div` around `children`.\n *\n * Needed because the clipped element is often a primitive that must own its\n * own tag — Base UI's `Button`, `Input` and `Select.Trigger` all render the\n * real interactive element and carry its behaviour. Wrapping them in a `div`\n * would leave the interactive element inside an inert box.\n */\n asChild?: boolean;\n /** Classes for the shape itself: fill, border, shadow, padding. */\n className?: string;\n children?: ReactNode;\n}\n\nexport function Squircle({\n radius,\n smoothing,\n wrapperClassName,\n wrapperRef,\n ring = false,\n asChild = false,\n className,\n children,\n ref: forwardedRef,\n style,\n ...props\n}: SquircleProps) {\n const [element, setElement] = useState<HTMLDivElement | null>(null);\n // Lisse 0.7.2 binds its observer/effects lifecycle to the ref object, not\n // ref.current. Replace that object only when the actual DOM node changes\n // (asChild tag/key changes or a primitive temporarily rendering null).\n const ref = useMemo(() => ({ current: element }), [element]);\n const internalWrapperRef = useRef<HTMLDivElement>(null);\n const mergedWrapperRef = useMemo(() => composeRefs(internalWrapperRef, wrapperRef), [wrapperRef]);\n const border = `var(--radius-${radius})`;\n const child = asChild ? getShapeChild(children) : undefined;\n const childProps = child?.props as ComponentProps<\"div\"> | undefined;\n const childRef = childProps?.ref;\n const mergedRef = useMemo(\n () => composeRefs(setElement, forwardedRef, childRef),\n [forwardedRef, childRef],\n );\n const mergedStyle = { ...style, ...childProps?.style };\n // A uniform authored radius overrides the token for SSR, clipping and SVG.\n const renderedRadius = mergedStyle.borderRadius ?? border;\n const measuredRadius =\n typeof renderedRadius === \"number\" ? `${renderedRadius}px` : renderedRadius;\n const appearance = useAppearance(ref, measuredRadius, smoothing, mergedStyle);\n\n // 0.7.2's autoEffects only extracts at mount. Explicit effects are compared\n // by value on every commit, so theme/variant changes update the existing SVG.\n useSmoothCorners(ref, appearance.corners, {\n wrapperRef: internalWrapperRef,\n autoEffects: false,\n effects: appearance.effects,\n // 0.7.2 otherwise keeps an existing shadow handle with DEFAULT_SHADOW\n // when an explicit shadow disappears (e.g. panel -> control).\n skipShadowHandle: !appearance.effects.shadow,\n fallbackBorderRadius: measuredRadius,\n });\n\n const shapeProps = {\n ...props,\n ref: mergedRef,\n \"data-cl-squircle\": radius,\n style: { ...mergedStyle, borderRadius: renderedRadius },\n className: cn(className, childProps?.className),\n };\n\n return (\n <div\n ref={mergedWrapperRef}\n style={{ \"--cl-squircle-radius\": measuredRadius } as CSSProperties}\n className={cn(\n \"relative\",\n ring === true && \"cl-squircle-root\",\n ring === \"field\" && \"cl-squircle-root cl-squircle-field\",\n wrapperClassName,\n )}\n >\n {child ? cloneElement(child, shapeProps) : <div {...shapeProps}>{children}</div>}\n </div>\n );\n}\n\nfunction getShapeChild(child: ReactNode) {\n if (!isValidElement<ComponentProps<\"div\">>(child) || child.type === Fragment) {\n throw new Error(\"Squircle: `asChild` expects exactly one non-Fragment React element.\");\n }\n return child;\n}\n\n/** React 19: a cleanup-returning callback must not also receive null. */\nexport function composeRefs<T>(...refs: (Ref<T> | undefined)[]): RefCallback<T> {\n return (node) => {\n const cleanups = refs.map((ref) => {\n if (typeof ref === \"function\") {\n const cleanup = ref(node);\n return typeof cleanup === \"function\" ? cleanup : () => ref(null);\n }\n if (ref) {\n ref.current = node;\n return () => {\n ref.current = null;\n };\n }\n return undefined;\n });\n return () => {\n for (const cleanup of cleanups) cleanup?.();\n };\n };\n}\n\nconst hiddenStyles = {\n \"border-top-color\": \"transparent\",\n \"border-right-color\": \"transparent\",\n \"border-bottom-color\": \"transparent\",\n \"border-left-color\": \"transparent\",\n \"box-shadow\": \"none\",\n} as const;\n\ntype HiddenProperty = keyof typeof hiddenStyles;\ntype SavedStyle = { value: string; priority: string };\n\n/**\n * Read the actual shape's cascade, not documentElement: local themes, rem/calc\n * radii, inline tokens and nested light/dark scopes all work. Observers and\n * interaction/resize events resample on changes; there is no frame polling.\n */\nfunction useAppearance(\n ref: { current: HTMLElement | null },\n border: string,\n smoothing: number | undefined,\n style: CSSProperties,\n) {\n const [appearance, setAppearance] = useState({\n // Not design defaults: SSR uses the CSS border-radius, before measurement.\n corners: { radius: 0, smoothing: 0 },\n effects: {} as EffectsConfig,\n });\n const latest = useRef({ border, smoothing, style });\n latest.current = { border, smoothing, style };\n const syncRef = useRef<(() => void) | null>(null);\n\n useIsomorphicLayoutEffect(() => {\n const element = ref.current;\n if (!element) return;\n const view = element.ownerDocument.defaultView;\n if (!view) return;\n const saved = new Map<HiddenProperty, SavedStyle>();\n let previousStyle = \"\";\n const restore = () => {\n for (const [property, source] of saved) {\n // An imperative or React inline update takes precedence over our mask.\n if (\n element.style.getPropertyValue(property) === hiddenStyles[property] &&\n element.style.getPropertyPriority(property) === \"important\"\n ) {\n element.style.setProperty(property, source.value, source.priority);\n }\n }\n saved.clear();\n };\n const observer =\n typeof view.MutationObserver === \"function\"\n ? new view.MutationObserver(() => sync())\n : undefined;\n const resize =\n typeof view.ResizeObserver === \"function\" ? new view.ResizeObserver(() => sync()) : undefined;\n const scheme = view.matchMedia?.(\"(prefers-color-scheme: dark)\");\n const sync = () => {\n // Sample target CSS, not a transition starting from our transparent mask.\n const transition = element.style.getPropertyValue(\"transition\");\n const transitionPriority = element.style.getPropertyPriority(\"transition\");\n element.style.setProperty(\"transition\", \"none\", \"important\");\n restore();\n const current = latest.current;\n // React may write the same value as our mask (e.g. shadow -> none).\n // Reconcile declarative border/shadow styles before taking a new snapshot.\n const authored = JSON.stringify(current.style);\n if (authored !== previousStyle) {\n if (previousStyle !== \"\") {\n const source = element.ownerDocument.createElement(\"div\").style;\n for (const [key, value] of Object.entries(current.style)) {\n if (/^border.*(?:Color)?$/.test(key) && !/Width|Radius/.test(key)) {\n source.setProperty(\n key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`),\n String(value),\n );\n }\n }\n source.boxShadow = current.style.boxShadow ?? \"\";\n for (const property of Object.keys(hiddenStyles) as HiddenProperty[]) {\n element.style.setProperty(property, source.getPropertyValue(property));\n }\n }\n previousStyle = authored;\n }\n // Let the browser resolve units, calc() and inherited custom properties.\n const oldRadius = element.style.borderTopLeftRadius;\n element.style.borderTopLeftRadius = current.border;\n const computed = view.getComputedStyle(element);\n const radiusValue = computed.borderTopLeftRadius;\n const radius = radiusValue.endsWith(\"%\")\n ? (Number.parseFloat(radiusValue) * Math.min(element.clientWidth, element.clientHeight)) /\n 100\n : Number.parseFloat(radiusValue);\n const tokenSmoothing = Number.parseFloat(computed.getPropertyValue(\"--cl-corner-smoothing\"));\n const effects: EffectsConfig = {\n innerBorder: parseBorder(element, computed),\n ...parseBoxShadow(computed.boxShadow),\n };\n element.style.borderTopLeftRadius = oldRadius;\n if (element.style.clipPath) {\n element.style.borderRadius = \"\";\n }\n const next = {\n corners: {\n radius: Number.isFinite(radius) ? Math.max(0, radius) : 0,\n smoothing: Math.min(\n 1,\n Math.max(\n 0,\n current.smoothing ?? (Number.isFinite(tokenSmoothing) ? tokenSmoothing : 0),\n ),\n ),\n },\n effects,\n };\n for (const property of Object.keys(hiddenStyles) as HiddenProperty[]) {\n if (\n property === \"box-shadow\" ? effects.shadow || effects.innerShadow : effects.innerBorder\n ) {\n saved.set(property, {\n value: element.style.getPropertyValue(property),\n priority: element.style.getPropertyPriority(property),\n });\n // Keep border widths/layout; Lisse's autoEffects instead sets border:0.\n element.style.setProperty(property, hiddenStyles[property], \"important\");\n }\n }\n element.style.setProperty(\"transition\", transition, transitionPriority);\n observer?.takeRecords();\n setAppearance((previous) =>\n JSON.stringify(previous) === JSON.stringify(next) ? previous : next,\n );\n };\n const onInteraction = (event: Event) => {\n if (event.type === \"transitionend\" || event.type === \"animationend\") {\n if (event.target !== element) return;\n } else if (event.type === \"pointerover\" || event.type === \"pointerout\") {\n const related = (event as PointerEvent).relatedTarget;\n if (related instanceof view.Node && element.contains(related)) return;\n }\n sync();\n };\n const events = [\n \"pointerover\",\n \"pointerout\",\n \"focusin\",\n \"focusout\",\n \"transitionend\",\n \"animationend\",\n ];\n const cleanup = () => {\n observer?.disconnect();\n resize?.disconnect();\n for (const event of events) element.removeEventListener(event, onInteraction);\n view.removeEventListener(\"resize\", sync);\n scheme?.removeEventListener?.(\"change\", sync);\n syncRef.current = null;\n restore();\n };\n try {\n syncRef.current = sync;\n observer?.observe(element, { attributes: true });\n for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {\n observer?.observe(ancestor, { attributes: true, attributeFilter: [\"class\", \"style\"] });\n }\n for (const event of events) element.addEventListener(event, onInteraction);\n view.addEventListener(\"resize\", sync);\n scheme?.addEventListener?.(\"change\", sync);\n resize?.observe(element);\n sync();\n return cleanup;\n } catch (error) {\n cleanup();\n throw error;\n }\n }, [ref]);\n\n useIsomorphicLayoutEffect(() => {\n syncRef.current?.();\n });\n return appearance;\n}\n"],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ import { ClassValue } from "cn";
2
+ //#region src/lib/utils.d.ts
3
+ /**
4
+ * Tailwind's `text-*`, `shadow-*` and `ring-*` prefixes each cover two
5
+ * unrelated utility groups — a value (`text-lg` font size, `shadow-lg`, ...)
6
+ * and a color (`text-red-500`, `shadow-red-500`, ...). Class-merge libraries
7
+ * decide which group a class belongs to by recognising the value, and they
8
+ * cannot recognise a token that only exists in ClaraLight's `@theme`.
9
+ *
10
+ * The failure is silent and destructive. `text-title` reads as a *color* to
11
+ * both `cn` and `tailwind-merge`, so:
12
+ *
13
+ * cn("text-title text-foreground") -> "text-foreground"
14
+ *
15
+ * The font size is gone, with no warning. Every ClaraLight type step is
16
+ * affected because they are all custom names, which is why registering them
17
+ * here is not optional.
18
+ *
19
+ * `cn/config` is `cn`'s twin of `extendTailwindMerge`; `createCn` returns a
20
+ * `cn` with the same signature. The groups below are the *value* groups, which
21
+ * is what the token names actually mean. Colors need no registration: unknown
22
+ * values already fall through to the color group, which is the right answer
23
+ * for them.
24
+ *
25
+ * Regression-tested in `utils.spec.ts` — if a token is added to `theme.css`
26
+ * without being added here, that test is what catches it.
27
+ */
28
+ export declare const cn: (...inputs: ClassValue[]) => string;
29
+ //#endregion
30
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1,76 @@
1
+ import { createCn } from "cn/config";
2
+ //#region src/lib/utils.ts
3
+ /**
4
+ * Tailwind's `text-*`, `shadow-*` and `ring-*` prefixes each cover two
5
+ * unrelated utility groups — a value (`text-lg` font size, `shadow-lg`, ...)
6
+ * and a color (`text-red-500`, `shadow-red-500`, ...). Class-merge libraries
7
+ * decide which group a class belongs to by recognising the value, and they
8
+ * cannot recognise a token that only exists in ClaraLight's `@theme`.
9
+ *
10
+ * The failure is silent and destructive. `text-title` reads as a *color* to
11
+ * both `cn` and `tailwind-merge`, so:
12
+ *
13
+ * cn("text-title text-foreground") -> "text-foreground"
14
+ *
15
+ * The font size is gone, with no warning. Every ClaraLight type step is
16
+ * affected because they are all custom names, which is why registering them
17
+ * here is not optional.
18
+ *
19
+ * `cn/config` is `cn`'s twin of `extendTailwindMerge`; `createCn` returns a
20
+ * `cn` with the same signature. The groups below are the *value* groups, which
21
+ * is what the token names actually mean. Colors need no registration: unknown
22
+ * values already fall through to the color group, which is the right answer
23
+ * for them.
24
+ *
25
+ * Regression-tested in `utils.spec.ts` — if a token is added to `theme.css`
26
+ * without being added here, that test is what catches it.
27
+ */
28
+ const cn = createCn({ extend: { classGroups: {
29
+ "font-size": [{ text: [
30
+ "display",
31
+ "headline",
32
+ "title",
33
+ "body",
34
+ "callout",
35
+ "label",
36
+ "caption",
37
+ "mono",
38
+ "mono-strong",
39
+ "button"
40
+ ] }],
41
+ shadow: [{ shadow: [
42
+ "frost",
43
+ "panel",
44
+ "dialog"
45
+ ] }],
46
+ h: [{ h: [
47
+ "control-sm",
48
+ "control-md",
49
+ "control-lg"
50
+ ] }],
51
+ size: [{ size: [
52
+ "icon-sm",
53
+ "icon-md",
54
+ "icon-lg"
55
+ ] }],
56
+ rounded: [{ rounded: ["item"] }]
57
+ } } });
58
+ /**
59
+ * A token's leading number, in the unit the token was written in: px as px,
60
+ * durations as milliseconds.
61
+ *
62
+ * Custom properties are substituted, not computed, so the unit comes back as it
63
+ * was authored — and a `450ms` in the source is `.45s` in the built sheet. A
64
+ * duration therefore has to be converted rather than read at face value: 0.45 is
65
+ * a dwell that has already elapsed, and anything comparing a token against a
66
+ * clock counts in the milliseconds it was written in.
67
+ */
68
+ function tokenNumber(value) {
69
+ const number = Number.parseFloat(value);
70
+ if (!Number.isFinite(number)) return void 0;
71
+ return value.endsWith("ms") ? number : value.endsWith("s") ? number * 1e3 : number;
72
+ }
73
+ //#endregion
74
+ export { cn, tokenNumber };
75
+
76
+ //# sourceMappingURL=utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.js","names":[],"sources":["../../src/lib/utils.ts"],"sourcesContent":["import type { ClassValue } from \"cn\";\nimport { createCn } from \"cn/config\";\n\n/**\n * Tailwind's `text-*`, `shadow-*` and `ring-*` prefixes each cover two\n * unrelated utility groups — a value (`text-lg` font size, `shadow-lg`, ...)\n * and a color (`text-red-500`, `shadow-red-500`, ...). Class-merge libraries\n * decide which group a class belongs to by recognising the value, and they\n * cannot recognise a token that only exists in ClaraLight's `@theme`.\n *\n * The failure is silent and destructive. `text-title` reads as a *color* to\n * both `cn` and `tailwind-merge`, so:\n *\n * cn(\"text-title text-foreground\") -> \"text-foreground\"\n *\n * The font size is gone, with no warning. Every ClaraLight type step is\n * affected because they are all custom names, which is why registering them\n * here is not optional.\n *\n * `cn/config` is `cn`'s twin of `extendTailwindMerge`; `createCn` returns a\n * `cn` with the same signature. The groups below are the *value* groups, which\n * is what the token names actually mean. Colors need no registration: unknown\n * values already fall through to the color group, which is the right answer\n * for them.\n *\n * Regression-tested in `utils.spec.ts` — if a token is added to `theme.css`\n * without being added here, that test is what catches it.\n */\nexport const cn: (...inputs: ClassValue[]) => string = createCn({\n extend: {\n classGroups: {\n /* --text-* in theme.css */\n \"font-size\": [\n {\n text: [\n \"display\",\n \"headline\",\n \"title\",\n \"body\",\n \"callout\",\n \"label\",\n \"caption\",\n \"mono\",\n \"mono-strong\",\n \"button\",\n ],\n },\n ],\n /* --shadow-* in theme.css */\n shadow: [{ shadow: [\"frost\", \"panel\", \"dialog\"] }],\n /* Named component geometry must also yield to caller overrides. */\n h: [{ h: [\"control-sm\", \"control-md\", \"control-lg\"] }],\n size: [{ size: [\"icon-sm\", \"icon-md\", \"icon-lg\"] }],\n rounded: [{ rounded: [\"item\"] }],\n },\n },\n});\n\n/**\n * A token's leading number, in the unit the token was written in: px as px,\n * durations as milliseconds.\n *\n * Custom properties are substituted, not computed, so the unit comes back as it\n * was authored — and a `450ms` in the source is `.45s` in the built sheet. A\n * duration therefore has to be converted rather than read at face value: 0.45 is\n * a dwell that has already elapsed, and anything comparing a token against a\n * clock counts in the milliseconds it was written in.\n */\nexport function tokenNumber(value: string): number | undefined {\n const number = Number.parseFloat(value);\n if (!Number.isFinite(number)) return undefined;\n return value.endsWith(\"ms\") ? number : value.endsWith(\"s\") ? number * 1000 : number;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,MAAa,KAA0C,SAAS,EAC9D,QAAQ,EACN,aAAa;CAEX,aAAa,CACX,EACE,MAAM;EACJ;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF,EACF,CACF;CAEA,QAAQ,CAAC,EAAE,QAAQ;EAAC;EAAS;EAAS;CAAQ,EAAE,CAAC;CAEjD,GAAG,CAAC,EAAE,GAAG;EAAC;EAAc;EAAc;CAAY,EAAE,CAAC;CACrD,MAAM,CAAC,EAAE,MAAM;EAAC;EAAW;EAAW;CAAS,EAAE,CAAC;CAClD,SAAS,CAAC,EAAE,SAAS,CAAC,MAAM,EAAE,CAAC;AACjC,EACF,EACF,CAAC;;;;;;;;;;;AAYD,SAAgB,YAAY,OAAmC;CAC7D,MAAM,SAAS,OAAO,WAAW,KAAK;CACtC,IAAI,CAAC,OAAO,SAAS,MAAM,GAAG,OAAO,KAAA;CACrC,OAAO,MAAM,SAAS,IAAI,IAAI,SAAS,MAAM,SAAS,GAAG,IAAI,SAAS,MAAO;AAC/E"}
@@ -0,0 +1,40 @@
1
+ import { ComponentProps } from "react";
2
+ import { Button } from "@base-ui/react/button";
3
+ import { VariantProps } from "class-variance-authority";
4
+ //#region src/ui/button.d.ts
5
+ /**
6
+ * ClaraLight button variants.
7
+ *
8
+ * `secondary` is the default because ClaraLight is a tool-like design
9
+ * language: most buttons in a real screen are neutral glass controls, and the
10
+ * accent should mark one primary action per view, not every button.
11
+ */
12
+ export declare const buttonVariants: (props?: ({
13
+ variant?: "danger" | "ghost" | "primary" | "secondary" | null | undefined;
14
+ size?: "lg" | "md" | "sm" | null | undefined;
15
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
16
+ export type ButtonProps = Omit<ComponentProps<typeof Button>, "className"> & VariantProps<typeof buttonVariants> & {
17
+ /** Applied to the clipped element: fill, border, label. */
18
+ className?: string;
19
+ /**
20
+ * Applied to the wrapper, which is what a caller's flex or grid lays out —
21
+ * put width, margin and placement here.
22
+ */
23
+ wrapperClassName?: string;
24
+ };
25
+ /**
26
+ * A ClaraLight capsule button.
27
+ *
28
+ * Wraps Base UI's `Button` rather than rendering a bare `<button>`: that gives
29
+ * the `render` prop for polymorphism (rendering an `<a>` while keeping button
30
+ * semantics), and correct disabled behaviour for non-native elements, which a
31
+ * plain `disabled` attribute cannot express.
32
+ *
33
+ * @example
34
+ * <Button variant="primary">Upload</Button>
35
+ * <Button render={<a href="/docs" />}>Docs</Button>
36
+ */
37
+ declare function Button$1({ className, wrapperClassName, variant, size, ...props }: ButtonProps): import("react").JSX.Element;
38
+ //#endregion
39
+ export { Button$1 as Button };
40
+ //# sourceMappingURL=button.d.ts.map