@exegia/corpora-ui 0.21.0 → 0.22.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.
Files changed (59) hide show
  1. package/dist-lib/components/blocks/layout.d.ts +4 -1
  2. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +1 -1
  3. package/dist-lib/components/blocks/shell/index.d.ts +0 -12
  4. package/dist-lib/components/composed/logo.d.ts +34 -0
  5. package/dist-lib/components/user-avatar/type.d.ts +10 -2
  6. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +5 -0
  7. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +9 -0
  8. package/dist-lib/components/user-avatar/utils.d.ts +22 -0
  9. package/dist-lib/index.d.ts +1 -1
  10. package/dist-lib/index.js +3083 -3229
  11. package/dist-lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/components/blocks/layout.ts +30 -2
  14. package/src/components/blocks/profile/profile-card-block.tsx +1 -1
  15. package/src/components/blocks/shell/index.ts +0 -13
  16. package/src/components/composed/__tests__/logo.test.tsx +60 -0
  17. package/src/components/composed/logo.tsx +159 -0
  18. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +41 -0
  19. package/src/components/user-avatar/component.tsx +38 -8
  20. package/src/components/user-avatar/fallback.tsx +3 -1
  21. package/src/components/user-avatar/type.ts +10 -2
  22. package/src/components/user-avatar/use-user-avatar.ts +38 -2
  23. package/src/components/user-avatar/user-avatar-atom.ts +20 -0
  24. package/src/components/user-avatar/utils.ts +94 -0
  25. package/src/index.ts +1 -1
  26. package/dist-lib/components/blocks/nav/sidebar-block.d.ts +0 -14
  27. package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +0 -3
  28. package/dist-lib/components/blocks/nav/types.d.ts +0 -60
  29. package/dist-lib/components/blocks/nav/utils.d.ts +0 -1
  30. package/dist-lib/components/blocks/shell/animated-sidebar-content.d.ts +0 -2
  31. package/dist-lib/components/blocks/shell/animated-sidebar-footer.d.ts +0 -2
  32. package/dist-lib/components/blocks/shell/animated-sidebar-group-content.d.ts +0 -2
  33. package/dist-lib/components/blocks/shell/animated-sidebar-group-label.d.ts +0 -2
  34. package/dist-lib/components/blocks/shell/animated-sidebar-group.d.ts +0 -2
  35. package/dist-lib/components/blocks/shell/animated-sidebar-header.d.ts +0 -2
  36. package/dist-lib/components/blocks/shell/animated-sidebar-menu-button.d.ts +0 -3
  37. package/dist-lib/components/blocks/shell/animated-sidebar-menu-item.d.ts +0 -2
  38. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-button.d.ts +0 -3
  39. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-item.d.ts +0 -2
  40. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub.d.ts +0 -2
  41. package/dist-lib/components/blocks/shell/animated-sidebar-menu.d.ts +0 -2
  42. package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +0 -125
  43. package/src/components/blocks/nav/sidebar-block.tsx +0 -142
  44. package/src/components/blocks/nav/sidebar-nav-row.tsx +0 -78
  45. package/src/components/blocks/nav/types.ts +0 -65
  46. package/src/components/blocks/nav/utils.ts +0 -4
  47. package/src/components/blocks/shell/animated-sidebar-content.tsx +0 -19
  48. package/src/components/blocks/shell/animated-sidebar-footer.tsx +0 -19
  49. package/src/components/blocks/shell/animated-sidebar-group-content.tsx +0 -16
  50. package/src/components/blocks/shell/animated-sidebar-group-label.tsx +0 -29
  51. package/src/components/blocks/shell/animated-sidebar-group.tsx +0 -16
  52. package/src/components/blocks/shell/animated-sidebar-header.tsx +0 -16
  53. package/src/components/blocks/shell/animated-sidebar-menu-button.tsx +0 -151
  54. package/src/components/blocks/shell/animated-sidebar-menu-item.tsx +0 -20
  55. package/src/components/blocks/shell/animated-sidebar-menu-sub-button.tsx +0 -85
  56. package/src/components/blocks/shell/animated-sidebar-menu-sub-item.tsx +0 -19
  57. package/src/components/blocks/shell/animated-sidebar-menu-sub.tsx +0 -44
  58. package/src/components/blocks/shell/animated-sidebar-menu.tsx +0 -26
  59. /package/dist-lib/components/{blocks/nav/__tests__/sidebar-block.test.d.ts → composed/__tests__/logo.test.d.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exegia/corpora-ui",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "shadcn-ready React UI library for the corpora manuscript-research apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,7 +1,35 @@
1
- import ShellLayout from "@/components/blocks/shell"
1
+ import ShellLayout, {
2
+ AnimatedPanel,
3
+ AnimatedPanelTrigger,
4
+ useShellPanels,
5
+ resetShellPanelWidthAtom,
6
+ resizeShellPanelAtom,
7
+ shellFitFitsAtom,
8
+ shellFitMeasuredAtom,
9
+ shellFitMetricsAtom,
10
+ shellFitPanelBoundsAtom,
11
+ shellFitPanelWidthAtom,
12
+ shellFitRequestedWidthAtom,
13
+ shellFitStateAtom,
14
+ } from "@/components/blocks/shell"
2
15
 
3
16
  const Layout = {
4
- Main: ShellLayout
17
+ Main: ShellLayout,
18
+ Panel: AnimatedPanel,
19
+ Trigger: AnimatedPanelTrigger,
20
+ }
21
+
22
+ export {
23
+ useShellPanels,
24
+ resetShellPanelWidthAtom,
25
+ resizeShellPanelAtom,
26
+ shellFitFitsAtom,
27
+ shellFitMeasuredAtom,
28
+ shellFitMetricsAtom,
29
+ shellFitPanelBoundsAtom,
30
+ shellFitPanelWidthAtom,
31
+ shellFitRequestedWidthAtom,
32
+ shellFitStateAtom,
5
33
  }
6
34
 
7
35
  export default Layout
@@ -103,7 +103,7 @@ export interface ProfileCardBlockProps {
103
103
  * its avatar — for an icon-collapsed sidebar rail. The fold animates: the
104
104
  * identity lines and chevron slide to zero width, the avatar stays put.
105
105
  * Controlled when passed. Left unset, the card follows the `AnimatedPanel`
106
- * it sits in (a `SidebarBlock` footer folds with the rail on its own), and
106
+ * it sits in (a panel footer folds with the rail on its own), and
107
107
  * outside a panel starts from `defaultVariant`.
108
108
  */
109
109
  variant?: ProfileCardVariant
@@ -27,17 +27,4 @@ export * from "./animated-panel-inset"
27
27
  export * from "./animated-panel-provider"
28
28
  export * from "./animated-panel-trigger"
29
29
 
30
- export * from "./animated-sidebar-header"
31
- export * from "./animated-sidebar-content"
32
- export * from "./animated-sidebar-footer"
33
- export * from "./animated-sidebar-group"
34
- export * from "./animated-sidebar-group-label"
35
- export * from "./animated-sidebar-group-content"
36
- export * from "./animated-sidebar-menu"
37
- export * from "./animated-sidebar-menu-item"
38
- export * from "./animated-sidebar-menu-sub"
39
- export * from "./animated-sidebar-menu-sub-item"
40
- export * from "./animated-sidebar-menu-sub-button"
41
- export * from "./animated-sidebar-menu-button"
42
-
43
30
  export { ShellLayout as default }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { render, screen } from "@testing-library/react"
3
+
4
+ import { Logo } from "../logo"
5
+
6
+ const root = () => document.querySelector<HTMLElement>('[data-slot="logo"]')
7
+ const wordmark = () =>
8
+ document.querySelector<HTMLElement>('[data-slot="logo-wordmark"]')
9
+ const monogram = () =>
10
+ document.querySelector<HTMLElement>('[data-slot="logo-monogram"]')
11
+
12
+ describe("Logo", () => {
13
+ test("defaults: monogram tile from the name, name as the wordmark", () => {
14
+ render(<Logo name="Corpora Codex" />)
15
+ expect(monogram()?.textContent).toBe("CC")
16
+ expect(wordmark()?.textContent).toBe("Corpora Codex")
17
+ expect(root()?.tagName).toBe("SPAN")
18
+ expect(root()?.getAttribute("data-variant")).toBe("full")
19
+ })
20
+
21
+ test("a custom mark wins over src; src renders a decorative image", () => {
22
+ const { rerender } = render(
23
+ <Logo name="Corpora" src="https://example.com/mark.png" />
24
+ )
25
+ expect(monogram()).toBeNull()
26
+ expect(document.querySelector("img")?.getAttribute("alt")).toBe("")
27
+ rerender(
28
+ <Logo
29
+ name="Corpora"
30
+ src="https://example.com/mark.png"
31
+ mark={<svg data-testid="mark" />}
32
+ />
33
+ )
34
+ expect(document.querySelector("img")).toBeNull()
35
+ expect(screen.getByTestId("mark")).toBeTruthy()
36
+ })
37
+
38
+ test("href renders a link named for AT regardless of variant", () => {
39
+ render(<Logo name="Corpora" href="/" variant="mark" />)
40
+ const link = screen.getByRole("link", { name: "Corpora" })
41
+ expect(link.getAttribute("href")).toBe("/")
42
+ expect(link.getAttribute("data-slot")).toBe("logo")
43
+ })
44
+
45
+ test("variant=mark folds the wordmark: hidden from AT, root keeps the name", () => {
46
+ const { rerender } = render(<Logo name="Corpora" variant="mark" />)
47
+ expect(wordmark()?.getAttribute("aria-hidden")).toBe("true")
48
+ expect(root()?.getAttribute("aria-label")).toBe("Corpora")
49
+ expect(root()?.getAttribute("data-variant")).toBe("mark")
50
+ // Unfolded, the visible wordmark is the name — no double label.
51
+ rerender(<Logo name="Corpora" variant="full" />)
52
+ expect(wordmark()?.getAttribute("aria-hidden")).toBeNull()
53
+ expect(root()?.getAttribute("aria-label")).toBeNull()
54
+ })
55
+
56
+ test("a custom wordmark replaces the name text", () => {
57
+ render(<Logo name="Corpora" wordmark={<span>corpora/ui</span>} />)
58
+ expect(wordmark()?.textContent).toBe("corpora/ui")
59
+ })
60
+ })
@@ -0,0 +1,159 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { motion, useReducedMotion } from "motion/react"
5
+ import type { Variants } from "motion/react"
6
+
7
+ import { EASE_OUT } from "@/lib/ease.ts"
8
+ import { cn } from "@/lib/utils"
9
+ import { initialsFrom } from "@/components/user-avatar"
10
+
11
+ /** `full` shows mark + wordmark; `mark` folds the wordmark away — an icon
12
+ * rail, a favicon-sized corner. */
13
+ export type LogoVariant = "full" | "mark"
14
+
15
+ export interface LogoProps
16
+ extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
17
+ /**
18
+ * Brand name. Labels the logo for AT (and the link, when `href` renders
19
+ * one), drives the default wordmark, and the monogram tile when no mark
20
+ * is given.
21
+ */
22
+ name: string
23
+ /** Custom mark — an inline SVG sized to fill its box. Wins over `src`. */
24
+ mark?: React.ReactNode
25
+ /** Image URL for the mark. Decorative — `name` labels the logo. */
26
+ src?: string
27
+ /** Wordmark content. Defaults to `name`. */
28
+ wordmark?: React.ReactNode
29
+ /** `full` (default) or `mark` — the wordmark folds away, animated. */
30
+ variant?: LogoVariant
31
+ /** Renders the logo as a link — the usual "mark goes home" affordance. */
32
+ href?: string
33
+ }
34
+
35
+ /** Wordmark fold/unfold — the same envelope the profile card's identity
36
+ * lines use, so a logo and a profile card folding in the same rail move as
37
+ * one. Kept local: composed components do not reach into blocks. Expressed
38
+ * as named variants for consistency with the profile card's fold envelope —
39
+ * house style for multi-property fold states, not a motion requirement. */
40
+ const WORDMARK_VARIANTS: Variants = {
41
+ open: {
42
+ width: "auto",
43
+ opacity: 1,
44
+ x: 0,
45
+ transition: { duration: 0.2, delay: 0.08, ease: EASE_OUT },
46
+ },
47
+ folded: {
48
+ width: 0,
49
+ opacity: 0,
50
+ x: -4,
51
+ transition: { duration: 0.12, ease: EASE_OUT },
52
+ },
53
+ }
54
+
55
+ const WORDMARK_VARIANTS_REDUCED: Variants = {
56
+ open: { width: "auto", opacity: 1, x: 0, transition: { duration: 0.16, ease: EASE_OUT } },
57
+ folded: { width: 0, opacity: 0, x: 0, transition: { duration: 0.16, ease: EASE_OUT } },
58
+ }
59
+
60
+ /**
61
+ * Brand lockup: a mark beside a wordmark. The mark is whatever the brand
62
+ * has — an SVG (`mark`), an image (`src`), or, given neither, a monogram
63
+ * tile derived from `name`. `variant="mark"` folds the wordmark away with
64
+ * the same motion a collapsing rail uses, so the lockup can sit in one and
65
+ * follow its fold. With `href` the whole lockup is a link named by `name`.
66
+ *
67
+ * Sizing rides on the mark: it defaults to `size-8`; restyle via
68
+ * `[&_[data-slot=logo-mark]]:size-*` or wrap in a text-size context for the
69
+ * wordmark, which inherits.
70
+ */
71
+ export function Logo({
72
+ name,
73
+ mark,
74
+ src,
75
+ wordmark,
76
+ variant = "full",
77
+ href,
78
+ className,
79
+ ...props
80
+ }: LogoProps): React.ReactElement {
81
+ const reduce = useReducedMotion()
82
+ const folded = variant === "mark"
83
+
84
+ const markNode = mark ?? (
85
+ src !== undefined ? (
86
+ // Decorative: the root's `name` (visible wordmark or aria-label)
87
+ // already names the brand.
88
+ <img alt="" className="size-full object-contain" src={src} />
89
+ ) : (
90
+ <span
91
+ aria-hidden="true"
92
+ className="flex size-full items-center justify-center rounded-lg bg-foreground font-semibold text-background text-[45cqw]"
93
+ data-slot="logo-monogram"
94
+ >
95
+ {initialsFrom(name)}
96
+ </span>
97
+ )
98
+ )
99
+
100
+ const content = (
101
+ <>
102
+ <span
103
+ className="size-8 shrink-0 @container [&_svg]:size-full"
104
+ data-slot="logo-mark"
105
+ >
106
+ {markNode}
107
+ </span>
108
+ <motion.span
109
+ animate={folded ? "folded" : "open"}
110
+ variants={reduce ? WORDMARK_VARIANTS_REDUCED : WORDMARK_VARIANTS}
111
+ // Folded, the wordmark is gone for AT too — the root's label names
112
+ // the brand on its own.
113
+ aria-hidden={folded || undefined}
114
+ className={cn(
115
+ "min-w-0 overflow-hidden font-semibold whitespace-nowrap tracking-tight",
116
+ folded && "pointer-events-none",
117
+ // 0px wide while folded, but the flex gap before it would still
118
+ // offset the mark — pull it back by that gap.
119
+ folded && "-ml-2"
120
+ )}
121
+ data-slot="logo-wordmark"
122
+ initial={false}
123
+ >
124
+ {wordmark ?? name}
125
+ </motion.span>
126
+ </>
127
+ )
128
+
129
+ const rootClassName = cn(
130
+ "inline-flex shrink-0 items-center gap-2 text-foreground",
131
+ className
132
+ )
133
+
134
+ return href !== undefined ? (
135
+ <a
136
+ // Always labelled: the folded state hides the wordmark, and a link
137
+ // must not lose its name mid-animation.
138
+ aria-label={name}
139
+ className={rootClassName}
140
+ data-slot="logo"
141
+ data-variant={variant}
142
+ href={href}
143
+ {...props}
144
+ >
145
+ {content}
146
+ </a>
147
+ ) : (
148
+ <span
149
+ aria-label={folded ? name : undefined}
150
+ className={rootClassName}
151
+ data-slot="logo"
152
+ data-variant={variant}
153
+ role={folded ? "img" : undefined}
154
+ {...props}
155
+ >
156
+ {content}
157
+ </span>
158
+ )
159
+ }
@@ -8,13 +8,16 @@ import {
8
8
  removeUserAvatarInstance,
9
9
  resetUserAvatarAtom,
10
10
  setUserAvatarBezelAngleAtom,
11
+ setUserAvatarImageToneAtom,
11
12
  setUserAvatarPresenceAtom,
12
13
  toggleUserAvatarPresenceAtom,
13
14
  userAvatarBezelAngleAtom,
15
+ userAvatarImageToneAtom,
14
16
  userAvatarIsOnlineAtom,
15
17
  userAvatarPresenceAtom,
16
18
  userAvatarStateAtom,
17
19
  } from "../user-avatar-atom"
20
+ import { bezelAlphasForTone } from "../utils"
18
21
  import { useUserAvatarActions, useUserAvatarState } from "../use-user-avatar-state"
19
22
  import type { UserAvatarActions, UserAvatarState } from "../type"
20
23
 
@@ -179,6 +182,43 @@ describe("UserAvatar · bezel", () => {
179
182
  })
180
183
  })
181
184
 
185
+ describe("UserAvatar · bezel tone", () => {
186
+ test("alphas lean on the shadow over a pale rim and ease the highlight over a dark one", () => {
187
+ const dark = bezelAlphasForTone(0)
188
+ const light = bezelAlphasForTone(1)
189
+ expect(dark.hi).toBeLessThan(light.hi)
190
+ expect(dark.lo).toBeGreaterThan(light.lo)
191
+ // Out-of-range input clamps rather than extrapolating past [0, 1].
192
+ expect(bezelAlphasForTone(-3)).toEqual(dark)
193
+ expect(bezelAlphasForTone(7)).toEqual(light)
194
+ })
195
+
196
+ test("without a sampled tone the rim carries no inline alphas — the theme's defaults apply", () => {
197
+ render(<UserAvatar name="Jenny Hamilton" />)
198
+ const rim = bezel()
199
+ expect(rim?.hasAttribute("data-tone")).toBe(false)
200
+ expect(rim?.getAttribute("style")).not.toContain("--bezel-hi-a")
201
+ // The defaults live in classes, per theme, so a photo-less disc is lit
202
+ // for the page it sits on.
203
+ expect(rim?.className).toContain("[--bezel-hi-a:1]")
204
+ expect(rim?.className).toContain("dark:[--bezel-hi-a:0.18]")
205
+ })
206
+
207
+ test("the tone atom clamps and clears", () => {
208
+ const store = createStore()
209
+ store.set(setUserAvatarImageToneAtom("t"), 1.7)
210
+ expect(store.get(userAvatarImageToneAtom("t"))).toBe(1)
211
+ store.set(setUserAvatarImageToneAtom("t"), 0.3)
212
+ expect(store.get(userAvatarImageToneAtom("t"))).toBe(0.3)
213
+ store.set(setUserAvatarImageToneAtom("t"), Number.NaN)
214
+ expect(store.get(userAvatarImageToneAtom("t"))).toBeNull()
215
+ store.set(setUserAvatarImageToneAtom("t"), 0.5)
216
+ store.set(resetUserAvatarAtom("t"))
217
+ expect(store.get(userAvatarImageToneAtom("t"))).toBeNull()
218
+ removeUserAvatarInstance("t")
219
+ })
220
+ })
221
+
182
222
  describe("UserAvatar · store", () => {
183
223
  test("presence, toggle, isOnline and reset on an isolated store", () => {
184
224
  const store = createStore()
@@ -203,6 +243,7 @@ describe("UserAvatar · store", () => {
203
243
  presence: null,
204
244
  bezelAngle: DEFAULT_BEZEL_ANGLE,
205
245
  imageStatus: "idle",
246
+ imageTone: null,
206
247
  })
207
248
  removeUserAvatarInstance("a")
208
249
  })
@@ -8,6 +8,7 @@ import { Fallback } from "./fallback"
8
8
  import { PresenceBadge } from "./presence-badge"
9
9
  import type { UserAvatarProps } from "./type"
10
10
  import { useUserAvatar } from "./use-user-avatar"
11
+ import { bezelAlphasForTone } from "./utils"
11
12
 
12
13
  /**
13
14
  * Identity avatar: an image when one is given, initials otherwise, with an
@@ -44,8 +45,10 @@ export function UserAvatar({
44
45
  presence: presenceProp,
45
46
  bezel,
46
47
  hasImage,
48
+ src,
47
49
  })
48
- const { presence, bezelAngle, imageStatus, setImageStatus, ref } = avatar
50
+ const { presence, bezelAngle, imageStatus, imageTone, setImageStatus, ref } =
51
+ avatar
49
52
 
50
53
  const loading =
51
54
  loadingProp ??
@@ -54,6 +57,10 @@ export function UserAvatar({
54
57
  // still-loading `src` shows initials/skeleton and should be treated as
55
58
  // the flat disc it is.
56
59
  const showsImage = hasImage && loadingProp !== true && imageStatus === "loaded"
60
+ // With a photo on screen and its rim sampled, the bezel's alphas follow the
61
+ // photo rather than the theme: the ring sits on the image, not the page.
62
+ const bezelAlphas =
63
+ showsImage && imageTone !== null ? bezelAlphasForTone(imageTone) : null
57
64
 
58
65
  return (
59
66
  // The frame is what the badge and bezel hang off: the Avatar root clips
@@ -66,7 +73,11 @@ export function UserAvatar({
66
73
  >
67
74
  <Avatar
68
75
  className={cn(
69
- "size-8 border-1 border-white bg-neutral-800! text-xl dark:border-neutral-900",
76
+ // `@container` lets the fallback size its initials in cqw — a
77
+ // fraction of whatever `size-*` the caller put on this element —
78
+ // instead of a fixed text step. `text-xl` stays as the fallback for
79
+ // engines without container units.
80
+ "@container size-8 border-1 border-white bg-neutral-800! text-xl dark:border-neutral-900",
70
81
  // The emboss itself: an inset drop toward the light's opposite side
71
82
  // is what the ring below is rotated against.
72
83
  bezel &&
@@ -122,23 +133,42 @@ export function UserAvatar({
122
133
  // A conic highlight → shadow sweep, masked to a thin rim and rotated
123
134
  // so the highlight faces the pointer. Rotation (not the gradient
124
135
  // angle) is what moves, because `transform` transitions and a
125
- // gradient stop does not. Light and dark pick their own alphas via
126
- // the two CSS vars; the gradient reads them.
136
+ // gradient stop does not. The highlight is white and the shadow
137
+ // black at alphas `--bezel-hi-a` / `--bezel-lo-a`: light and dark
138
+ // pick their own defaults (a white rim glares on a dark page), and a
139
+ // sampled photo overrides both inline so the ring is weighted against
140
+ // the image it actually sits on — scaled by `--bezel-hi-k`, because
141
+ // the rim borders the page as much as the photo and the same white
142
+ // that lifts a dark portrait off a light page glares on a dark one.
127
143
  <span
128
144
  aria-hidden="true"
129
145
  className={cn(
130
146
  "pointer-events-none absolute inset-0 rounded-full",
131
- "[--bezel-hi:--theme(--color-white)] [--bezel-lo:--theme(--color-black/20%)]",
132
- "dark:[--bezel-hi:--theme(--color-white/18%)] dark:[--bezel-lo:--theme(--color-black/40%)]",
147
+ "[--bezel-hi-a:1] [--bezel-lo-a:0.2] dark:[--bezel-hi-a:0.18] dark:[--bezel-lo-a:0.4]",
148
+ "[--bezel-hi-k:1] dark:[--bezel-hi-k:0.55]",
149
+ "[--bezel-hi:color-mix(in_oklab,white_calc(var(--bezel-hi-a)*100%),transparent)]",
150
+ "[--bezel-lo:color-mix(in_oklab,black_calc(var(--bezel-lo-a)*100%),transparent)]",
133
151
  // Starts at 6 o'clock: shadow there, highlight peaking at 12 (50%),
134
152
  // symmetric either side — so rotate(angle) puts the highlight at
135
153
  // exactly `bezelAngle` clockwise from 12.
136
154
  "bg-[conic-gradient(from_180deg,var(--bezel-lo)_0%,transparent_24%,transparent_28%,var(--bezel-hi)_50%,transparent_72%,transparent_76%,var(--bezel-lo)_100%)]",
137
- "[mask-image:radial-gradient(farthest-side,transparent_calc(95%-4px),#000_calc(100%-2px))]",
155
+ // Rim width is a share of the radius, not a px count, so the
156
+ // bezel reads the same at size-8 and size-16: solid over the outer
157
+ // 8%, feathering in over the 14% inside that.
158
+ "[mask-image:radial-gradient(farthest-side,transparent_78%,#000_92%)]",
138
159
  "mix-blend-normal transition-transform duration-150 ease-smooth-out motion-reduce:transition-none"
139
160
  )}
140
161
  data-slot="user-avatar-bezel"
141
- style={{ transform: `rotate(${bezelAngle * BEZEL_DAMPING}deg)` }}
162
+ data-tone={imageTone === null ? undefined : imageTone.toFixed(2)}
163
+ style={{
164
+ transform: `rotate(${bezelAngle * BEZEL_DAMPING}deg)`,
165
+ ...(bezelAlphas
166
+ ? {
167
+ "--bezel-hi-a": `calc(${bezelAlphas.hi.toFixed(2)} * var(--bezel-hi-k))`,
168
+ "--bezel-lo-a": bezelAlphas.lo.toFixed(2),
169
+ }
170
+ : null),
171
+ }}
142
172
  />
143
173
  ) : null}
144
174
  {presence ? (
@@ -12,7 +12,9 @@ export function Fallback({
12
12
  loading?: boolean
13
13
  }) {
14
14
  return (
15
- <AvatarFallback>
15
+ // Initials scale with the disc: 42% of the avatar's width (≈ text-xl on
16
+ // size-12), resolved against the `@container` on the Avatar root.
17
+ <AvatarFallback className="text-[42cqw]">
16
18
  {loading ? (
17
19
  <Skeleton
18
20
  className="size-full rounded-full"
@@ -41,8 +41,11 @@ export interface UserAvatarProps extends Omit<
41
41
  presence?: UserPresence
42
42
  /**
43
43
  * Embossed rim whose highlight follows the pointer, so the avatar reads as
44
- * a lit bezel rather than a flat disc. Light and dark aware; static under
45
- * reduced motion. On by default — pass `false` for a flat disc.
44
+ * a lit bezel rather than a flat disc. Light and dark aware, and with a
45
+ * photo it samples the image's rim lightness (CORS permitting) to weight
46
+ * highlight against shadow, so the emboss reads on a dark portrait and a
47
+ * pale one alike. Static under reduced motion. On by default — pass
48
+ * `false` for a flat disc.
46
49
  */
47
50
  bezel?: boolean
48
51
  /**
@@ -62,6 +65,11 @@ export interface UserAvatarState {
62
65
  * `((a % 360) + 360) % 360` if you need it normalised. */
63
66
  bezelAngle: number
64
67
  imageStatus: ImageStatus
68
+ /** Perceived lightness of the loaded image's rim, 0 (black) → 1 (white),
69
+ * or `null` while unknown — no image yet, or one that could not be sampled
70
+ * (non-CORS host, no canvas). The bezel's highlight/shadow alphas follow
71
+ * it so the emboss reads on a dark photo and a pale one alike. */
72
+ imageTone: number | null
65
73
  }
66
74
 
67
75
  /** Write-only handles onto one avatar. Nothing here re-renders the caller. */
@@ -15,10 +15,13 @@ import {
15
15
  removeUserAvatarInstance,
16
16
  setUserAvatarBezelAngleAtom,
17
17
  setUserAvatarImageStatusAtom,
18
+ setUserAvatarImageToneAtom,
18
19
  userAvatarBezelAngleAtom,
19
20
  userAvatarImageStatusAtom,
21
+ userAvatarImageToneAtom,
20
22
  userAvatarPresenceAtom,
21
23
  } from "./user-avatar-atom"
24
+ import { measureRimTone } from "./utils"
22
25
 
23
26
  export interface UseUserAvatarOptions {
24
27
  avatarId?: UserAvatarInstanceId
@@ -28,6 +31,9 @@ export interface UseUserAvatarOptions {
28
31
  bezel: boolean
29
32
  /** Only meaningful with an image — no image, no status to track. */
30
33
  hasImage: boolean
34
+ /** The image URL, sampled for its rim lightness once it has loaded so the
35
+ * bezel can pick alphas that read against it. Omit to skip sampling. */
36
+ src?: string
31
37
  }
32
38
 
33
39
  export interface UserAvatarBinding {
@@ -35,6 +41,8 @@ export interface UserAvatarBinding {
35
41
  presence: UserPresence | null
36
42
  bezelAngle: number
37
43
  imageStatus: ImageStatus
44
+ /** Rim lightness of the loaded image, 0–1, or `null` while unknown. */
45
+ imageTone: number | null
38
46
  /** Hand to the image's `onLoadingStatusChange`. Stable. */
39
47
  setImageStatus: (status: ImageStatus) => void
40
48
  /** Attach to the avatar root — the bezel is lit relative to its centre. */
@@ -56,7 +64,7 @@ const ANGLE_EPSILON = 1.5
56
64
  * survives a remount.
57
65
  */
58
66
  export function useUserAvatar(options: UseUserAvatarOptions): UserAvatarBinding {
59
- const { presence: presenceProp, bezel, hasImage } = options
67
+ const { presence: presenceProp, bezel, hasImage, src } = options
60
68
  const generatedId = React.useId()
61
69
  const avatarId = options.avatarId ?? generatedId
62
70
  const controlsPresence = presenceProp !== undefined
@@ -69,6 +77,7 @@ export function useUserAvatar(options: UseUserAvatarOptions): UserAvatarBinding
69
77
  const project = useSetAtom(projectUserAvatarPropsAtom(avatarId))
70
78
  const setBezelAngle = useSetAtom(setUserAvatarBezelAngleAtom(avatarId))
71
79
  const setImageStatus = useSetAtom(setUserAvatarImageStatusAtom(avatarId))
80
+ const setImageTone = useSetAtom(setUserAvatarImageToneAtom(avatarId))
72
81
 
73
82
  // Layout effect: the projection must land before children read the store
74
83
  // in the same commit. Primitive deps, so no loop guard is needed here.
@@ -86,6 +95,7 @@ export function useUserAvatar(options: UseUserAvatarOptions): UserAvatarBinding
86
95
  const presence = useAtomValue(userAvatarPresenceAtom(avatarId))
87
96
  const bezelAngle = useAtomValue(userAvatarBezelAngleAtom(avatarId))
88
97
  const imageStatus = useAtomValue(userAvatarImageStatusAtom(avatarId))
98
+ const imageTone = useAtomValue(userAvatarImageToneAtom(avatarId))
89
99
 
90
100
  const ref = React.useRef<HTMLElement | null>(null)
91
101
  const reduce = useReducedMotion()
@@ -140,5 +150,31 @@ export function useUserAvatar(options: UseUserAvatarOptions): UserAvatarBinding
140
150
  if (!hasImage) setImageStatus("idle")
141
151
  }, [hasImage, setImageStatus])
142
152
 
143
- return { avatarId, presence, bezelAngle, imageStatus, setImageStatus, ref }
153
+ // Once the image is on screen, read how light its rim is so the bezel's
154
+ // highlight and shadow can be weighted against it. Cleared whenever the
155
+ // image changes or goes away — a stale tone from the previous photo would
156
+ // light the new one wrong for a frame. Sampling is async (a second, CORS
157
+ // clean decode of the same URL, memoised per src); the cancelled flag
158
+ // keeps a slow sample from landing on a later src.
159
+ React.useEffect(() => {
160
+ setImageTone(null)
161
+ if (!bezel || !hasImage || !src || imageStatus !== "loaded") return
162
+ let cancelled = false
163
+ void measureRimTone(src).then((tone) => {
164
+ if (!cancelled) setImageTone(tone)
165
+ })
166
+ return () => {
167
+ cancelled = true
168
+ }
169
+ }, [bezel, hasImage, src, imageStatus, setImageTone])
170
+
171
+ return {
172
+ avatarId,
173
+ presence,
174
+ bezelAngle,
175
+ imageStatus,
176
+ imageTone,
177
+ setImageStatus,
178
+ ref,
179
+ }
144
180
  }
@@ -105,6 +105,12 @@ export const userAvatarImageStatusAtom = stateFamily<ImageStatus>(
105
105
  "idle"
106
106
  )
107
107
 
108
+ /** Rim lightness of the loaded image, `null` until sampled (or unsampleable). */
109
+ export const userAvatarImageToneAtom = stateFamily<number | null>(
110
+ "imageTone",
111
+ null
112
+ )
113
+
108
114
  /** @internal Which fields the mounted component controls from props. */
109
115
  export const userAvatarConfigAtom = stateFamily<UserAvatarConfig>(
110
116
  "config",
@@ -126,6 +132,7 @@ export const userAvatarStateAtom = readFamily<UserAvatarState>(
126
132
  presence: get(userAvatarPresenceAtom(id)),
127
133
  bezelAngle: get(userAvatarBezelAngleAtom(id)),
128
134
  imageStatus: get(userAvatarImageStatusAtom(id)),
135
+ imageTone: get(userAvatarImageToneAtom(id)),
129
136
  })
130
137
  )
131
138
 
@@ -168,6 +175,18 @@ export const setUserAvatarImageStatusAtom = actionFamily<[status: ImageStatus]>(
168
175
  }
169
176
  )
170
177
 
178
+ /** @internal Written by the hook once the loaded image has been sampled;
179
+ * cleared (null) when the image changes or goes away. Clamped to [0, 1]. */
180
+ export const setUserAvatarImageToneAtom = actionFamily<[tone: number | null]>(
181
+ "setImageTone",
182
+ (_get, set, id, tone) => {
183
+ set(
184
+ userAvatarImageToneAtom(id),
185
+ tone === null || !Number.isFinite(tone) ? null : Math.min(1, Math.max(0, tone))
186
+ )
187
+ }
188
+ )
189
+
171
190
  /** @internal Projection of the mounted component's controlled props. */
172
191
  export const projectUserAvatarPropsAtom = actionFamily<
173
192
  [config: UserAvatarConfig, presence: UserPresence | null | undefined]
@@ -187,6 +206,7 @@ export const resetUserAvatarAtom = actionFamily<[]>(
187
206
  set(userAvatarPresenceAtom(id), null)
188
207
  set(userAvatarBezelAngleAtom(id), DEFAULT_BEZEL_ANGLE)
189
208
  set(userAvatarImageStatusAtom(id), "idle")
209
+ set(userAvatarImageToneAtom(id), null)
190
210
  set(userAvatarConfigAtom(id), DEFAULT_CONFIG)
191
211
  }
192
212
  )