@olympusoss/canvas 2.10.0 → 2.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olympusoss/canvas",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import { Loader2 } from "lucide-react";
4
+ import * as React from "react";
5
+
6
+ import { cn } from "../../lib/utils";
7
+
8
+ export interface SpinnerProps extends React.SVGAttributes<SVGSVGElement> {
9
+ /**
10
+ * Pixel size of the rendered SVG.
11
+ * @default 16
12
+ */
13
+ size?: number;
14
+ /** Tailwind / CSS classes merged via `cn()`. */
15
+ className?: string;
16
+ /**
17
+ * Accessible label announced by screen readers. Defaults to `"Loading"`.
18
+ * Pass an empty string to mark as purely decorative when the surrounding
19
+ * label already announces the loading state.
20
+ */
21
+ label?: string;
22
+ }
23
+
24
+ /**
25
+ * Inline loading indicator. Wraps `Loader2` from lucide-react with the
26
+ * canvas `animate-spin` keyframe and an accessible label.
27
+ *
28
+ * Use inside buttons (`{loading ? <Spinner/> : 'Submit'}`), next to inline
29
+ * labels, or anywhere a small spinning indicator is appropriate. For a
30
+ * full-section loader with a message, use `LoadingState` (molecule).
31
+ */
32
+ const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(
33
+ ({ size = 16, className, label = "Loading", ...props }, ref) => (
34
+ <Loader2
35
+ ref={ref}
36
+ size={size}
37
+ className={cn("animate-spin shrink-0", className)}
38
+ role={label ? "status" : undefined}
39
+ aria-label={label || undefined}
40
+ aria-hidden={label ? undefined : true}
41
+ {...props}
42
+ />
43
+ ),
44
+ );
45
+ Spinner.displayName = "Spinner";
46
+
47
+ export { Spinner };
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "../../lib/utils";
6
+ import { Card, CardContent, CardHeader } from "./card";
7
+
8
+ export interface AuthShellProps {
9
+ /**
10
+ * Brand lockup rendered above the card. Compose a `ClientBrand` (or
11
+ * your own component) and pass it in. Slot is left empty to keep this
12
+ * shell unaware of OAuth2 / Hydra specifics.
13
+ */
14
+ brand?: React.ReactNode;
15
+ /** Card title. Pairs with `subtitle`. */
16
+ title?: React.ReactNode;
17
+ /** Optional one-line subtitle shown beneath `title`. */
18
+ subtitle?: React.ReactNode;
19
+ /** Card body content. */
20
+ children?: React.ReactNode;
21
+ /**
22
+ * Optional footer line beneath the card (legal / privacy / support).
23
+ */
24
+ footer?: React.ReactNode;
25
+ /**
26
+ * Card width preset. `default` = 408px (single-column flows like login,
27
+ * recovery), `wide` = 460px (multi-step or denser flows like consent).
28
+ * @default "default"
29
+ */
30
+ width?: "default" | "wide";
31
+ /**
32
+ * Allow children to break the card frame (useful for full-bleed flow
33
+ * pickers or media). When `false` the card content gets the standard
34
+ * `p-6` padding.
35
+ * @default false
36
+ */
37
+ flush?: boolean;
38
+ /** Tailwind / CSS classes merged onto the outer wrapper via `cn()`. */
39
+ className?: string;
40
+ }
41
+
42
+ /**
43
+ * Centered single-card layout for auth flows.
44
+ *
45
+ * Renders, top to bottom:
46
+ * 1. Brand slot (`brand`) above the card
47
+ * 2. Card with optional centred title + subtitle, then `children`
48
+ * 3. Optional footer line beneath the card
49
+ *
50
+ * Layout is intentionally narrow and prescriptive: this is the
51
+ * agreed-upon shape for every Olympus auth screen (login, register,
52
+ * recovery, reset, verify, OTP, consent, logout, status). For app shells
53
+ * with sidebars or multi-pane layouts, compose your own flex shell
54
+ * directly (see `Sidebar` + `SidebarInset`).
55
+ */
56
+ const AuthShell = React.forwardRef<HTMLDivElement, AuthShellProps>(
57
+ (
58
+ { brand, title, subtitle, children, footer, width = "default", flush = false, className },
59
+ ref,
60
+ ) => (
61
+ <div
62
+ ref={ref}
63
+ className={cn(
64
+ "relative z-10 flex min-h-screen flex-col items-center justify-center px-6 py-12",
65
+ className,
66
+ )}
67
+ >
68
+ {brand && <div className="mb-7">{brand}</div>}
69
+
70
+ <Card
71
+ className={cn(
72
+ "w-full shadow-lg shadow-black/5 dark:shadow-black/30",
73
+ width === "wide" ? "max-w-[460px]" : "max-w-[408px]",
74
+ )}
75
+ >
76
+ {title && (
77
+ <CardHeader className={cn("text-center", subtitle ? "pb-3" : "pb-4")}>
78
+ <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
79
+ {subtitle && <p className="mt-1.5 text-sm text-muted-foreground">{subtitle}</p>}
80
+ </CardHeader>
81
+ )}
82
+ <CardContent className={cn(flush ? "p-0" : "pt-0", !title && !flush && "pt-6")}>
83
+ {children}
84
+ </CardContent>
85
+ </Card>
86
+
87
+ {footer && (
88
+ <div className="mt-6 max-w-[460px] text-center text-xs text-muted-foreground">{footer}</div>
89
+ )}
90
+ </div>
91
+ ),
92
+ );
93
+ AuthShell.displayName = "AuthShell";
94
+
95
+ export { AuthShell };
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "../../lib/utils";
6
+
7
+ export interface ClientBrandProps {
8
+ /** OAuth2 client display name (e.g. "Questrade", "Athena"). */
9
+ clientName: string;
10
+ /**
11
+ * Optional logo URI. Renders as an `<img>` next to the wordmark when
12
+ * present. When absent, a tinted monogram tile is rendered using the
13
+ * first letter of `clientName`.
14
+ */
15
+ clientLogoUri?: string;
16
+ /**
17
+ * Pixel size of the logo/monogram tile.
18
+ * @default 36
19
+ */
20
+ size?: number;
21
+ /**
22
+ * Show the wordmark next to the logo.
23
+ * @default true
24
+ */
25
+ showWordmark?: boolean;
26
+ /**
27
+ * Background colour applied to the monogram fallback tile. Honours
28
+ * Tailwind classes or a raw CSS colour.
29
+ * @default "bg-foreground text-background"
30
+ */
31
+ monogramClassName?: string;
32
+ /** Tailwind / CSS classes merged onto the root via `cn()`. */
33
+ className?: string;
34
+ }
35
+
36
+ /**
37
+ * OAuth2 client lockup. Renders the requesting application's logo + name
38
+ * so users see "you're signing in to [App]", not the identity-provider
39
+ * platform underneath.
40
+ *
41
+ * Provide `clientLogoUri` to render the client's logo image. Falls back to
42
+ * a monogram tile when no URI is supplied or the image fails to load.
43
+ *
44
+ * Designed for the brand slot of `AuthShell` / sign-in pages. Stays a thin
45
+ * presentation primitive: no client-registry lookups, no fetches. Pass the
46
+ * shaped data in from your auth backend.
47
+ */
48
+ const ClientBrand = React.forwardRef<HTMLDivElement, ClientBrandProps>(
49
+ (
50
+ { clientName, clientLogoUri, size = 36, showWordmark = true, monogramClassName, className },
51
+ ref,
52
+ ) => {
53
+ const [imgFailed, setImgFailed] = React.useState(false);
54
+ const showImage = !!clientLogoUri && !imgFailed;
55
+ const initial = (clientName?.trim()?.[0] || "?").toUpperCase();
56
+
57
+ return (
58
+ <div
59
+ ref={ref}
60
+ role="img"
61
+ aria-label={`Signing in to ${clientName}`}
62
+ className={cn("flex items-center gap-2.5", className)}
63
+ >
64
+ {showImage ? (
65
+ <img
66
+ src={clientLogoUri}
67
+ alt=""
68
+ width={size}
69
+ height={size}
70
+ className="shrink-0 rounded-lg object-contain"
71
+ style={{ width: size, height: size }}
72
+ onError={() => setImgFailed(true)}
73
+ />
74
+ ) : (
75
+ <div
76
+ className={cn(
77
+ "flex shrink-0 items-center justify-center rounded-lg font-semibold",
78
+ monogramClassName || "bg-foreground text-background",
79
+ )}
80
+ style={{ width: size, height: size, fontSize: Math.round(size * 0.5) }}
81
+ aria-hidden
82
+ >
83
+ {initial}
84
+ </div>
85
+ )}
86
+ {showWordmark && (
87
+ <span className="text-lg font-semibold tracking-tight text-foreground">{clientName}</span>
88
+ )}
89
+ </div>
90
+ );
91
+ },
92
+ );
93
+ ClientBrand.displayName = "ClientBrand";
94
+
95
+ export { ClientBrand };
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "../../lib/utils";
6
+ import { Button, type ButtonProps } from "../atoms/button";
7
+
8
+ export interface CountdownButtonProps
9
+ extends Omit<ButtonProps, "disabled" | "onClick" | "children"> {
10
+ /**
11
+ * Seconds to wait before the button becomes clickable. The countdown
12
+ * starts when the component mounts and resets every time `triggerKey`
13
+ * changes.
14
+ * @default 30
15
+ */
16
+ duration?: number;
17
+ /**
18
+ * Bump this value to restart the countdown (e.g. after a successful
19
+ * resend, set it to `Date.now()` so the button locks out again).
20
+ */
21
+ triggerKey?: string | number;
22
+ /** Called when the button is clicked and the countdown has elapsed. */
23
+ onClick?: () => void;
24
+ /**
25
+ * Render-prop for the button label. Receives `secondsLeft` (0 when
26
+ * ready). Default renders `"Resend"` / `"Resend in 23s"`.
27
+ */
28
+ children?: React.ReactNode | ((secondsLeft: number) => React.ReactNode);
29
+ /**
30
+ * Action verb shown in the default label. Used as `${verb}` when ready
31
+ * and `${verb} in Ns` while counting down.
32
+ * @default "Resend"
33
+ */
34
+ verb?: string;
35
+ }
36
+
37
+ /**
38
+ * Button that locks out for a duration after mount or after `triggerKey`
39
+ * changes. Renders countdown text while disabled.
40
+ *
41
+ * Use for "resend code" / "resend email" flows where the user shouldn't be
42
+ * able to spam the action. Triggering a new send from elsewhere should
43
+ * update `triggerKey` to restart the lockout.
44
+ */
45
+ const CountdownButton = React.forwardRef<HTMLButtonElement, CountdownButtonProps>(
46
+ (
47
+ { duration = 30, triggerKey, onClick, children, verb = "Resend", className, ...buttonProps },
48
+ ref,
49
+ ) => {
50
+ const [secondsLeft, setSecondsLeft] = React.useState(duration);
51
+
52
+ React.useEffect(() => {
53
+ setSecondsLeft(duration);
54
+ const interval = setInterval(() => {
55
+ setSecondsLeft((s) => {
56
+ if (s <= 1) {
57
+ clearInterval(interval);
58
+ return 0;
59
+ }
60
+ return s - 1;
61
+ });
62
+ }, 1000);
63
+ return () => clearInterval(interval);
64
+ }, [duration, triggerKey]);
65
+
66
+ const ready = secondsLeft <= 0;
67
+ const labelNode =
68
+ typeof children === "function"
69
+ ? children(secondsLeft)
70
+ : children !== undefined
71
+ ? children
72
+ : ready
73
+ ? verb
74
+ : `${verb} in ${secondsLeft}s`;
75
+
76
+ return (
77
+ <Button
78
+ ref={ref}
79
+ type="button"
80
+ disabled={!ready}
81
+ onClick={() => ready && onClick?.()}
82
+ className={cn(className)}
83
+ {...buttonProps}
84
+ >
85
+ {labelNode}
86
+ </Button>
87
+ );
88
+ },
89
+ );
90
+ CountdownButton.displayName = "CountdownButton";
91
+
92
+ export { CountdownButton };
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ import { Eye, EyeOff } from "lucide-react";
4
+ import * as React from "react";
5
+
6
+ import { cn } from "../../lib/utils";
7
+ import { Button } from "../atoms/button";
8
+ import { Input } from "../atoms/input";
9
+
10
+ export interface PasswordInputProps
11
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
12
+ /**
13
+ * Initial visibility state. Pair with `onVisibilityChange` for a
14
+ * controlled input.
15
+ * @default false
16
+ */
17
+ defaultVisible?: boolean;
18
+ /** Controlled visibility. Overrides `defaultVisible`. */
19
+ visible?: boolean;
20
+ /** Fired when the user clicks the eye toggle. */
21
+ onVisibilityChange?: (visible: boolean) => void;
22
+ /**
23
+ * Accessible label on the eye toggle button.
24
+ * @default "Toggle password visibility"
25
+ */
26
+ toggleLabel?: string;
27
+ }
28
+
29
+ /**
30
+ * Password input with a show/hide eye toggle. Renders as an `Input` (atom)
31
+ * with a `Button` (atom) absolutely positioned at the trailing edge.
32
+ *
33
+ * Use anywhere a password field is needed: login, registration, settings,
34
+ * reset flows.
35
+ */
36
+ const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
37
+ (
38
+ {
39
+ defaultVisible = false,
40
+ visible: controlledVisible,
41
+ onVisibilityChange,
42
+ toggleLabel = "Toggle password visibility",
43
+ className,
44
+ ...props
45
+ },
46
+ ref,
47
+ ) => {
48
+ const [internalVisible, setInternalVisible] = React.useState(defaultVisible);
49
+ const visible = controlledVisible ?? internalVisible;
50
+
51
+ const toggle = () => {
52
+ const next = !visible;
53
+ if (controlledVisible === undefined) setInternalVisible(next);
54
+ onVisibilityChange?.(next);
55
+ };
56
+
57
+ return (
58
+ <div className="relative">
59
+ <Input
60
+ ref={ref}
61
+ type={visible ? "text" : "password"}
62
+ className={cn("pr-10", className)}
63
+ {...props}
64
+ />
65
+ <Button
66
+ type="button"
67
+ variant="ghost"
68
+ size="icon"
69
+ onClick={toggle}
70
+ aria-label={toggleLabel}
71
+ aria-pressed={visible}
72
+ className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
73
+ tabIndex={-1}
74
+ >
75
+ {visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
76
+ </Button>
77
+ </div>
78
+ );
79
+ },
80
+ );
81
+ PasswordInput.displayName = "PasswordInput";
82
+
83
+ export { PasswordInput };
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "../../lib/utils";
6
+
7
+ export type PasswordStrengthLevel = 0 | 1 | 2 | 3 | 4;
8
+
9
+ export interface PasswordStrength {
10
+ /** 0 = empty, 1 = weak, 2 = fair, 3 = good, 4 = strong. */
11
+ level: PasswordStrengthLevel;
12
+ /** Short human label shown next to the meter. */
13
+ label: string;
14
+ }
15
+
16
+ /**
17
+ * Score a password 0-4 based on length + character variety. Returns the
18
+ * level plus a short label. Heuristic-only; for a true strength score use a
19
+ * dictionary-aware estimator like `@zxcvbn-ts/core` and map its score here.
20
+ */
21
+ export function scorePassword(pw: string): PasswordStrength {
22
+ if (!pw) return { level: 0, label: "" };
23
+ let score = 0;
24
+ if (pw.length >= 8) score++;
25
+ if (pw.length >= 12) score++;
26
+ if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
27
+ if (/\d/.test(pw)) score++;
28
+ if (/[^A-Za-z0-9]/.test(pw)) score++;
29
+ if (pw.length < 6) score = Math.min(score, 1);
30
+
31
+ // score is clamped into [1, 4]: `Math.max(1, …)` provides the floor,
32
+ // `Math.min(4, …)` provides the ceiling. Higher-tier scorers can map
33
+ // dictionary results into the same shape.
34
+ const level = Math.min(4, Math.max(1, score)) as PasswordStrengthLevel;
35
+ const labels = ["", "Weak", "Fair", "Good", "Strong"];
36
+ return { level, label: labels[level] };
37
+ }
38
+
39
+ export interface PasswordStrengthMeterProps {
40
+ /** Password value to score. Empty string renders nothing. */
41
+ value: string;
42
+ /**
43
+ * Custom scorer. Override the default heuristic with a zxcvbn-based
44
+ * scorer for production use.
45
+ */
46
+ score?: (pw: string) => PasswordStrength;
47
+ /** Tailwind / CSS classes merged onto the root via `cn()`. */
48
+ className?: string;
49
+ /**
50
+ * Hide the strength label text and show only the bar.
51
+ * @default false
52
+ */
53
+ hideLabel?: boolean;
54
+ }
55
+
56
+ /**
57
+ * Four-segment strength bar for a password input. Renders nothing for an
58
+ * empty value. Use beneath a password input in registration and reset
59
+ * flows.
60
+ */
61
+ const PasswordStrengthMeter = React.forwardRef<HTMLDivElement, PasswordStrengthMeterProps>(
62
+ ({ value, score = scorePassword, className, hideLabel = false }, ref) => {
63
+ const { level, label } = score(value);
64
+ if (level === 0) return null;
65
+ const segmentColors = [
66
+ "bg-muted",
67
+ "bg-destructive",
68
+ "bg-amber-500",
69
+ "bg-emerald-500",
70
+ "bg-emerald-600",
71
+ ];
72
+ const labelColor =
73
+ level === 1
74
+ ? "text-destructive"
75
+ : level === 2
76
+ ? "text-amber-600 dark:text-amber-500"
77
+ : "text-emerald-600 dark:text-emerald-500";
78
+
79
+ return (
80
+ <div ref={ref} className={cn("flex items-center gap-2", className)}>
81
+ <div className="flex flex-1 gap-1" aria-hidden>
82
+ {[1, 2, 3, 4].map((seg) => (
83
+ <div
84
+ key={seg}
85
+ className={cn(
86
+ "h-1 flex-1 rounded-full transition-colors",
87
+ seg <= level ? segmentColors[level] : "bg-muted",
88
+ )}
89
+ />
90
+ ))}
91
+ </div>
92
+ {!hideLabel && (
93
+ <span className={cn("text-xs font-medium tabular-nums", labelColor)}>{label}</span>
94
+ )}
95
+ <span className="sr-only" role="status">
96
+ Password strength: {label}
97
+ </span>
98
+ </div>
99
+ );
100
+ },
101
+ );
102
+ PasswordStrengthMeter.displayName = "PasswordStrengthMeter";
103
+
104
+ export { PasswordStrengthMeter };
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export { Section, type SectionProps } from "./components/atoms/section";
27
27
  export { Separator } from "./components/atoms/separator";
28
28
  export { Skeleton } from "./components/atoms/skeleton";
29
29
  export { Slider } from "./components/atoms/slider";
30
+ export { Spinner, type SpinnerProps } from "./components/atoms/spinner";
30
31
  export { Switch } from "./components/atoms/switch";
31
32
  export { Textarea } from "./components/atoms/textarea";
32
33
  export { Toggle, toggleVariants } from "./components/atoms/toggle";
@@ -148,6 +149,10 @@ export {
148
149
  type AnimatedBackgroundOrb,
149
150
  type AnimatedBackgroundProps,
150
151
  } from "./components/molecules/animated-background";
152
+ export {
153
+ AuthShell,
154
+ type AuthShellProps,
155
+ } from "./components/molecules/auth-shell";
151
156
  export {
152
157
  BrandLockup,
153
158
  type BrandLockupProps,
@@ -176,7 +181,15 @@ export {
176
181
  CardHeader,
177
182
  CardTitle,
178
183
  } from "./components/molecules/card";
184
+ export {
185
+ ClientBrand,
186
+ type ClientBrandProps,
187
+ } from "./components/molecules/client-brand";
179
188
  export { CodeBlock, type CodeBlockProps } from "./components/molecules/code-block";
189
+ export {
190
+ CountdownButton,
191
+ type CountdownButtonProps,
192
+ } from "./components/molecules/countdown-button";
180
193
  export { EmptyState, type EmptyStateProps } from "./components/molecules/empty-state";
181
194
  export { ErrorState, type ErrorStateProps } from "./components/molecules/error-state";
182
195
  export {
@@ -229,6 +242,17 @@ export {
229
242
  PaginationNext,
230
243
  PaginationPrevious,
231
244
  } from "./components/molecules/pagination";
245
+ export {
246
+ PasswordInput,
247
+ type PasswordInputProps,
248
+ } from "./components/molecules/password-input";
249
+ export {
250
+ type PasswordStrength,
251
+ type PasswordStrengthLevel,
252
+ PasswordStrengthMeter,
253
+ type PasswordStrengthMeterProps,
254
+ scorePassword,
255
+ } from "./components/molecules/password-strength-meter";
232
256
  export {
233
257
  PhoneInput,
234
258
  type PhoneInputProps,