@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.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @skipleague/design (SkipUI)
2
+
3
+ The shared SkipLeague design system (roadmap #17 / #60) — tokens + React components
4
+ so every SkipLeague app uses one set of primitives instead of per-app copies.
5
+
6
+ ## Installing in an app
7
+
8
+ This is a **public repo installed directly from git** — no registry, no auth token.
9
+ Add it to the app's `package.json` pinned to a version tag:
10
+
11
+ ```jsonc
12
+ // package.json
13
+ "dependencies": {
14
+ "@skipleague/design": "github:SkipLeague/design#v0.3.1"
15
+ }
16
+ ```
17
+
18
+ `npm install` clones the repo at that tag and runs its `prepare` script (builds
19
+ `dist/`), so the package is ready to import — in local dev and in CI, with no
20
+ `.npmrc` and no token. To pull a newer version later, bump the `#vX.Y.Z` tag.
21
+
22
+ ```ts
23
+ import "@skipleague/design/tokens.css";
24
+ import { ProfileMenu, TopBar, AppLogo, ShareMenu } from "@skipleague/design";
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ Import the tokens once at the app root, then use components anywhere:
30
+
31
+ ```ts
32
+ import "@skipleague/design/tokens.css";
33
+ import { ProfileMenu } from "@skipleague/design";
34
+ ```
35
+
36
+ ```tsx
37
+ <ProfileMenu
38
+ user={{ displayName: user.display_name, email: user.email }}
39
+ currentSlug="skipracquetball" // omit on the platform apex
40
+ accountUrl="https://skipleague.com/account"
41
+ onSignOut={logout}
42
+ tone="light" // "dark" for dark headers (default)
43
+ />
44
+ ```
45
+
46
+ The app switcher lists the live SkipLeague apps (`SKIPLEAGUE_APPS`); the current
47
+ app is highlighted light-green and non-clickable. To add an app (e.g. when
48
+ SkipToday/SkipEvolve launch), update `src/apps.ts` — every app's menu follows.
49
+
50
+ ## Exports
51
+
52
+ - `ProfileMenu`, `AppBadge`, `AppLogo`, `TopBar`, `TopBarIconButton`, `ShareMenu`
53
+ - `Button`, `Card`, `Field`, `Input`, `Select`
54
+ - `SKIPLEAGUE_APPS`, `SKIPLEAGUE_ACCOUNT_URL`, type `AppLink`
55
+ - `@skipleague/design/tokens.css` — the design tokens (CSS variables)
56
+
57
+ ## Build
58
+
59
+ ```bash
60
+ npm install
61
+ npm run build # tsc → dist/
62
+ ```
63
+
64
+ ## Releasing a new version
65
+
66
+ There's no registry to publish to — consumers install from git by tag. To cut a
67
+ release, bump the version and push the tag, then point apps at the new tag:
68
+
69
+ ```bash
70
+ npm version patch # bump version + create the vX.Y.Z tag
71
+ git push --follow-tags # publish the tag for consumers to pin
72
+ ```
73
+
74
+ `prepare` builds `dist/` automatically on every install, so `dist/` is not committed.
75
+
76
+ ## Roadmap
77
+
78
+ Adopt `TopBar`/`AppLogo` + the rest across the apps (replacing per-app copies),
79
+ then keep extending the component set. See SkipPlatform roadmap #60.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The square letter-logo for a SkipLeague app — the first letter of the name
3
+ * with the "Skip" prefix dropped (e.g. "SkipLists" → "L"). Brand-green.
4
+ */
5
+ export declare function AppBadge({ name, size }: {
6
+ name: string;
7
+ size?: number;
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * The square letter-logo for a SkipLeague app — the first letter of the name
4
+ * with the "Skip" prefix dropped (e.g. "SkipLists" → "L"). Brand-green.
5
+ */
6
+ export function AppBadge({ name, size = 20 }) {
7
+ const style = {
8
+ display: "inline-flex",
9
+ alignItems: "center",
10
+ justifyContent: "center",
11
+ width: size,
12
+ height: size,
13
+ borderRadius: "var(--skl-radius-badge)",
14
+ background: "var(--skl-color-brand)",
15
+ color: "#fff",
16
+ fontSize: "var(--skl-text-2xs)",
17
+ fontWeight: 700,
18
+ flexShrink: 0,
19
+ };
20
+ return (_jsx("span", { style: style, "aria-hidden": true, children: name.replace(/^Skip/, "").charAt(0) }));
21
+ }
@@ -0,0 +1,13 @@
1
+ /** A SkipLeague app's logo glyph (monoline, drawn on the brand tile). */
2
+ export type AppGlyph = "lists" | "racquetball" | "trips";
3
+ /**
4
+ * The per-app logo mark — a brand-green rounded tile holding a white monoline
5
+ * glyph. Replaces the single-letter {@link AppBadge}; gives each SkipLeague app a
6
+ * distinct mark for the top bar, app switcher, and home screens.
7
+ */
8
+ export declare function AppLogo({ app, size, bg, glyph, }: {
9
+ app: AppGlyph;
10
+ size?: number;
11
+ bg?: string;
12
+ glyph?: string;
13
+ }): import("react").JSX.Element;
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Exact 24×24 glyph paths (design_handoff_top_bar). `color` only matters for the
3
+ // racquetball ball, which is filled rather than stroked.
4
+ function glyphPaths(app, color) {
5
+ switch (app) {
6
+ case "lists":
7
+ return (_jsxs(_Fragment, { children: [_jsx("path", { d: "M3.5 7l1.8 1.8L8.5 5.6" }), _jsx("path", { d: "M12.5 7H20.5" }), _jsx("path", { d: "M3.5 16l1.8 1.8L8.5 14.6" }), _jsx("path", { d: "M12.5 16.5H20.5" })] }));
8
+ case "racquetball":
9
+ return (_jsxs(_Fragment, { children: [_jsx("ellipse", { cx: "10", cy: "9", rx: "5.4", ry: "6.6", transform: "rotate(-34 10 9)" }), _jsx("path", { d: "M6.6 14.2L3.4 19.4" }), _jsx("circle", { cx: "18", cy: "17.4", r: "2.3", fill: color, stroke: "none" })] }));
10
+ case "trips":
11
+ return (_jsxs(_Fragment, { children: [_jsx("path", { d: "M12 21.5s6.6-5.8 6.6-10.5a6.6 6.6 0 1 0-13.2 0c0 4.7 6.6 10.5 6.6 10.5z" }), _jsx("circle", { cx: "12", cy: "10.8", r: "2.4" })] }));
12
+ }
13
+ }
14
+ /**
15
+ * The per-app logo mark — a brand-green rounded tile holding a white monoline
16
+ * glyph. Replaces the single-letter {@link AppBadge}; gives each SkipLeague app a
17
+ * distinct mark for the top bar, app switcher, and home screens.
18
+ */
19
+ export function AppLogo({ app, size = 28, bg = "var(--skl-color-brand)", glyph = "#ffffff", }) {
20
+ const inner = Math.round(size * 0.84);
21
+ const style = {
22
+ display: "inline-flex",
23
+ alignItems: "center",
24
+ justifyContent: "center",
25
+ width: size,
26
+ height: size,
27
+ borderRadius: Math.round(size * 0.32),
28
+ background: bg,
29
+ boxShadow: "0 1px 2px rgba(2,6,23,0.18)",
30
+ flexShrink: 0,
31
+ };
32
+ return (_jsx("span", { style: style, "aria-hidden": true, children: _jsx("svg", { width: inner, height: inner, viewBox: "0 0 24 24", fill: "none", stroke: glyph, strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", children: glyphPaths(app, glyph) }) }));
33
+ }
@@ -0,0 +1,41 @@
1
+ import type { ReactNode } from "react";
2
+ export interface BottomNavTab {
3
+ label: string;
4
+ /** 24px line icon (stroke 2, currentColor) — inherits the cell's color. */
5
+ icon: ReactNode;
6
+ /** Current tab (accent color, bold). Derive from your router. */
7
+ active?: boolean;
8
+ /**
9
+ * "Tab Colors" option — a per-tab accent (any CSS color). When set and this
10
+ * tab is active, its icon/label AND the center button + glow use this color
11
+ * instead of the default brand. Omit on every tab to keep the standard
12
+ * single-color bar (the default). Provide a color per tab for an app whose
13
+ * tabs each have their own identity (e.g. SkipGifts).
14
+ */
15
+ color?: string;
16
+ onClick?: () => void;
17
+ }
18
+ export interface BottomNavAction {
19
+ /** Short visual label under the "+" (e.g. "List", "Match"). */
20
+ label: string;
21
+ /**
22
+ * Accessible name for the action control, announced to screen-reader and
23
+ * voice-control users. Use a verb phrase ("New list", "Log match") so the
24
+ * button isn't read as a bare noun confusable with a tab. Falls back to
25
+ * `label`. The visual label always stays `label`; per WCAG 2.5.3, keep
26
+ * `label`'s text inside `ariaLabel` (e.g. label "List" → ariaLabel "New list").
27
+ */
28
+ ariaLabel?: string;
29
+ /** The "+" glyph (white, ~26px, stroke 2.4). Inherits white from the button. */
30
+ icon: ReactNode;
31
+ onClick?: () => void;
32
+ }
33
+ export interface BottomNavProps {
34
+ /** 2–8 tabs, split evenly around the center action. */
35
+ tabs: BottomNavTab[];
36
+ /** The floating center "+" button and its label. */
37
+ action: BottomNavAction;
38
+ /** Optional grouped-header band; even tab counts only. */
39
+ groups?: [string, string];
40
+ }
41
+ export declare function BottomNav({ tabs, action, groups }: BottomNavProps): import("react").JSX.Element;
@@ -0,0 +1,145 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The shared SkipLeague bottom navigation (design_handoff_bottom_nav).
4
+ *
5
+ * ONE layout for every app: a row of tabs split evenly around a floating
6
+ * center action ("+"). Only the tab labels/icons and the action's label change
7
+ * per app — spacing, sizing, the button, and its clipped glow are identical
8
+ * everywhere, so the bar looks the same across the family.
9
+ *
10
+ * [tab][tab] ... ( + ) ... [tab][tab]
11
+ * label
12
+ *
13
+ * - `tabs` holds 2–8 tabs. They split around the center: the first
14
+ * `ceil(n/2)` render left of the "+", the rest render right. (For an odd
15
+ * count the extra tab sits on the left; the "+" tracks the true center of
16
+ * its column, so it drifts slightly right of screen-center — even counts are
17
+ * the designed-for case.)
18
+ * - `action` is the floating center button and the word beneath it.
19
+ * - `groups` (optional, even counts only) adds a header band labelling the left
20
+ * pair/triple vs the right. Total bar height is unchanged — the tabs shift
21
+ * down to make room.
22
+ */
23
+ const ICON = 24; // tab icon box (24px line icons, stroke 2)
24
+ const LABEL_GAP = 10; // icon -> label vertical gap
25
+ const ACTION_SIZE = 53; // floating center button (square)
26
+ const ACTION_RADIUS = 17; // center button corner radius
27
+ const ACTION_OVERHANG = 10; // px the button rises above the bar's top edge
28
+ const PLUS = 26; // "+" glyph box inside the button
29
+ // Soft mint halo + a grounding shadow. Painted on a layer INSIDE the
30
+ // overflow:hidden bar so it is clipped at the top edge — no glow above the line.
31
+ const ACTION_GLOW = "0 0 22px 4px rgba(94,234,212,0.6), 0 10px 22px rgba(15,118,110,0.30)";
32
+ // Two vertical-spacing presets that yield the SAME total bar height.
33
+ const PLAIN = { padTop: 10, padBottom: 18, headerGap: 0 };
34
+ const GROUPED = { padTop: 5, padBottom: 6, headerGap: 5 };
35
+ const cell = {
36
+ display: "flex",
37
+ flexDirection: "column",
38
+ alignItems: "center",
39
+ gap: LABEL_GAP,
40
+ };
41
+ const labelBase = {
42
+ fontFamily: "var(--skl-font-sans)",
43
+ fontSize: 13,
44
+ lineHeight: 1.15,
45
+ };
46
+ const groupLabel = {
47
+ ...labelBase,
48
+ fontSize: 11,
49
+ fontWeight: 700,
50
+ letterSpacing: "0.1em",
51
+ textTransform: "uppercase",
52
+ color: "var(--skl-color-text-faint)",
53
+ textAlign: "center",
54
+ };
55
+ function Tab({ label, icon, active, color, onClick, lift }) {
56
+ const accent = color ?? "var(--skl-color-brand)";
57
+ return (_jsxs("button", { type: "button", onClick: onClick, style: {
58
+ ...cell,
59
+ background: "none",
60
+ border: "none",
61
+ padding: 0,
62
+ cursor: "pointer",
63
+ transform: `translateY(${lift}px)`,
64
+ // icon color (currentColor); the label sets its own color below
65
+ color: active ? accent : "var(--skl-color-text-faint)",
66
+ }, children: [_jsx("span", { style: { height: ICON, display: "flex", alignItems: "flex-end" }, children: icon }), _jsx("span", { style: {
67
+ ...labelBase,
68
+ fontWeight: active ? 700 : 600,
69
+ color: active ? accent : "var(--skl-color-text-muted)",
70
+ }, children: label })] }));
71
+ }
72
+ export function BottomNav({ tabs, action, groups }) {
73
+ const n = tabs.length;
74
+ if (n < 2 || n > 8) {
75
+ // Render what we were given, but flag the contract violation in dev.
76
+ console.warn(`BottomNav expects 2–8 tabs, received ${n}.`);
77
+ }
78
+ // Split around the center: extra tab (odd n) goes left.
79
+ const leftCount = Math.ceil(n / 2);
80
+ const left = tabs.slice(0, leftCount);
81
+ const right = tabs.slice(leftCount);
82
+ const columns = n + 1; // tabs + the center label column
83
+ // Grouped headers are an even-count feature (a header over each side).
84
+ const grouped = Array.isArray(groups) && groups.length === 2 && n % 2 === 0;
85
+ const sp = grouped ? GROUPED : PLAIN;
86
+ // In grouped mode the tab row sits lower; lift the center label back in line
87
+ // with the side tabs (right under the "+"), and nudge the tabs up for balance.
88
+ const centerLabelLift = grouped ? -13 : 0;
89
+ const tabLift = grouped ? -2 : 0;
90
+ // Horizontal center of the action column (== 50% for even counts).
91
+ const actionLeft = `${((leftCount + 0.5) / columns) * 100}%`;
92
+ const gridCols = `repeat(${columns}, 1fr)`;
93
+ // "Tab Colors": the center button + label + glow follow the active tab's color
94
+ // when one is set; otherwise everything stays the default brand teal + mint glow.
95
+ const activeTab = tabs.find((t) => t.active);
96
+ const accent = activeTab?.color ?? "var(--skl-color-brand)";
97
+ const glow = activeTab?.color
98
+ ? `0 0 22px 4px color-mix(in srgb, ${activeTab.color} 55%, transparent), 0 10px 22px color-mix(in srgb, ${activeTab.color} 30%, transparent)`
99
+ : ACTION_GLOW;
100
+ return (_jsxs("nav", { style: { position: "relative", flex: "none" }, children: [_jsxs("div", { style: {
101
+ background: "var(--skl-color-surface)",
102
+ borderTop: "1px solid var(--skl-color-border)",
103
+ position: "relative",
104
+ overflow: "hidden",
105
+ // Keep the tabs clear of the iOS home indicator (additive to padBottom).
106
+ paddingBottom: "env(safe-area-inset-bottom, 0px)",
107
+ }, children: [_jsx("span", { "aria-hidden": true, style: {
108
+ position: "absolute",
109
+ top: -ACTION_OVERHANG,
110
+ left: actionLeft,
111
+ transform: "translateX(-50%)",
112
+ width: ACTION_SIZE,
113
+ height: ACTION_SIZE,
114
+ borderRadius: ACTION_RADIUS,
115
+ boxShadow: glow,
116
+ pointerEvents: "none",
117
+ } }), grouped && (_jsxs("div", { style: { display: "grid", gridTemplateColumns: gridCols, paddingTop: sp.padTop }, children: [_jsx("div", { style: { ...groupLabel, gridColumn: `1 / ${leftCount + 1}` }, children: groups[0] }), _jsx("div", { style: { ...groupLabel, gridColumn: `${leftCount + 2} / ${columns + 1}` }, children: groups[1] })] })), _jsxs("div", { style: {
118
+ display: "grid",
119
+ gridTemplateColumns: gridCols,
120
+ paddingTop: grouped ? sp.headerGap : sp.padTop,
121
+ paddingBottom: sp.padBottom,
122
+ position: "relative",
123
+ }, children: [left.map((t, i) => (_jsx(Tab, { ...t, lift: tabLift }, `l${i}`))), _jsxs("div", { onClick: action.onClick, "aria-hidden": true, style: { ...cell, cursor: "pointer" }, children: [_jsx("div", { style: { height: ICON } }), _jsx("span", { style: {
124
+ ...labelBase,
125
+ fontWeight: 700,
126
+ color: accent,
127
+ transform: `translateY(${centerLabelLift}px)`,
128
+ }, children: action.label })] }), right.map((t, i) => (_jsx(Tab, { ...t, lift: tabLift }, `r${i}`)))] })] }), _jsx("button", { type: "button", onClick: action.onClick, "aria-label": action.ariaLabel ?? action.label, style: {
129
+ position: "absolute",
130
+ top: -ACTION_OVERHANG,
131
+ left: actionLeft,
132
+ transform: "translateX(-50%)",
133
+ width: ACTION_SIZE,
134
+ height: ACTION_SIZE,
135
+ borderRadius: ACTION_RADIUS,
136
+ background: accent,
137
+ color: "#fff",
138
+ border: "none",
139
+ display: "flex",
140
+ alignItems: "center",
141
+ justifyContent: "center",
142
+ zIndex: 3,
143
+ cursor: "pointer",
144
+ }, children: _jsx("span", { style: { display: "flex", width: PLUS, height: PLUS }, children: action.icon }) })] }));
145
+ }
@@ -0,0 +1,7 @@
1
+ import type { ButtonHTMLAttributes } from "react";
2
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
3
+ /** "primary" = brand-green fill (default); "secondary" = white with a border. */
4
+ variant?: "primary" | "secondary";
5
+ }
6
+ /** The standard SkipLeague button. Token-driven; matches the apps' primary action. */
7
+ export declare function Button({ variant, style, disabled, ...props }: ButtonProps): import("react").JSX.Element;
package/dist/Button.js ADDED
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /** The standard SkipLeague button. Token-driven; matches the apps' primary action. */
3
+ export function Button({ variant = "primary", style, disabled, ...props }) {
4
+ const base = {
5
+ display: "inline-flex",
6
+ alignItems: "center",
7
+ justifyContent: "center",
8
+ gap: "0.5rem",
9
+ fontFamily: "var(--skl-font-sans)",
10
+ fontSize: "var(--skl-text-sm)",
11
+ fontWeight: 600,
12
+ padding: "0.625rem 1.25rem",
13
+ borderRadius: "var(--skl-radius-md)",
14
+ cursor: disabled ? "default" : "pointer",
15
+ opacity: disabled ? 0.6 : 1,
16
+ border: "1px solid transparent",
17
+ ...(variant === "primary"
18
+ ? { background: "var(--skl-color-brand)", color: "#fff" }
19
+ : { background: "var(--skl-color-surface)", color: "var(--skl-color-text)", borderColor: "var(--skl-color-border)" }),
20
+ };
21
+ return _jsx("button", { ...props, disabled: disabled, style: { ...base, ...style } });
22
+ }
package/dist/Card.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ /**
3
+ * A section card: an optional heading above a white, bordered, rounded container —
4
+ * the standard grouping used on the account/settings pages.
5
+ */
6
+ export declare function Card({ title, children, style, }: {
7
+ title?: string;
8
+ children: ReactNode;
9
+ style?: CSSProperties;
10
+ }): import("react").JSX.Element;
package/dist/Card.js ADDED
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A section card: an optional heading above a white, bordered, rounded container —
4
+ * the standard grouping used on the account/settings pages.
5
+ */
6
+ export function Card({ title, children, style, }) {
7
+ return (_jsxs("section", { style: { fontFamily: "var(--skl-font-sans)", ...style }, children: [title && (_jsx("h3", { style: { margin: "0 0 0.75rem", fontSize: "1rem", fontWeight: 600, color: "var(--skl-color-text)" }, children: title })), _jsx("div", { style: {
8
+ background: "var(--skl-color-surface)",
9
+ border: "1px solid var(--skl-color-border)",
10
+ borderRadius: "var(--skl-radius-panel)",
11
+ padding: "1rem",
12
+ }, children: children })] }));
13
+ }
@@ -0,0 +1,9 @@
1
+ import type { InputHTMLAttributes, ReactNode } from "react";
2
+ /** A labeled form row: a label, the control (children), and an optional hint. */
3
+ export declare function Field({ label, hint, children, }: {
4
+ label: string;
5
+ hint?: ReactNode;
6
+ children: ReactNode;
7
+ }): import("react").JSX.Element;
8
+ /** A styled text input. */
9
+ export declare function Input({ style, disabled, ...props }: InputHTMLAttributes<HTMLInputElement>): import("react").JSX.Element;
package/dist/Field.js ADDED
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { controlStyle } from "./styles.js";
3
+ /** A labeled form row: a label, the control (children), and an optional hint. */
4
+ export function Field({ label, hint, children, }) {
5
+ return (_jsxs("div", { style: { fontFamily: "var(--skl-font-sans)" }, children: [_jsx("label", { style: { display: "block", fontSize: "var(--skl-text-sm)", fontWeight: 500, marginBottom: 4, color: "var(--skl-color-text)" }, children: label }), children, hint && (_jsx("p", { style: { margin: "0.25rem 0 0", fontSize: "var(--skl-text-xs)", color: "var(--skl-color-text-muted)" }, children: hint }))] }));
6
+ }
7
+ /** A styled text input. */
8
+ export function Input({ style, disabled, ...props }) {
9
+ return (_jsx("input", { ...props, disabled: disabled, style: {
10
+ ...controlStyle,
11
+ ...(disabled ? { background: "var(--skl-color-surface-muted)", color: "var(--skl-color-text-muted)" } : {}),
12
+ ...style,
13
+ } }));
14
+ }
@@ -0,0 +1,72 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type AppLink } from "./apps.js";
3
+ export interface ProfileMenuUser {
4
+ displayName?: string | null;
5
+ email?: string | null;
6
+ }
7
+ /** Args passed to a custom `renderLink` for internal (in-app) menu links. */
8
+ export interface ProfileMenuLinkArgs {
9
+ href: string;
10
+ role?: string;
11
+ style?: CSSProperties;
12
+ children: ReactNode;
13
+ }
14
+ export interface ProfileMenuProps {
15
+ /** The signed-in user (name + email shown at the top of the menu). */
16
+ user?: ProfileMenuUser;
17
+ /**
18
+ * Slug of the app you're currently in (e.g. "skipracquetball"). That app's row
19
+ * is highlighted light-green and non-clickable. Omit on the platform apex.
20
+ */
21
+ currentSlug?: string;
22
+ /** Apps to list in the switcher. Defaults to the live SkipLeague apps. */
23
+ apps?: AppLink[];
24
+ /**
25
+ * Slugs of the apps THIS user has enabled (e.g. the platform's `app_slugs`).
26
+ * When provided, the switcher shows only those apps (the current app is always
27
+ * kept), so every app shows the same "your apps" set without each one
28
+ * re-implementing the filter. Omit to list every app in `apps`.
29
+ */
30
+ enabledSlugs?: string[];
31
+ /** Target of the "Manage account" link. Defaults to the platform account page. */
32
+ accountUrl?: string;
33
+ /** Called when the user clicks "Sign out". */
34
+ onSignOut: () => void;
35
+ /**
36
+ * Header tone the trigger button sits on — "dark" (default) for the apex/dark
37
+ * headers, "light" for white headers. Only affects the button, not the menu.
38
+ */
39
+ tone?: "light" | "dark";
40
+ /**
41
+ * Render the internal "Manage account" link via your own router (e.g. React
42
+ * Router's `<Link>`) instead of a full-page `<a href>`. App-switcher links to
43
+ * OTHER SkipLeague apps are always plain `<a>` (cross-app navigation).
44
+ * Example: `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`.
45
+ */
46
+ renderLink?: (args: ProfileMenuLinkArgs) => ReactNode;
47
+ /**
48
+ * Mark "Manage account" as the current page — highlighted light-green and
49
+ * non-clickable (e.g. the platform is already on /account).
50
+ */
51
+ accountIsCurrent?: boolean;
52
+ /**
53
+ * Signed-out support (platform apex): when there's no `user` AND this is
54
+ * provided, the menu shows a single "Sign in" action instead of the
55
+ * name / Manage-account / Sign-out items. Omit it to keep the always-signed-in
56
+ * behavior product apps rely on.
57
+ */
58
+ onSignIn?: () => void;
59
+ /** Label for the signed-out action (default "Sign in"). */
60
+ signInLabel?: string;
61
+ }
62
+ /**
63
+ * The canonical SkipLeague account control: a boxed user-icon button that opens
64
+ * a dropdown with the user's name/email, an inline **app switcher** (one click to
65
+ * jump to another SkipLeague app; the current app is highlighted and not
66
+ * clickable), **Manage account**, and **Sign out**. Also covers the platform
67
+ * apex via `renderLink` (SPA nav), `accountIsCurrent`, and `onSignIn`
68
+ * (signed-out state).
69
+ *
70
+ * Requires `@skipleague/design/tokens.css` to be imported once at the app root.
71
+ */
72
+ export declare function ProfileMenu({ user, currentSlug, apps, enabledSlugs, accountUrl, onSignOut, tone, renderLink, accountIsCurrent, onSignIn, signInLabel, }: ProfileMenuProps): import("react").JSX.Element;
@@ -0,0 +1,106 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { CircleUser, LogIn, LogOut, Settings } from "lucide-react";
4
+ import { AppBadge } from "./AppBadge.js";
5
+ import { SKIPLEAGUE_ACCOUNT_URL, SKIPLEAGUE_APPS } from "./apps.js";
6
+ /**
7
+ * The canonical SkipLeague account control: a boxed user-icon button that opens
8
+ * a dropdown with the user's name/email, an inline **app switcher** (one click to
9
+ * jump to another SkipLeague app; the current app is highlighted and not
10
+ * clickable), **Manage account**, and **Sign out**. Also covers the platform
11
+ * apex via `renderLink` (SPA nav), `accountIsCurrent`, and `onSignIn`
12
+ * (signed-out state).
13
+ *
14
+ * Requires `@skipleague/design/tokens.css` to be imported once at the app root.
15
+ */
16
+ export function ProfileMenu({ user, currentSlug, apps = SKIPLEAGUE_APPS, enabledSlugs, accountUrl = SKIPLEAGUE_ACCOUNT_URL, onSignOut, tone = "dark", renderLink, accountIsCurrent = false, onSignIn, signInLabel = "Sign in", }) {
17
+ const [open, setOpen] = useState(false);
18
+ const [hover, setHover] = useState(false);
19
+ const ref = useRef(null);
20
+ useEffect(() => {
21
+ if (!open)
22
+ return;
23
+ const onDocClick = (e) => {
24
+ if (ref.current && !ref.current.contains(e.target))
25
+ setOpen(false);
26
+ };
27
+ const onKey = (e) => {
28
+ if (e.key === "Escape")
29
+ setOpen(false);
30
+ };
31
+ document.addEventListener("mousedown", onDocClick);
32
+ document.addEventListener("keydown", onKey);
33
+ return () => {
34
+ document.removeEventListener("mousedown", onDocClick);
35
+ document.removeEventListener("keydown", onKey);
36
+ };
37
+ }, [open]);
38
+ // Show only the apps this user has enabled (always keeping the current app),
39
+ // when the caller passes the user's enabled slugs. Otherwise list every app.
40
+ const visibleApps = enabledSlugs
41
+ ? apps.filter((a) => a.slug === currentSlug || enabledSlugs.includes(a.slug))
42
+ : apps;
43
+ const signedIn = !!(user?.displayName || user?.email);
44
+ // Signed-out menu only when a sign-in handler is supplied — otherwise keep the
45
+ // existing always-signed-in behavior product apps depend on.
46
+ const showSignedOut = !signedIn && !!onSignIn;
47
+ const label = signedIn ? user.displayName || user.email || "Account" : "Account";
48
+ const brand = tone === "light" ? "var(--skl-color-brand)" : "var(--skl-color-brand-bright)";
49
+ const idleIcon = tone === "light" ? "#334155" : "#e2e8f0";
50
+ const idleBorder = tone === "light" ? "var(--skl-color-border)" : "rgba(255,255,255,0.18)";
51
+ const boxBg = tone === "light" ? "#ffffff" : "rgba(255,255,255,0.06)";
52
+ const active = open || hover;
53
+ const accountLabel = (_jsxs(_Fragment, { children: [_jsx(Settings, { size: 15 }), " Manage account"] }));
54
+ const accountItem = accountIsCurrent ? (_jsx("div", { "aria-current": "page", style: { ...itemStyle, fontWeight: 600, background: "var(--skl-color-current-bg)", color: "var(--skl-color-current-text)", cursor: "default" }, children: accountLabel })) : renderLink ? (renderLink({ href: accountUrl, role: "menuitem", style: itemStyle, children: accountLabel })) : (_jsx("a", { href: accountUrl, role: "menuitem", style: itemStyle, children: accountLabel }));
55
+ return (_jsxs("div", { ref: ref, style: { position: "relative", fontFamily: "var(--skl-font-sans)" }, children: [_jsx("button", { onClick: () => setOpen((o) => !o), onMouseEnter: () => setHover(true), onMouseLeave: () => setHover(false), "aria-haspopup": "menu", "aria-expanded": open, "aria-label": "Account", title: label, style: {
56
+ display: "inline-flex",
57
+ alignItems: "center",
58
+ justifyContent: "center",
59
+ width: 38,
60
+ height: 38,
61
+ borderRadius: "var(--skl-radius-control)",
62
+ cursor: "pointer",
63
+ border: `1px solid ${active ? brand : idleBorder}`,
64
+ background: boxBg,
65
+ color: active ? brand : idleIcon,
66
+ transition: "border-color 0.15s, color 0.15s",
67
+ }, children: _jsx(CircleUser, { size: 22 }) }), open && (_jsx("div", { role: "menu", style: menuStyle, children: showSignedOut ? (_jsxs(_Fragment, { children: [_jsx("div", { style: { padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--skl-color-border)" }, children: _jsx("div", { style: { fontWeight: 600, fontSize: "var(--skl-text-sm)", color: "var(--skl-color-text)" }, children: "Not signed in" }) }), _jsxs("button", { role: "menuitem", onClick: onSignIn, style: { ...itemStyle, width: "100%", textAlign: "left", background: "none", border: "none", cursor: "pointer" }, children: [_jsx(LogIn, { size: 15 }), " ", signInLabel] })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { style: { padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--skl-color-border)" }, children: [_jsx("div", { style: { fontWeight: 600, fontSize: "var(--skl-text-sm)", color: "var(--skl-color-text)" }, children: label }), user?.email && _jsx("div", { style: { fontSize: "var(--skl-text-xs)", color: "var(--skl-color-text-muted)" }, children: user.email })] }), visibleApps.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { style: switchHeading, children: "Switch app" }), visibleApps.map((a) => {
68
+ const isCurrent = a.slug === currentSlug;
69
+ const inner = (_jsxs("span", { style: { display: "flex", alignItems: "center", gap: "0.5rem" }, children: [_jsx(AppBadge, { name: a.name }), a.name] }));
70
+ // The app you're in: light-green highlight, not clickable.
71
+ return isCurrent ? (_jsx("div", { "aria-current": "page", style: { ...itemStyle, fontWeight: 600, background: "var(--skl-color-current-bg)", color: "var(--skl-color-current-text)", cursor: "default" }, children: inner }, a.slug)) : (_jsx("a", { href: a.url, role: "menuitem", style: itemStyle, children: inner }, a.slug));
72
+ })] })), _jsx("div", { style: divider }), accountItem, _jsx("div", { style: divider }), _jsxs("button", { role: "menuitem", onClick: onSignOut, style: { ...itemStyle, width: "100%", textAlign: "left", background: "none", border: "none", cursor: "pointer" }, children: [_jsx(LogOut, { size: 15 }), " Sign out"] })] })) }))] }));
73
+ }
74
+ const menuStyle = {
75
+ position: "absolute",
76
+ right: 0,
77
+ top: "calc(100% + 8px)",
78
+ minWidth: 200,
79
+ background: "var(--skl-color-surface)",
80
+ border: "1px solid var(--skl-color-border)",
81
+ borderRadius: "var(--skl-radius-panel)",
82
+ boxShadow: "var(--skl-shadow-menu)",
83
+ overflow: "hidden",
84
+ zIndex: 50,
85
+ };
86
+ const itemStyle = {
87
+ display: "flex",
88
+ alignItems: "center",
89
+ gap: "0.5rem",
90
+ padding: "0.55rem 0.75rem",
91
+ fontSize: "var(--skl-text-sm)",
92
+ color: "var(--skl-color-text)",
93
+ textDecoration: "none",
94
+ };
95
+ const switchHeading = {
96
+ padding: "0.5rem 0.75rem 0.25rem",
97
+ fontSize: "var(--skl-text-2xs)",
98
+ fontWeight: 600,
99
+ color: "var(--skl-color-text-faint)",
100
+ textTransform: "uppercase",
101
+ letterSpacing: 0.5,
102
+ };
103
+ const divider = {
104
+ borderTop: "1px solid var(--skl-color-border)",
105
+ margin: "0.25rem 0",
106
+ };
@@ -0,0 +1,3 @@
1
+ import type { SelectHTMLAttributes } from "react";
2
+ /** A styled select. Pass `<option>`s as children. */
3
+ export declare function Select({ style, children, ...props }: SelectHTMLAttributes<HTMLSelectElement>): import("react").JSX.Element;
package/dist/Select.js ADDED
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { controlStyle } from "./styles.js";
3
+ /** A styled select. Pass `<option>`s as children. */
4
+ export function Select({ style, children, ...props }) {
5
+ return (_jsx("select", { ...props, style: { ...controlStyle, ...style }, children: children }));
6
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The top-bar Share dropdown (design_handoff_top_bar). A self-contained trigger
3
+ * (the upload glyph, styled like a {@link TopBarIconButton}) + an anchored menu —
4
+ * same open/dismiss model as ProfileMenu (click, outside-click, Escape).
5
+ *
6
+ * `noun` names the thing being shared ("players" → "Share players"). Pass the
7
+ * handlers you support; omitted items are hidden.
8
+ */
9
+ export declare function ShareMenu({ noun, compact, onCopyLink, onMessages, onEmail, onMore, }: {
10
+ noun: string;
11
+ compact?: boolean;
12
+ onCopyLink?: () => void;
13
+ onMessages?: () => void;
14
+ onEmail?: () => void;
15
+ onMore?: () => void;
16
+ }): import("react").JSX.Element;