@skipleague/design 0.4.4

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.
@@ -0,0 +1,276 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+
3
+ /**
4
+ * The shared SkipLeague bottom navigation (design_handoff_bottom_nav).
5
+ *
6
+ * ONE layout for every app: a row of tabs split evenly around a floating
7
+ * center action ("+"). Only the tab labels/icons and the action's label change
8
+ * per app — spacing, sizing, the button, and its clipped glow are identical
9
+ * everywhere, so the bar looks the same across the family.
10
+ *
11
+ * [tab][tab] ... ( + ) ... [tab][tab]
12
+ * label
13
+ *
14
+ * - `tabs` holds 2–8 tabs. They split around the center: the first
15
+ * `ceil(n/2)` render left of the "+", the rest render right. (For an odd
16
+ * count the extra tab sits on the left; the "+" tracks the true center of
17
+ * its column, so it drifts slightly right of screen-center — even counts are
18
+ * the designed-for case.)
19
+ * - `action` is the floating center button and the word beneath it.
20
+ * - `groups` (optional, even counts only) adds a header band labelling the left
21
+ * pair/triple vs the right. Total bar height is unchanged — the tabs shift
22
+ * down to make room.
23
+ */
24
+
25
+ const ICON = 24; // tab icon box (24px line icons, stroke 2)
26
+ const LABEL_GAP = 10; // icon -> label vertical gap
27
+ const ACTION_SIZE = 53; // floating center button (square)
28
+ const ACTION_RADIUS = 17; // center button corner radius
29
+ const ACTION_OVERHANG = 10; // px the button rises above the bar's top edge
30
+ const PLUS = 26; // "+" glyph box inside the button
31
+
32
+ // Soft mint halo + a grounding shadow. Painted on a layer INSIDE the
33
+ // overflow:hidden bar so it is clipped at the top edge — no glow above the line.
34
+ const ACTION_GLOW = "0 0 22px 4px rgba(94,234,212,0.6), 0 10px 22px rgba(15,118,110,0.30)";
35
+
36
+ // Two vertical-spacing presets that yield the SAME total bar height.
37
+ const PLAIN = { padTop: 10, padBottom: 18, headerGap: 0 };
38
+ const GROUPED = { padTop: 5, padBottom: 6, headerGap: 5 };
39
+
40
+ export interface BottomNavTab {
41
+ label: string;
42
+ /** 24px line icon (stroke 2, currentColor) — inherits the cell's color. */
43
+ icon: ReactNode;
44
+ /** Current tab (accent color, bold). Derive from your router. */
45
+ active?: boolean;
46
+ /**
47
+ * "Tab Colors" option — a per-tab accent (any CSS color). When set and this
48
+ * tab is active, its icon/label AND the center button + glow use this color
49
+ * instead of the default brand. Omit on every tab to keep the standard
50
+ * single-color bar (the default). Provide a color per tab for an app whose
51
+ * tabs each have their own identity (e.g. SkipGifts).
52
+ */
53
+ color?: string;
54
+ onClick?: () => void;
55
+ }
56
+
57
+ export interface BottomNavAction {
58
+ /** Short visual label under the "+" (e.g. "List", "Match"). */
59
+ label: string;
60
+ /**
61
+ * Accessible name for the action control, announced to screen-reader and
62
+ * voice-control users. Use a verb phrase ("New list", "Log match") so the
63
+ * button isn't read as a bare noun confusable with a tab. Falls back to
64
+ * `label`. The visual label always stays `label`; per WCAG 2.5.3, keep
65
+ * `label`'s text inside `ariaLabel` (e.g. label "List" → ariaLabel "New list").
66
+ */
67
+ ariaLabel?: string;
68
+ /** The "+" glyph (white, ~26px, stroke 2.4). Inherits white from the button. */
69
+ icon: ReactNode;
70
+ onClick?: () => void;
71
+ }
72
+
73
+ export interface BottomNavProps {
74
+ /** 2–8 tabs, split evenly around the center action. */
75
+ tabs: BottomNavTab[];
76
+ /** The floating center "+" button and its label. */
77
+ action: BottomNavAction;
78
+ /** Optional grouped-header band; even tab counts only. */
79
+ groups?: [string, string];
80
+ }
81
+
82
+ const cell: CSSProperties = {
83
+ display: "flex",
84
+ flexDirection: "column",
85
+ alignItems: "center",
86
+ gap: LABEL_GAP,
87
+ };
88
+
89
+ const labelBase: CSSProperties = {
90
+ fontFamily: "var(--skl-font-sans)",
91
+ fontSize: 13,
92
+ lineHeight: 1.15,
93
+ };
94
+
95
+ const groupLabel: CSSProperties = {
96
+ ...labelBase,
97
+ fontSize: 11,
98
+ fontWeight: 700,
99
+ letterSpacing: "0.1em",
100
+ textTransform: "uppercase",
101
+ color: "var(--skl-color-text-faint)",
102
+ textAlign: "center",
103
+ };
104
+
105
+ function Tab({ label, icon, active, color, onClick, lift }: BottomNavTab & { lift: number }) {
106
+ const accent = color ?? "var(--skl-color-brand)";
107
+ return (
108
+ <button
109
+ type="button"
110
+ onClick={onClick}
111
+ style={{
112
+ ...cell,
113
+ background: "none",
114
+ border: "none",
115
+ padding: 0,
116
+ cursor: "pointer",
117
+ transform: `translateY(${lift}px)`,
118
+ // icon color (currentColor); the label sets its own color below
119
+ color: active ? accent : "var(--skl-color-text-faint)",
120
+ }}
121
+ >
122
+ <span style={{ height: ICON, display: "flex", alignItems: "flex-end" }}>{icon}</span>
123
+ <span
124
+ style={{
125
+ ...labelBase,
126
+ fontWeight: active ? 700 : 600,
127
+ color: active ? accent : "var(--skl-color-text-muted)",
128
+ }}
129
+ >
130
+ {label}
131
+ </span>
132
+ </button>
133
+ );
134
+ }
135
+
136
+ export function BottomNav({ tabs, action, groups }: BottomNavProps) {
137
+ const n = tabs.length;
138
+ if (n < 2 || n > 8) {
139
+ // Render what we were given, but flag the contract violation in dev.
140
+ console.warn(`BottomNav expects 2–8 tabs, received ${n}.`);
141
+ }
142
+
143
+ // Split around the center: extra tab (odd n) goes left.
144
+ const leftCount = Math.ceil(n / 2);
145
+ const left = tabs.slice(0, leftCount);
146
+ const right = tabs.slice(leftCount);
147
+ const columns = n + 1; // tabs + the center label column
148
+
149
+ // Grouped headers are an even-count feature (a header over each side).
150
+ const grouped = Array.isArray(groups) && groups.length === 2 && n % 2 === 0;
151
+ const sp = grouped ? GROUPED : PLAIN;
152
+
153
+ // In grouped mode the tab row sits lower; lift the center label back in line
154
+ // with the side tabs (right under the "+"), and nudge the tabs up for balance.
155
+ const centerLabelLift = grouped ? -13 : 0;
156
+ const tabLift = grouped ? -2 : 0;
157
+
158
+ // Horizontal center of the action column (== 50% for even counts).
159
+ const actionLeft = `${((leftCount + 0.5) / columns) * 100}%`;
160
+ const gridCols = `repeat(${columns}, 1fr)`;
161
+
162
+ // "Tab Colors": the center button + label + glow follow the active tab's color
163
+ // when one is set; otherwise everything stays the default brand teal + mint glow.
164
+ const activeTab = tabs.find((t) => t.active);
165
+ const accent = activeTab?.color ?? "var(--skl-color-brand)";
166
+ const glow = activeTab?.color
167
+ ? `0 0 22px 4px color-mix(in srgb, ${activeTab.color} 55%, transparent), 0 10px 22px color-mix(in srgb, ${activeTab.color} 30%, transparent)`
168
+ : ACTION_GLOW;
169
+
170
+ return (
171
+ <nav style={{ position: "relative", flex: "none" }}>
172
+ {/* Bar — clips the glow at its top edge ("the line"). */}
173
+ <div
174
+ style={{
175
+ background: "var(--skl-color-surface)",
176
+ borderTop: "1px solid var(--skl-color-border)",
177
+ position: "relative",
178
+ overflow: "hidden",
179
+ // Keep the tabs clear of the iOS home indicator (additive to padBottom).
180
+ paddingBottom: "env(safe-area-inset-bottom, 0px)",
181
+ }}
182
+ >
183
+ {/* Glow layer (clipped to the bar; sits behind the grid). */}
184
+ <span
185
+ aria-hidden
186
+ style={{
187
+ position: "absolute",
188
+ top: -ACTION_OVERHANG,
189
+ left: actionLeft,
190
+ transform: "translateX(-50%)",
191
+ width: ACTION_SIZE,
192
+ height: ACTION_SIZE,
193
+ borderRadius: ACTION_RADIUS,
194
+ boxShadow: glow,
195
+ pointerEvents: "none",
196
+ }}
197
+ />
198
+
199
+ {/* Optional group-header band (labels left vs right). */}
200
+ {grouped && (
201
+ <div style={{ display: "grid", gridTemplateColumns: gridCols, paddingTop: sp.padTop }}>
202
+ <div style={{ ...groupLabel, gridColumn: `1 / ${leftCount + 1}` }}>{groups![0]}</div>
203
+ <div style={{ ...groupLabel, gridColumn: `${leftCount + 2} / ${columns + 1}` }}>
204
+ {groups![1]}
205
+ </div>
206
+ </div>
207
+ )}
208
+
209
+ {/* Tab row. */}
210
+ <div
211
+ style={{
212
+ display: "grid",
213
+ gridTemplateColumns: gridCols,
214
+ paddingTop: grouped ? sp.headerGap : sp.padTop,
215
+ paddingBottom: sp.padBottom,
216
+ position: "relative",
217
+ }}
218
+ >
219
+ {left.map((t, i) => (
220
+ <Tab key={`l${i}`} {...t} lift={tabLift} />
221
+ ))}
222
+
223
+ {/* Center column: the action's label. Pointer-clickable (tapping the
224
+ word fires the action too) but NOT a separate focusable control or
225
+ screen-reader announcement — the floating "+" below is the single
226
+ control for the action, so `aria-hidden` here avoids a duplicate tab
227
+ stop / double announcement. Keyboard & SR users get exactly one
228
+ control per action; touch users can still tap the word. */}
229
+ <div onClick={action.onClick} aria-hidden style={{ ...cell, cursor: "pointer" }}>
230
+ <div style={{ height: ICON }} />
231
+ <span
232
+ style={{
233
+ ...labelBase,
234
+ fontWeight: 700,
235
+ color: accent,
236
+ transform: `translateY(${centerLabelLift}px)`,
237
+ }}
238
+ >
239
+ {action.label}
240
+ </span>
241
+ </div>
242
+
243
+ {right.map((t, i) => (
244
+ <Tab key={`r${i}`} {...t} lift={tabLift} />
245
+ ))}
246
+ </div>
247
+ </div>
248
+
249
+ {/* Floating center button — unclipped, rises ACTION_OVERHANG above the bar. */}
250
+ <button
251
+ type="button"
252
+ onClick={action.onClick}
253
+ aria-label={action.ariaLabel ?? action.label}
254
+ style={{
255
+ position: "absolute",
256
+ top: -ACTION_OVERHANG,
257
+ left: actionLeft,
258
+ transform: "translateX(-50%)",
259
+ width: ACTION_SIZE,
260
+ height: ACTION_SIZE,
261
+ borderRadius: ACTION_RADIUS,
262
+ background: accent,
263
+ color: "#fff",
264
+ border: "none",
265
+ display: "flex",
266
+ alignItems: "center",
267
+ justifyContent: "center",
268
+ zIndex: 3,
269
+ cursor: "pointer",
270
+ }}
271
+ >
272
+ <span style={{ display: "flex", width: PLUS, height: PLUS }}>{action.icon}</span>
273
+ </button>
274
+ </nav>
275
+ );
276
+ }
package/src/Button.tsx ADDED
@@ -0,0 +1,28 @@
1
+ import type { ButtonHTMLAttributes, CSSProperties } from "react";
2
+
3
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
4
+ /** "primary" = brand-green fill (default); "secondary" = white with a border. */
5
+ variant?: "primary" | "secondary";
6
+ }
7
+
8
+ /** The standard SkipLeague button. Token-driven; matches the apps' primary action. */
9
+ export function Button({ variant = "primary", style, disabled, ...props }: ButtonProps) {
10
+ const base: CSSProperties = {
11
+ display: "inline-flex",
12
+ alignItems: "center",
13
+ justifyContent: "center",
14
+ gap: "0.5rem",
15
+ fontFamily: "var(--skl-font-sans)",
16
+ fontSize: "var(--skl-text-sm)",
17
+ fontWeight: 600,
18
+ padding: "0.625rem 1.25rem",
19
+ borderRadius: "var(--skl-radius-md)",
20
+ cursor: disabled ? "default" : "pointer",
21
+ opacity: disabled ? 0.6 : 1,
22
+ border: "1px solid transparent",
23
+ ...(variant === "primary"
24
+ ? { background: "var(--skl-color-brand)", color: "#fff" }
25
+ : { background: "var(--skl-color-surface)", color: "var(--skl-color-text)", borderColor: "var(--skl-color-border)" }),
26
+ };
27
+ return <button {...props} disabled={disabled} style={{ ...base, ...style }} />;
28
+ }
package/src/Card.tsx ADDED
@@ -0,0 +1,33 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+
3
+ /**
4
+ * A section card: an optional heading above a white, bordered, rounded container —
5
+ * the standard grouping used on the account/settings pages.
6
+ */
7
+ export function Card({
8
+ title,
9
+ children,
10
+ style,
11
+ }: {
12
+ title?: string;
13
+ children: ReactNode;
14
+ style?: CSSProperties;
15
+ }) {
16
+ return (
17
+ <section style={{ fontFamily: "var(--skl-font-sans)", ...style }}>
18
+ {title && (
19
+ <h3 style={{ margin: "0 0 0.75rem", fontSize: "1rem", fontWeight: 600, color: "var(--skl-color-text)" }}>{title}</h3>
20
+ )}
21
+ <div
22
+ style={{
23
+ background: "var(--skl-color-surface)",
24
+ border: "1px solid var(--skl-color-border)",
25
+ borderRadius: "var(--skl-radius-panel)",
26
+ padding: "1rem",
27
+ }}
28
+ >
29
+ {children}
30
+ </div>
31
+ </section>
32
+ );
33
+ }
package/src/Field.tsx ADDED
@@ -0,0 +1,40 @@
1
+ import type { InputHTMLAttributes, ReactNode } from "react";
2
+ import { controlStyle } from "./styles.js";
3
+
4
+ /** A labeled form row: a label, the control (children), and an optional hint. */
5
+ export function Field({
6
+ label,
7
+ hint,
8
+ children,
9
+ }: {
10
+ label: string;
11
+ hint?: ReactNode;
12
+ children: ReactNode;
13
+ }) {
14
+ return (
15
+ <div style={{ fontFamily: "var(--skl-font-sans)" }}>
16
+ <label style={{ display: "block", fontSize: "var(--skl-text-sm)", fontWeight: 500, marginBottom: 4, color: "var(--skl-color-text)" }}>
17
+ {label}
18
+ </label>
19
+ {children}
20
+ {hint && (
21
+ <p style={{ margin: "0.25rem 0 0", fontSize: "var(--skl-text-xs)", color: "var(--skl-color-text-muted)" }}>{hint}</p>
22
+ )}
23
+ </div>
24
+ );
25
+ }
26
+
27
+ /** A styled text input. */
28
+ export function Input({ style, disabled, ...props }: InputHTMLAttributes<HTMLInputElement>) {
29
+ return (
30
+ <input
31
+ {...props}
32
+ disabled={disabled}
33
+ style={{
34
+ ...controlStyle,
35
+ ...(disabled ? { background: "var(--skl-color-surface-muted)", color: "var(--skl-color-text-muted)" } : {}),
36
+ ...style,
37
+ }}
38
+ />
39
+ );
40
+ }
@@ -0,0 +1,275 @@
1
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
+ import { CircleUser, LogIn, LogOut, Settings } from "lucide-react";
3
+ import { AppBadge } from "./AppBadge.js";
4
+ import { SKIPLEAGUE_ACCOUNT_URL, SKIPLEAGUE_APPS, type AppLink } from "./apps.js";
5
+
6
+ export interface ProfileMenuUser {
7
+ displayName?: string | null;
8
+ email?: string | null;
9
+ }
10
+
11
+ /** Args passed to a custom `renderLink` for internal (in-app) menu links. */
12
+ export interface ProfileMenuLinkArgs {
13
+ href: string;
14
+ role?: string;
15
+ style?: CSSProperties;
16
+ children: ReactNode;
17
+ }
18
+
19
+ export interface ProfileMenuProps {
20
+ /** The signed-in user (name + email shown at the top of the menu). */
21
+ user?: ProfileMenuUser;
22
+ /**
23
+ * Slug of the app you're currently in (e.g. "skipracquetball"). That app's row
24
+ * is highlighted light-green and non-clickable. Omit on the platform apex.
25
+ */
26
+ currentSlug?: string;
27
+ /** Apps to list in the switcher. Defaults to the live SkipLeague apps. */
28
+ apps?: AppLink[];
29
+ /**
30
+ * Slugs of the apps THIS user has enabled (e.g. the platform's `app_slugs`).
31
+ * When provided, the switcher shows only those apps (the current app is always
32
+ * kept), so every app shows the same "your apps" set without each one
33
+ * re-implementing the filter. Omit to list every app in `apps`.
34
+ */
35
+ enabledSlugs?: string[];
36
+ /** Target of the "Manage account" link. Defaults to the platform account page. */
37
+ accountUrl?: string;
38
+ /** Called when the user clicks "Sign out". */
39
+ onSignOut: () => void;
40
+ /**
41
+ * Header tone the trigger button sits on — "dark" (default) for the apex/dark
42
+ * headers, "light" for white headers. Only affects the button, not the menu.
43
+ */
44
+ tone?: "light" | "dark";
45
+ /**
46
+ * Render the internal "Manage account" link via your own router (e.g. React
47
+ * Router's `<Link>`) instead of a full-page `<a href>`. App-switcher links to
48
+ * OTHER SkipLeague apps are always plain `<a>` (cross-app navigation).
49
+ * Example: `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`.
50
+ */
51
+ renderLink?: (args: ProfileMenuLinkArgs) => ReactNode;
52
+ /**
53
+ * Mark "Manage account" as the current page — highlighted light-green and
54
+ * non-clickable (e.g. the platform is already on /account).
55
+ */
56
+ accountIsCurrent?: boolean;
57
+ /**
58
+ * Signed-out support (platform apex): when there's no `user` AND this is
59
+ * provided, the menu shows a single "Sign in" action instead of the
60
+ * name / Manage-account / Sign-out items. Omit it to keep the always-signed-in
61
+ * behavior product apps rely on.
62
+ */
63
+ onSignIn?: () => void;
64
+ /** Label for the signed-out action (default "Sign in"). */
65
+ signInLabel?: string;
66
+ }
67
+
68
+ /**
69
+ * The canonical SkipLeague account control: a boxed user-icon button that opens
70
+ * a dropdown with the user's name/email, an inline **app switcher** (one click to
71
+ * jump to another SkipLeague app; the current app is highlighted and not
72
+ * clickable), **Manage account**, and **Sign out**. Also covers the platform
73
+ * apex via `renderLink` (SPA nav), `accountIsCurrent`, and `onSignIn`
74
+ * (signed-out state).
75
+ *
76
+ * Requires `@skipleague/design/tokens.css` to be imported once at the app root.
77
+ */
78
+ export function ProfileMenu({
79
+ user,
80
+ currentSlug,
81
+ apps = SKIPLEAGUE_APPS,
82
+ enabledSlugs,
83
+ accountUrl = SKIPLEAGUE_ACCOUNT_URL,
84
+ onSignOut,
85
+ tone = "dark",
86
+ renderLink,
87
+ accountIsCurrent = false,
88
+ onSignIn,
89
+ signInLabel = "Sign in",
90
+ }: ProfileMenuProps) {
91
+ const [open, setOpen] = useState(false);
92
+ const [hover, setHover] = useState(false);
93
+ const ref = useRef<HTMLDivElement>(null);
94
+
95
+ useEffect(() => {
96
+ if (!open) return;
97
+ const onDocClick = (e: MouseEvent) => {
98
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
99
+ };
100
+ const onKey = (e: KeyboardEvent) => {
101
+ if (e.key === "Escape") setOpen(false);
102
+ };
103
+ document.addEventListener("mousedown", onDocClick);
104
+ document.addEventListener("keydown", onKey);
105
+ return () => {
106
+ document.removeEventListener("mousedown", onDocClick);
107
+ document.removeEventListener("keydown", onKey);
108
+ };
109
+ }, [open]);
110
+
111
+ // Show only the apps this user has enabled (always keeping the current app),
112
+ // when the caller passes the user's enabled slugs. Otherwise list every app.
113
+ const visibleApps = enabledSlugs
114
+ ? apps.filter((a) => a.slug === currentSlug || enabledSlugs.includes(a.slug))
115
+ : apps;
116
+
117
+ const signedIn = !!(user?.displayName || user?.email);
118
+ // Signed-out menu only when a sign-in handler is supplied — otherwise keep the
119
+ // existing always-signed-in behavior product apps depend on.
120
+ const showSignedOut = !signedIn && !!onSignIn;
121
+
122
+ const label = signedIn ? user!.displayName || user!.email || "Account" : "Account";
123
+ const brand = tone === "light" ? "var(--skl-color-brand)" : "var(--skl-color-brand-bright)";
124
+ const idleIcon = tone === "light" ? "#334155" : "#e2e8f0";
125
+ const idleBorder = tone === "light" ? "var(--skl-color-border)" : "rgba(255,255,255,0.18)";
126
+ const boxBg = tone === "light" ? "#ffffff" : "rgba(255,255,255,0.06)";
127
+ const active = open || hover;
128
+
129
+ const accountLabel = (
130
+ <>
131
+ <Settings size={15} /> Manage account
132
+ </>
133
+ );
134
+ const accountItem = accountIsCurrent ? (
135
+ <div aria-current="page" style={{ ...itemStyle, fontWeight: 600, background: "var(--skl-color-current-bg)", color: "var(--skl-color-current-text)", cursor: "default" }}>
136
+ {accountLabel}
137
+ </div>
138
+ ) : renderLink ? (
139
+ renderLink({ href: accountUrl, role: "menuitem", style: itemStyle, children: accountLabel })
140
+ ) : (
141
+ <a href={accountUrl} role="menuitem" style={itemStyle}>
142
+ {accountLabel}
143
+ </a>
144
+ );
145
+
146
+ return (
147
+ <div ref={ref} style={{ position: "relative", fontFamily: "var(--skl-font-sans)" }}>
148
+ <button
149
+ onClick={() => setOpen((o) => !o)}
150
+ onMouseEnter={() => setHover(true)}
151
+ onMouseLeave={() => setHover(false)}
152
+ aria-haspopup="menu"
153
+ aria-expanded={open}
154
+ aria-label="Account"
155
+ title={label}
156
+ style={{
157
+ display: "inline-flex",
158
+ alignItems: "center",
159
+ justifyContent: "center",
160
+ width: 38,
161
+ height: 38,
162
+ borderRadius: "var(--skl-radius-control)",
163
+ cursor: "pointer",
164
+ border: `1px solid ${active ? brand : idleBorder}`,
165
+ background: boxBg,
166
+ color: active ? brand : idleIcon,
167
+ transition: "border-color 0.15s, color 0.15s",
168
+ }}
169
+ >
170
+ <CircleUser size={22} />
171
+ </button>
172
+
173
+ {open && (
174
+ <div role="menu" style={menuStyle}>
175
+ {showSignedOut ? (
176
+ <>
177
+ <div style={{ padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--skl-color-border)" }}>
178
+ <div style={{ fontWeight: 600, fontSize: "var(--skl-text-sm)", color: "var(--skl-color-text)" }}>Not signed in</div>
179
+ </div>
180
+ <button
181
+ role="menuitem"
182
+ onClick={onSignIn}
183
+ style={{ ...itemStyle, width: "100%", textAlign: "left", background: "none", border: "none", cursor: "pointer" }}
184
+ >
185
+ <LogIn size={15} /> {signInLabel}
186
+ </button>
187
+ </>
188
+ ) : (
189
+ <>
190
+ <div style={{ padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--skl-color-border)" }}>
191
+ <div style={{ fontWeight: 600, fontSize: "var(--skl-text-sm)", color: "var(--skl-color-text)" }}>{label}</div>
192
+ {user?.email && <div style={{ fontSize: "var(--skl-text-xs)", color: "var(--skl-color-text-muted)" }}>{user.email}</div>}
193
+ </div>
194
+
195
+ {/* Switcher only when there are apps to switch to — pass apps={[]} to
196
+ hide it entirely (e.g. dev mode, or a single-app deployment). */}
197
+ {visibleApps.length > 0 && (
198
+ <>
199
+ <div style={switchHeading}>Switch app</div>
200
+ {visibleApps.map((a) => {
201
+ const isCurrent = a.slug === currentSlug;
202
+ const inner = (
203
+ <span style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
204
+ <AppBadge name={a.name} />
205
+ {a.name}
206
+ </span>
207
+ );
208
+ // The app you're in: light-green highlight, not clickable.
209
+ return isCurrent ? (
210
+ <div key={a.slug} aria-current="page" style={{ ...itemStyle, fontWeight: 600, background: "var(--skl-color-current-bg)", color: "var(--skl-color-current-text)", cursor: "default" }}>
211
+ {inner}
212
+ </div>
213
+ ) : (
214
+ <a key={a.slug} href={a.url} role="menuitem" style={itemStyle}>
215
+ {inner}
216
+ </a>
217
+ );
218
+ })}
219
+ </>
220
+ )}
221
+
222
+ <div style={divider} />
223
+ {accountItem}
224
+ <div style={divider} />
225
+ <button
226
+ role="menuitem"
227
+ onClick={onSignOut}
228
+ style={{ ...itemStyle, width: "100%", textAlign: "left", background: "none", border: "none", cursor: "pointer" }}
229
+ >
230
+ <LogOut size={15} /> Sign out
231
+ </button>
232
+ </>
233
+ )}
234
+ </div>
235
+ )}
236
+ </div>
237
+ );
238
+ }
239
+
240
+ const menuStyle: CSSProperties = {
241
+ position: "absolute",
242
+ right: 0,
243
+ top: "calc(100% + 8px)",
244
+ minWidth: 200,
245
+ background: "var(--skl-color-surface)",
246
+ border: "1px solid var(--skl-color-border)",
247
+ borderRadius: "var(--skl-radius-panel)",
248
+ boxShadow: "var(--skl-shadow-menu)",
249
+ overflow: "hidden",
250
+ zIndex: 50,
251
+ };
252
+
253
+ const itemStyle: CSSProperties = {
254
+ display: "flex",
255
+ alignItems: "center",
256
+ gap: "0.5rem",
257
+ padding: "0.55rem 0.75rem",
258
+ fontSize: "var(--skl-text-sm)",
259
+ color: "var(--skl-color-text)",
260
+ textDecoration: "none",
261
+ };
262
+
263
+ const switchHeading: CSSProperties = {
264
+ padding: "0.5rem 0.75rem 0.25rem",
265
+ fontSize: "var(--skl-text-2xs)",
266
+ fontWeight: 600,
267
+ color: "var(--skl-color-text-faint)",
268
+ textTransform: "uppercase",
269
+ letterSpacing: 0.5,
270
+ };
271
+
272
+ const divider: CSSProperties = {
273
+ borderTop: "1px solid var(--skl-color-border)",
274
+ margin: "0.25rem 0",
275
+ };
package/src/Select.tsx ADDED
@@ -0,0 +1,11 @@
1
+ import type { SelectHTMLAttributes } from "react";
2
+ import { controlStyle } from "./styles.js";
3
+
4
+ /** A styled select. Pass `<option>`s as children. */
5
+ export function Select({ style, children, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
6
+ return (
7
+ <select {...props} style={{ ...controlStyle, ...style }}>
8
+ {children}
9
+ </select>
10
+ );
11
+ }