@olwiba/ui 0.2.20 → 0.2.22

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.20",
3
+ "version": "0.2.22",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -35,8 +35,14 @@ export function AppPageHero({
35
35
  <section
36
36
  className={cn(
37
37
  'relative overflow-hidden rounded-3xl border bg-card/80 p-5 shadow-sm sm:p-6',
38
- 'before:absolute before:-right-16 before:-top-16 before:size-44 before:rounded-full before:bg-primary/15 before:blur-3xl',
39
- 'after:absolute after:-bottom-20 after:left-10 after:size-52 after:rounded-full after:bg-primary/10 after:blur-3xl',
38
+ // The glow blobs are decoration, and `::after` is the section's last
39
+ // child so with everything on `z-index: auto` it paints above the
40
+ // header content and swallows clicks on whatever sits under it. At
41
+ // narrow widths the action row stacks below the copy, right into the
42
+ // bottom-left blob, which is how a visible button stops responding.
43
+ // pointer-events-none keeps them purely visual.
44
+ 'before:pointer-events-none before:absolute before:-right-16 before:-top-16 before:size-44 before:rounded-full before:bg-primary/15 before:blur-3xl',
45
+ 'after:pointer-events-none after:absolute after:-bottom-20 after:left-10 after:size-52 after:rounded-full after:bg-primary/10 after:blur-3xl',
40
46
  className,
41
47
  )}
42
48
  >
@@ -4,10 +4,12 @@ import type { LucideIcon } from 'lucide-react';
4
4
  import { useCallback, type CSSProperties, type ReactNode } from 'react';
5
5
  import {
6
6
  BellIcon,
7
+ BirdIcon,
7
8
  CreditCardIcon,
8
9
  LogOutIcon,
9
10
  MoreVerticalIcon,
10
11
  PlusCircleIcon,
12
+ SettingsIcon,
11
13
  } from 'lucide-react';
12
14
  import {
13
15
  Avatar,
@@ -63,6 +65,7 @@ export interface AppShellUser {
63
65
  onSignOut?: () => void;
64
66
  onBilling?: () => void;
65
67
  onNotifications?: () => void;
68
+ onSettings?: () => void;
66
69
  }
67
70
 
68
71
  export interface AppShellBrand {
@@ -142,41 +145,35 @@ const RAIL_SQUARE =
142
145
  '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
146
 
144
147
  /**
145
- * Initials from a name, or from the local part of an email.
148
+ * A stable miniature identity artwork, instead of one grey square or a pair
149
+ * of initials for everybody.
146
150
  *
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.
151
+ * FNV-1a gives the same unsigned hash for the same name/email on every device.
152
+ * Different byte ranges choose three hues and the gradient angle, so nearby
153
+ * names do not merely receive nearby shades of the same two-colour gradient.
164
154
  *
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.
155
+ * Lightness stays in a bounded range so the white bird remains legible. The
156
+ * radial highlight supplies depth without adding a fourth random colour.
171
157
  */
172
158
  function identityGradient(seed: string): CSSProperties {
173
- let hash = 0;
159
+ let hash = 2166136261;
174
160
  for (let i = 0; i < seed.length; i += 1) {
175
- hash = (hash * 31 + seed.charCodeAt(i)) | 0;
161
+ hash ^= seed.charCodeAt(i);
162
+ hash = Math.imul(hash, 16777619);
176
163
  }
177
- const hue = Math.abs(hash) % 360;
164
+ hash >>>= 0;
165
+
166
+ const firstHue = hash % 360;
167
+ const secondHue = (firstHue + 55 + ((hash >>> 8) % 70)) % 360;
168
+ const thirdHue = (firstHue + 190 + ((hash >>> 16) % 110)) % 360;
169
+ const angleHash = Math.imul(hash ^ (hash >>> 15), 2246822519) >>> 0;
170
+ const angle = angleHash % 360;
171
+
178
172
  return {
179
- backgroundImage: `linear-gradient(135deg, oklch(0.62 0.15 ${hue}), oklch(0.52 0.16 ${(hue + 28) % 360}))`,
173
+ backgroundImage: [
174
+ `radial-gradient(circle at 28% 18%, oklch(0.94 0.06 ${thirdHue} / 0.78), transparent 34%)`,
175
+ `linear-gradient(${angle}deg, oklch(0.68 0.18 ${firstHue}), oklch(0.55 0.2 ${secondHue}) 52%, oklch(0.62 0.18 ${thirdHue}))`,
176
+ ].join(', '),
180
177
  color: 'oklch(0.98 0 0)',
181
178
  };
182
179
  }
@@ -185,8 +182,8 @@ function NavUser({ user }: { user: AppShellUser }) {
185
182
  const { isMobile } = useSidebar();
186
183
  const uiMode = useUIMode();
187
184
  const avatarMode = uiMode !== 'default' ? (uiMode as 'playful' | 'smooth') : undefined;
188
- const initials = initialsOf(user.name ?? user.email);
189
- const identityTint = identityGradient(user.email);
185
+ const identitySeed = `${user.name?.trim().toLocaleLowerCase() ?? ''}|${user.email.toLocaleLowerCase()}`;
186
+ const identityTint = identityGradient(identitySeed);
190
187
 
191
188
  return (
192
189
  <SidebarMenu>
@@ -206,8 +203,11 @@ function NavUser({ user }: { user: AppShellUser }) {
206
203
  >
207
204
  <Avatar mode={avatarMode} size="sm">
208
205
  {user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
209
- <AvatarFallback className="font-medium" style={identityTint}>
210
- {initials}
206
+ <AvatarFallback className="text-white" style={identityTint}>
207
+ <BirdIcon
208
+ aria-hidden
209
+ className="size-5 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
210
+ />
211
211
  </AvatarFallback>
212
212
  </Avatar>
213
213
  <div className="grid flex-1 text-left text-sm leading-tight">
@@ -229,8 +229,11 @@ function NavUser({ user }: { user: AppShellUser }) {
229
229
  <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
230
230
  <Avatar mode={avatarMode} size="sm">
231
231
  {user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
232
- <AvatarFallback className="font-medium" style={identityTint}>
233
- {initials}
232
+ <AvatarFallback className="text-white" style={identityTint}>
233
+ <BirdIcon
234
+ aria-hidden
235
+ className="size-5 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
236
+ />
234
237
  </AvatarFallback>
235
238
  </Avatar>
236
239
  <div className="grid flex-1 text-left text-sm leading-tight">
@@ -239,7 +242,7 @@ function NavUser({ user }: { user: AppShellUser }) {
239
242
  </div>
240
243
  </div>
241
244
  </DropdownMenuLabel>
242
- {(user.onBilling || user.onNotifications) && (
245
+ {(user.onBilling || user.onNotifications || user.onSettings) && (
243
246
  <>
244
247
  <DropdownMenuSeparator />
245
248
  <DropdownMenuGroup>
@@ -255,6 +258,12 @@ function NavUser({ user }: { user: AppShellUser }) {
255
258
  Notifications
256
259
  </DropdownMenuItem>
257
260
  )}
261
+ {user.onSettings && (
262
+ <DropdownMenuItem onClick={user.onSettings}>
263
+ <SettingsIcon />
264
+ Settings
265
+ </DropdownMenuItem>
266
+ )}
258
267
  </DropdownMenuGroup>
259
268
  </>
260
269
  )}
@@ -500,7 +509,7 @@ export function AppShell({
500
509
  side={side}
501
510
  sidebarPosition={sidebarPosition}
502
511
  />
503
- <SidebarInset className={isContained ? undefined : 'overflow-y-auto'}>
512
+ <SidebarInset className="overflow-y-auto">
504
513
  <ShellHeader pageTitle={pageTitle} headerStart={headerStart} headerEnd={headerEnd} />
505
514
  <div className={contentClassName}>{children}</div>
506
515
  </SidebarInset>
@@ -105,20 +105,20 @@ export function UpdateBanner({
105
105
  >
106
106
  <Card
107
107
  className={cn(
108
- 'max-w-3xl border-amber-300/60 bg-amber-50 dark:border-amber-700/60 dark:bg-amber-950',
108
+ 'max-w-3xl border-border bg-card text-card-foreground shadow-lg',
109
109
  className,
110
110
  )}
111
111
  >
112
112
  <div className="flex items-center justify-between gap-6 p-4">
113
113
  <div className="flex items-center gap-3">
114
- <Sparkles className="size-5 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" />
115
- <p className="text-sm text-amber-800 dark:text-amber-200">{message}</p>
114
+ <Sparkles className="size-5 shrink-0 text-primary" aria-hidden="true" />
115
+ <p className="text-sm text-card-foreground">{message}</p>
116
116
  </div>
117
117
  <Button
118
118
  onClick={handleRefresh}
119
119
  disabled={refreshing}
120
120
  size="sm"
121
- className="shrink-0 bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700"
121
+ className="shrink-0"
122
122
  >
123
123
  {refreshing ? <RefreshCw className="size-4 animate-spin" /> : buttonLabel}
124
124
  </Button>
@@ -35,10 +35,11 @@ export interface OlwibaUIProviderProps {
35
35
  mode?: UIMode;
36
36
  }
37
37
 
38
- export function OlwibaUIProvider({ children, isMobile: isMobileProp, mode: initialMode = 'default' }: OlwibaUIProviderProps) {
38
+ export function OlwibaUIProvider({ children, isMobile: isMobileProp, mode: modeProp }: OlwibaUIProviderProps) {
39
39
  const detectedMobile = useIsMobile();
40
40
  const isMobile = isMobileProp ?? detectedMobile;
41
- const [mode, setMode] = React.useState<UIMode>(initialMode);
41
+ const [uncontrolledMode, setMode] = React.useState<UIMode>('default');
42
+ const mode = modeProp ?? uncontrolledMode;
42
43
 
43
44
  const variant = mode !== 'default' ? (mode as UIVariant) : undefined;
44
45