@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.
- package/dist-lib/components/blocks/layout.d.ts +4 -1
- package/dist-lib/components/blocks/profile/profile-card-block.d.ts +1 -1
- package/dist-lib/components/blocks/shell/index.d.ts +0 -12
- package/dist-lib/components/composed/logo.d.ts +34 -0
- package/dist-lib/components/user-avatar/type.d.ts +10 -2
- package/dist-lib/components/user-avatar/use-user-avatar.d.ts +5 -0
- package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +9 -0
- package/dist-lib/components/user-avatar/utils.d.ts +22 -0
- package/dist-lib/index.d.ts +1 -1
- package/dist-lib/index.js +3083 -3229
- package/dist-lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/blocks/layout.ts +30 -2
- package/src/components/blocks/profile/profile-card-block.tsx +1 -1
- package/src/components/blocks/shell/index.ts +0 -13
- package/src/components/composed/__tests__/logo.test.tsx +60 -0
- package/src/components/composed/logo.tsx +159 -0
- package/src/components/user-avatar/__tests__/user-avatar.test.tsx +41 -0
- package/src/components/user-avatar/component.tsx +38 -8
- package/src/components/user-avatar/fallback.tsx +3 -1
- package/src/components/user-avatar/type.ts +10 -2
- package/src/components/user-avatar/use-user-avatar.ts +38 -2
- package/src/components/user-avatar/user-avatar-atom.ts +20 -0
- package/src/components/user-avatar/utils.ts +94 -0
- package/src/index.ts +1 -1
- package/dist-lib/components/blocks/nav/sidebar-block.d.ts +0 -14
- package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +0 -3
- package/dist-lib/components/blocks/nav/types.d.ts +0 -60
- package/dist-lib/components/blocks/nav/utils.d.ts +0 -1
- package/dist-lib/components/blocks/shell/animated-sidebar-content.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-footer.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group-content.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group-label.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-group.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-header.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-button.d.ts +0 -3
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-item.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-button.d.ts +0 -3
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-item.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub.d.ts +0 -2
- package/dist-lib/components/blocks/shell/animated-sidebar-menu.d.ts +0 -2
- package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +0 -125
- package/src/components/blocks/nav/sidebar-block.tsx +0 -142
- package/src/components/blocks/nav/sidebar-nav-row.tsx +0 -78
- package/src/components/blocks/nav/types.ts +0 -65
- package/src/components/blocks/nav/utils.ts +0 -4
- package/src/components/blocks/shell/animated-sidebar-content.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-footer.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-group-content.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-group-label.tsx +0 -29
- package/src/components/blocks/shell/animated-sidebar-group.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-header.tsx +0 -16
- package/src/components/blocks/shell/animated-sidebar-menu-button.tsx +0 -151
- package/src/components/blocks/shell/animated-sidebar-menu-item.tsx +0 -20
- package/src/components/blocks/shell/animated-sidebar-menu-sub-button.tsx +0 -85
- package/src/components/blocks/shell/animated-sidebar-menu-sub-item.tsx +0 -19
- package/src/components/blocks/shell/animated-sidebar-menu-sub.tsx +0 -44
- package/src/components/blocks/shell/animated-sidebar-menu.tsx +0 -26
- /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,7 +1,35 @@
|
|
|
1
|
-
import ShellLayout
|
|
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
|
|
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 } =
|
|
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
|
-
|
|
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.
|
|
126
|
-
//
|
|
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
|
|
132
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
*
|
|
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
|
-
|
|
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
|
)
|