@olwiba/ui 0.2.16 → 0.2.18
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/index.js +40 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/app/AppShell.tsx +97 -35
- package/src/marketing/Footer.tsx +5 -1
- package/src/marketing/Navbar.tsx +22 -2
package/package.json
CHANGED
package/src/app/AppShell.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import type { LucideIcon } from 'lucide-react';
|
|
4
|
-
import { useCallback, type ReactNode } from 'react';
|
|
4
|
+
import { useCallback, type CSSProperties, type ReactNode } from 'react';
|
|
5
5
|
import {
|
|
6
6
|
BellIcon,
|
|
7
7
|
CreditCardIcon,
|
|
@@ -141,11 +141,52 @@ export interface AppShellProps {
|
|
|
141
141
|
const RAIL_SQUARE =
|
|
142
142
|
'group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!min-w-8 group-data-[collapsible=icon]:!max-w-8 group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:transition-[width,height]';
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Initials from a name, or from the local part of an email.
|
|
146
|
+
*
|
|
147
|
+
* "Ollie Bishop" gives OB; "ollie@nestrrr.com" gives OL rather than the OL@
|
|
148
|
+
* that slicing the raw string produced — and `hello@` no longer reads as HE
|
|
149
|
+
* for every address at a domain when a name is absent.
|
|
150
|
+
*/
|
|
151
|
+
function initialsOf(source: string): string {
|
|
152
|
+
const base = source.includes('@') ? (source.split('@')[0] ?? source) : source;
|
|
153
|
+
const words = base.split(/[\s._-]+/).filter(Boolean);
|
|
154
|
+
if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase();
|
|
155
|
+
return (words[0] ?? base).slice(0, 2).toUpperCase();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A stable colour per person, instead of one grey square for everybody.
|
|
160
|
+
*
|
|
161
|
+
* The hue comes from a hash of the email, so an avatar is always the same
|
|
162
|
+
* colour for the same account — recognisable at a glance in a member list, and
|
|
163
|
+
* identical across devices without storing anything.
|
|
164
|
+
*
|
|
165
|
+
* Deliberately not random per render, and deliberately not the brand colour:
|
|
166
|
+
* every user wearing `--primary` would make the avatar read as a piece of app
|
|
167
|
+
* chrome rather than as a person. Saturation and lightness are fixed so the
|
|
168
|
+
* whole set sits at one weight and white text clears contrast on all of them;
|
|
169
|
+
* only the hue moves. A gradient across a small hue span gives it some depth
|
|
170
|
+
* without becoming a second brand.
|
|
171
|
+
*/
|
|
172
|
+
function identityGradient(seed: string): CSSProperties {
|
|
173
|
+
let hash = 0;
|
|
174
|
+
for (let i = 0; i < seed.length; i += 1) {
|
|
175
|
+
hash = (hash * 31 + seed.charCodeAt(i)) | 0;
|
|
176
|
+
}
|
|
177
|
+
const hue = Math.abs(hash) % 360;
|
|
178
|
+
return {
|
|
179
|
+
backgroundImage: `linear-gradient(135deg, oklch(0.62 0.15 ${hue}), oklch(0.52 0.16 ${(hue + 28) % 360}))`,
|
|
180
|
+
color: 'oklch(0.98 0 0)',
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
144
184
|
function NavUser({ user }: { user: AppShellUser }) {
|
|
145
185
|
const { isMobile } = useSidebar();
|
|
146
186
|
const uiMode = useUIMode();
|
|
147
187
|
const avatarMode = uiMode !== 'default' ? (uiMode as 'playful' | 'smooth') : undefined;
|
|
148
|
-
const initials = (user.name ?? user.email)
|
|
188
|
+
const initials = initialsOf(user.name ?? user.email);
|
|
189
|
+
const identityTint = identityGradient(user.email);
|
|
149
190
|
|
|
150
191
|
return (
|
|
151
192
|
<SidebarMenu>
|
|
@@ -163,9 +204,11 @@ function NavUser({ user }: { user: AppShellUser }) {
|
|
|
163
204
|
RAIL_SQUARE,
|
|
164
205
|
)}
|
|
165
206
|
>
|
|
166
|
-
<Avatar mode={avatarMode} size="sm"
|
|
207
|
+
<Avatar mode={avatarMode} size="sm">
|
|
167
208
|
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
|
168
|
-
<AvatarFallback
|
|
209
|
+
<AvatarFallback className="font-medium" style={identityTint}>
|
|
210
|
+
{initials}
|
|
211
|
+
</AvatarFallback>
|
|
169
212
|
</Avatar>
|
|
170
213
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
171
214
|
<span className="truncate font-medium">{user.name ?? user.email}</span>
|
|
@@ -186,7 +229,9 @@ function NavUser({ user }: { user: AppShellUser }) {
|
|
|
186
229
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
|
187
230
|
<Avatar mode={avatarMode} size="sm">
|
|
188
231
|
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
|
189
|
-
<AvatarFallback
|
|
232
|
+
<AvatarFallback className="font-medium" style={identityTint}>
|
|
233
|
+
{initials}
|
|
234
|
+
</AvatarFallback>
|
|
190
235
|
</Avatar>
|
|
191
236
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
192
237
|
<span className="truncate font-medium">{user.name ?? user.email}</span>
|
|
@@ -259,37 +304,54 @@ function ShellSidebar({
|
|
|
259
304
|
<SidebarHeader className="py-3">
|
|
260
305
|
<SidebarMenu>
|
|
261
306
|
<SidebarMenuItem onClickCapture={closeMobileMenu}>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
307
|
+
{/* Not a link, and not a button.
|
|
308
|
+
|
|
309
|
+
It used to navigate to `brand.href`, which duplicated the
|
|
310
|
+
Dashboard nav item sitting directly beneath it — and because it
|
|
311
|
+
was a SidebarMenuButton it also lit up on hover, advertising an
|
|
312
|
+
interaction whose only outcome was a destination already on
|
|
313
|
+
screen. A masthead identifies; the nav navigates.
|
|
314
|
+
|
|
315
|
+
`brand.href` is kept on the type and still used by Navbar and
|
|
316
|
+
Footer, where the lockup is genuinely the way home. */}
|
|
317
|
+
<div
|
|
318
|
+
className={cn(
|
|
319
|
+
'flex items-center gap-2 overflow-hidden rounded-md p-2 text-left',
|
|
320
|
+
RAIL_SQUARE,
|
|
321
|
+
)}
|
|
270
322
|
>
|
|
271
|
-
{
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
323
|
+
{brand.logo ? (
|
|
324
|
+
// Neutral slot: the consumer's logo owns its own chrome
|
|
325
|
+
// (background, radius). Sized to fill the collapsed icon rail.
|
|
326
|
+
<span className="flex size-8 shrink-0 items-center justify-center [&>svg]:size-5">
|
|
327
|
+
{brand.logo}
|
|
328
|
+
</span>
|
|
329
|
+
) : (
|
|
330
|
+
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-sm font-semibold text-primary-foreground">
|
|
331
|
+
{fallbackLogo}
|
|
332
|
+
</span>
|
|
333
|
+
)}
|
|
334
|
+
{/* Exactly the trim Navbar's lockup uses — `ex alphabetic`, both
|
|
335
|
+
edges — because anything else is not centred. An `ex text`
|
|
336
|
+
under edge keeps the font's descender space below the baseline,
|
|
337
|
+
so the ink sits in the top of the box and centring the box puts
|
|
338
|
+
the name high.
|
|
339
|
+
|
|
340
|
+
What that costs is descender clipping, since an alphabetic
|
|
341
|
+
under edge puts the box bottom on the baseline and this span
|
|
342
|
+
used to carry `truncate` (`overflow: hidden`). So the clipping
|
|
343
|
+
moves off this element: the parent already has
|
|
344
|
+
`overflow-hidden` and is 32px tall — sized by the badge — so a
|
|
345
|
+
long name is cut there, where there is room to spare below the
|
|
346
|
+
baseline, instead of here where there is none.
|
|
347
|
+
|
|
348
|
+
The trade is the ellipsis. A name too long for the sidebar now
|
|
349
|
+
ends at the edge rather than in "…". Worth it: every name is
|
|
350
|
+
aligned, and only an overlong one loses the affordance. */}
|
|
351
|
+
<span className="min-w-0 whitespace-nowrap text-base font-semibold leading-none [text-box-edge:ex_alphabetic] [text-box-trim:trim-both] group-data-[collapsible=icon]:hidden">
|
|
352
|
+
{brand.name}
|
|
353
|
+
</span>
|
|
354
|
+
</div>
|
|
293
355
|
</SidebarMenuItem>
|
|
294
356
|
</SidebarMenu>
|
|
295
357
|
</SidebarHeader>
|
package/src/marketing/Footer.tsx
CHANGED
|
@@ -39,7 +39,11 @@ export function Footer({
|
|
|
39
39
|
children: (
|
|
40
40
|
<>
|
|
41
41
|
{brand.logo}
|
|
42
|
-
|
|
42
|
+
{/* Same trim as Navbar's lockup, and more visible here: text-lg
|
|
43
|
+
scales the unused ascender space up with the font size. */}
|
|
44
|
+
<span className="text-lg font-semibold leading-none text-foreground [text-box-edge:ex_alphabetic] [text-box-trim:trim-both]">
|
|
45
|
+
{brand.name}
|
|
46
|
+
</span>
|
|
43
47
|
</>
|
|
44
48
|
),
|
|
45
49
|
})}
|
package/src/marketing/Navbar.tsx
CHANGED
|
@@ -42,7 +42,24 @@ export function Navbar({
|
|
|
42
42
|
children: (
|
|
43
43
|
<span className="flex items-center gap-2 font-semibold">
|
|
44
44
|
{brand.logo}
|
|
45
|
-
|
|
45
|
+
{/* `items-center` centres the text's *box*, not its glyphs, and
|
|
46
|
+
the box reserves room for ascenders and descenders the name may
|
|
47
|
+
not use — so a lowercase wordmark sits low with dead air above.
|
|
48
|
+
`leading-none` only removes half-leading; the ascender space is
|
|
49
|
+
still inside the box, which is why it wasn't enough on its own.
|
|
50
|
+
|
|
51
|
+
text-box-trim/-edge trims the box to the glyphs themselves.
|
|
52
|
+
`ex alphabetic` = x-height to baseline, chosen over `cap
|
|
53
|
+
alphabetic` because these wordmarks are lowercase (nestrrr).
|
|
54
|
+
A product with a capitalised name wants `cap` instead —
|
|
55
|
+
otherwise its capitals overflow the trimmed box and read high.
|
|
56
|
+
|
|
57
|
+
leading-none stays as the fallback: text-box-trim is not
|
|
58
|
+
Baseline yet (no Firefox), and there it degrades to the old
|
|
59
|
+
behaviour rather than breaking. */}
|
|
60
|
+
<span className="leading-none [text-box-edge:ex_alphabetic] [text-box-trim:trim-both]">
|
|
61
|
+
{brand.name}
|
|
62
|
+
</span>
|
|
46
63
|
</span>
|
|
47
64
|
),
|
|
48
65
|
})}
|
|
@@ -104,7 +121,10 @@ export function Navbar({
|
|
|
104
121
|
children: (
|
|
105
122
|
<span className="flex items-center gap-2 font-semibold">
|
|
106
123
|
{brand.logo}
|
|
107
|
-
|
|
124
|
+
{/* Same fix as the desktop lockup above. */}
|
|
125
|
+
<span className="leading-none [text-box-edge:ex_alphabetic] [text-box-trim:trim-both]">
|
|
126
|
+
{brand.name}
|
|
127
|
+
</span>
|
|
108
128
|
</span>
|
|
109
129
|
),
|
|
110
130
|
})}
|