@olwiba/ui 0.2.17 → 0.2.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/ui",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -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).slice(0, 2).toUpperCase();
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" className="grayscale">
207
+ <Avatar mode={avatarMode} size="sm">
167
208
  {user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
168
- <AvatarFallback>{initials}</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>{initials}</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
- <SidebarMenuButton
263
- asChild
264
- size="lg"
265
- // No tooltip: unlike a nav item, the collapsed rail's logo is
266
- // still the logo a hover label repeating the app's own name
267
- // is noise. Nav items keep theirs because a bare icon doesn't
268
- // say where it goes.
269
- className={RAIL_SQUARE}
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
- {renderLink({
272
- href: brand.href ?? '#',
273
- children: (
274
- <>
275
- {brand.logo ? (
276
- // Neutral slot: the consumer's logo owns its own chrome
277
- // (background, radius). Sized to fill the collapsed icon rail.
278
- <span className="flex size-8 shrink-0 items-center justify-center [&>svg]:size-5">
279
- {brand.logo}
280
- </span>
281
- ) : (
282
- <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-sm font-semibold text-primary-foreground">
283
- {fallbackLogo}
284
- </span>
285
- )}
286
- <span className="min-w-0 truncate text-base font-semibold group-data-[collapsible=icon]:hidden">
287
- {brand.name}
288
- </span>
289
- </>
290
- ),
291
- })}
292
- </SidebarMenuButton>
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>
@@ -14,8 +14,21 @@ export interface HeroSectionProps {
14
14
  heading: string;
15
15
  badge?: string;
16
16
  description: string;
17
- primaryCta: { label: string; href: string };
17
+ /** Ignored when `primarySlot` is given. */
18
+ primaryCta?: { label: string; href: string };
18
19
  secondaryCta?: { label: string; href: string };
20
+ /**
21
+ * Replaces the primary button with your own control — a search field, a
22
+ * short form, an email capture.
23
+ *
24
+ * A hero whose first action *is* the product converts better than one that
25
+ * links to a page where the product starts. `secondaryCta` still renders
26
+ * beside it, so "…or see pricing" survives.
27
+ *
28
+ * Rendered full-width above the secondary CTA on small screens, since a
29
+ * field sharing a row with a button gets squeezed to nothing on a phone.
30
+ */
31
+ primarySlot?: React.ReactNode;
19
32
  heroImage?: React.ReactNode;
20
33
  media?: 'image' | 'phone' | 'none';
21
34
  phoneSize?: 'sm' | 'md' | 'lg';
@@ -34,6 +47,7 @@ export function HeroSection({
34
47
  description,
35
48
  primaryCta,
36
49
  secondaryCta,
50
+ primarySlot,
37
51
  heroImage,
38
52
  media = 'image',
39
53
  phoneSize = 'lg',
@@ -60,16 +74,26 @@ export function HeroSection({
60
74
  <p className="max-w-lg text-pretty text-lg text-muted-foreground">
61
75
  {description}
62
76
  </p>
63
- <div className="flex flex-wrap items-center gap-3">
64
- {renderLink({
65
- href: primaryCta.href,
66
- children: (
67
- <Button size="lg">
68
- {primaryCta.label}
69
- <ArrowRight className="ml-2 size-4" />
70
- </Button>
71
- ),
72
- })}
77
+ <div
78
+ className={cn(
79
+ 'flex gap-3',
80
+ primarySlot
81
+ ? 'flex-col items-stretch sm:flex-row sm:items-center'
82
+ : 'flex-wrap items-center',
83
+ )}
84
+ >
85
+ {primarySlot ?? (
86
+ primaryCta &&
87
+ renderLink({
88
+ href: primaryCta.href,
89
+ children: (
90
+ <Button size="lg">
91
+ {primaryCta.label}
92
+ <ArrowRight className="ml-2 size-4" />
93
+ </Button>
94
+ ),
95
+ })
96
+ )}
73
97
  {secondaryCta &&
74
98
  renderLink({
75
99
  href: secondaryCta.href,