@skipleague/design 0.5.0 → 0.6.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.
@@ -0,0 +1,50 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ /** A single navigation row in the desktop sidebar. */
3
+ export interface SidebarNavItem {
4
+ /** Stable key (also used for React reconciliation). */
5
+ key: string;
6
+ label: string;
7
+ /** ~18px line icon (stroke 2, currentColor) — inherits the row's color. */
8
+ icon?: ReactNode;
9
+ /** Renders an `<a href>`; omit for a plain `<button>` (e.g. pure onClick). */
10
+ href?: string;
11
+ /** Current route — highlighted. Derive from your router. */
12
+ active?: boolean;
13
+ onClick?: () => void;
14
+ }
15
+ /** A run of items under an optional uppercase heading (e.g. "LISTS"). */
16
+ export interface SidebarNavSection {
17
+ /** Uppercase section heading. Omit for an ungrouped run of items. */
18
+ heading?: string;
19
+ items: SidebarNavItem[];
20
+ }
21
+ /** Args passed to a custom `renderLink` so apps can route with their own `<Link>`. */
22
+ export interface SidebarNavLinkArgs {
23
+ href: string;
24
+ style?: CSSProperties;
25
+ "aria-current"?: "page";
26
+ children: ReactNode;
27
+ }
28
+ export interface SidebarNavProps {
29
+ /** Sections rendered top→bottom; each an optional heading + its items. */
30
+ sections: SidebarNavSection[];
31
+ /** Sidebar width in px (default 210). */
32
+ width?: number;
33
+ /**
34
+ * Render item links via your own router (e.g. React Router's `<Link>`) instead
35
+ * of a full-page `<a href>`. Example:
36
+ * `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`.
37
+ */
38
+ renderLink?: (args: SidebarNavLinkArgs) => ReactNode;
39
+ }
40
+ /**
41
+ * The desktop left navigation sidebar (design_handoff_desktop_action_bar).
42
+ *
43
+ * Sits BELOW the shared app bar ({@link TopBar}/`DesktopActionBar`), never
44
+ * beside it. Starts directly with nav items — it carries NO app-name heading;
45
+ * the app name lives only in the bar. Desktop-only: hide it below the app's
46
+ * desktop breakpoint and rely on the app's bottom tab bar instead.
47
+ *
48
+ * Which item is `active` is owned by the app's router, not this component.
49
+ */
50
+ export declare function SidebarNav({ sections, width, renderLink }: SidebarNavProps): import("react").JSX.Element;
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ /**
4
+ * The desktop left navigation sidebar (design_handoff_desktop_action_bar).
5
+ *
6
+ * Sits BELOW the shared app bar ({@link TopBar}/`DesktopActionBar`), never
7
+ * beside it. Starts directly with nav items — it carries NO app-name heading;
8
+ * the app name lives only in the bar. Desktop-only: hide it below the app's
9
+ * desktop breakpoint and rely on the app's bottom tab bar instead.
10
+ *
11
+ * Which item is `active` is owned by the app's router, not this component.
12
+ */
13
+ export function SidebarNav({ sections, width = 210, renderLink }) {
14
+ return (_jsx("nav", { style: {
15
+ width,
16
+ flex: "0 0 auto",
17
+ borderRight: "1px solid var(--skl-color-border)",
18
+ padding: "14px 12px",
19
+ background: "#ffffff",
20
+ fontFamily: "var(--skl-font-sans)",
21
+ }, children: sections.map((section, si) => (_jsxs("div", { children: [section.heading && _jsx("div", { style: headingStyle, children: section.heading }), section.items.map((item) => (_jsx(Item, { item: item, renderLink: renderLink }, item.key)))] }, section.heading ?? `s${si}`))) }));
22
+ }
23
+ function Item({ item, renderLink, }) {
24
+ const [hover, setHover] = useState(false);
25
+ const { label, icon, href, active, onClick } = item;
26
+ const style = {
27
+ display: "flex",
28
+ alignItems: "center",
29
+ gap: 11,
30
+ padding: "9px 11px",
31
+ borderRadius: 9,
32
+ fontWeight: 600,
33
+ fontSize: 14,
34
+ textDecoration: "none",
35
+ cursor: "pointer",
36
+ // Active wins; otherwise a subtle hover fill for affordance.
37
+ background: active ? "var(--skl-color-current-bg)" : hover ? "var(--skl-color-surface-muted)" : "transparent",
38
+ color: active ? "var(--skl-color-brand)" : "#475569",
39
+ };
40
+ const inner = (_jsxs(_Fragment, { children: [icon, label] }));
41
+ const hoverHandlers = {
42
+ onMouseEnter: () => setHover(true),
43
+ onMouseLeave: () => setHover(false),
44
+ };
45
+ // Link row (href) — use the app's router when renderLink is supplied.
46
+ if (href) {
47
+ if (renderLink) {
48
+ return (_jsx("div", { ...hoverHandlers, children: renderLink({ href, style, "aria-current": active ? "page" : undefined, children: inner }) }));
49
+ }
50
+ return (_jsx("a", { href: href, style: style, "aria-current": active ? "page" : undefined, onClick: onClick, ...hoverHandlers, children: inner }));
51
+ }
52
+ // Button row (no href) — pure onClick.
53
+ return (_jsx("button", { type: "button", onClick: onClick, "aria-current": active ? "page" : undefined, style: { ...style, width: "100%", textAlign: "left", border: "none" }, ...hoverHandlers, children: inner }));
54
+ }
55
+ const headingStyle = {
56
+ fontWeight: 700,
57
+ fontSize: 10,
58
+ textTransform: "uppercase",
59
+ letterSpacing: 0.7,
60
+ color: "var(--skl-color-text-faint)",
61
+ padding: "14px 11px 5px",
62
+ };
package/dist/TopBar.d.ts CHANGED
@@ -1,30 +1,61 @@
1
1
  import { type ButtonHTMLAttributes, type ReactNode } from "react";
2
2
  import { type AppGlyph } from "./AppLogo.js";
3
3
  import { type ProfileMenuProps } from "./ProfileMenu.js";
4
+ /** Bar surface tone: `dark` = the green header (default), `light` = white bar. */
5
+ export type TopBarTone = "dark" | "light";
4
6
  /**
5
- * The shared SkipLeague top bar (design_handoff_top_bar, Option A — dark).
7
+ * The shared SkipLeague app bar (design_handoff_top_bar design_handoff_desktop_action_bar).
8
+ * Also exported as `DesktopActionBar` — the same component under the newer name;
9
+ * migrate imports opportunistically and drop the alias once nothing uses `TopBar`.
6
10
  *
7
- * Layout is identical across apps; only the logo glyph, wordmark, and the
8
- * contextual action slot change:
9
- * [AppLogo + wordmark] ........ [ ...actions ][ ProfileMenu ]
11
+ * ONE bar for every app + breakpoint. It spans the full top row on both mobile
12
+ * and desktop; on desktop the app adds a left sidebar (see {@link SidebarNav})
13
+ * BELOW the bar the bar itself is unchanged. Layout is identical across apps;
14
+ * only the logo glyph, wordmark, tone, and which actions are enabled change:
15
+ *
16
+ * [AppLogo + wordmark] ........ [ search inbox members share overflow ][ actions ][ ProfileMenu ]
10
17
  *
11
18
  * - ProfileMenu is pinned to the far right on every page (the persistent slot).
12
- * - `actions` is the contextual slot (Print / Share / …) — render zero or more
13
- * {@link TopBarIconButton}s; they appear left of the ProfileMenu only when the
14
- * current page offers them.
19
+ * - The named action toggles render in a FIXED left→right order
20
+ * (search inbox members share overflow), each only when its `show*`
21
+ * flag is set, so the same action looks identical in every app.
22
+ * - `actions` is an escape hatch for one-off contextual buttons; render zero or
23
+ * more {@link TopBarIconButton}s. They sit between the named cluster and the
24
+ * ProfileMenu.
25
+ * - The app name appears ONLY here, never repeated in the sidebar.
15
26
  */
16
- export declare function TopBar({ app, appName, actions, compact, ...profile }: {
27
+ export declare function TopBar({ app, appName, tone, actions, compact, showSearch, showInbox, showMembers, showShare, showOverflow, onSearch, onInbox, onMembers, onShare, onOverflow, ...profile }: {
17
28
  app: AppGlyph;
18
29
  appName: string;
19
- /** Contextual page actions (Print/Share buttons) rendered left of ProfileMenu. */
30
+ /** Bar surface tone. `dark` (green header) is the default. */
31
+ tone?: TopBarTone;
32
+ /** One-off contextual buttons, rendered between the named cluster and ProfileMenu. */
20
33
  actions?: ReactNode;
21
- /** Mobile sizing (48px bar) vs the default 54px. */
34
+ /** Mobile sizing (52px bar) vs the default 56px. */
22
35
  compact?: boolean;
36
+ /** Named action toggles — each renders a ghost icon button when true, in fixed order. */
37
+ showSearch?: boolean;
38
+ showInbox?: boolean;
39
+ showMembers?: boolean;
40
+ showShare?: boolean;
41
+ showOverflow?: boolean;
42
+ onSearch?: () => void;
43
+ onInbox?: () => void;
44
+ onMembers?: () => void;
45
+ onShare?: () => void;
46
+ onOverflow?: () => void;
23
47
  } & Pick<ProfileMenuProps, "user" | "currentSlug" | "apps" | "enabledSlugs" | "accountUrl" | "onSignOut" | "renderLink" | "onSignIn">): import("react").JSX.Element;
24
48
  /**
25
- * A contextual top-bar action button (Print, Share, …) styled for the dark bar.
26
- * Pass a 17–18px line icon (stroke 2, currentColor) as children.
49
+ * A ghost (borderless-fill) icon button for the app bar the named actions use
50
+ * it, and apps pass their own for one-off `actions`. Give it a 17px line icon
51
+ * (stroke 2, currentColor) as children. Styling tracks the bar `tone`.
27
52
  */
28
- export declare function TopBarIconButton({ compact, style, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & {
53
+ export declare function TopBarIconButton({ tone, compact, style, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & {
54
+ tone?: TopBarTone;
29
55
  compact?: boolean;
30
56
  }): import("react").JSX.Element;
57
+ /**
58
+ * Alias for {@link TopBar} under its platform-app-bar name. Same component,
59
+ * same props — use whichever import name the app standardizes on.
60
+ */
61
+ export { TopBar as DesktopActionBar };
package/dist/TopBar.js CHANGED
@@ -1,45 +1,78 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
+ import { Inbox, MoreHorizontal, Search, Upload, Users } from "lucide-react";
3
4
  import { AppLogo } from "./AppLogo.js";
4
5
  import { ProfileMenu } from "./ProfileMenu.js";
5
6
  /**
6
- * The shared SkipLeague top bar (design_handoff_top_bar, Option A — dark).
7
+ * The shared SkipLeague app bar (design_handoff_top_bar design_handoff_desktop_action_bar).
8
+ * Also exported as `DesktopActionBar` — the same component under the newer name;
9
+ * migrate imports opportunistically and drop the alias once nothing uses `TopBar`.
7
10
  *
8
- * Layout is identical across apps; only the logo glyph, wordmark, and the
9
- * contextual action slot change:
10
- * [AppLogo + wordmark] ........ [ ...actions ][ ProfileMenu ]
11
+ * ONE bar for every app + breakpoint. It spans the full top row on both mobile
12
+ * and desktop; on desktop the app adds a left sidebar (see {@link SidebarNav})
13
+ * BELOW the bar the bar itself is unchanged. Layout is identical across apps;
14
+ * only the logo glyph, wordmark, tone, and which actions are enabled change:
15
+ *
16
+ * [AppLogo + wordmark] ........ [ search inbox members share overflow ][ actions ][ ProfileMenu ]
11
17
  *
12
18
  * - ProfileMenu is pinned to the far right on every page (the persistent slot).
13
- * - `actions` is the contextual slot (Print / Share / …) — render zero or more
14
- * {@link TopBarIconButton}s; they appear left of the ProfileMenu only when the
15
- * current page offers them.
19
+ * - The named action toggles render in a FIXED left→right order
20
+ * (search inbox members share overflow), each only when its `show*`
21
+ * flag is set, so the same action looks identical in every app.
22
+ * - `actions` is an escape hatch for one-off contextual buttons; render zero or
23
+ * more {@link TopBarIconButton}s. They sit between the named cluster and the
24
+ * ProfileMenu.
25
+ * - The app name appears ONLY here, never repeated in the sidebar.
16
26
  */
17
- export function TopBar({ app, appName, actions, compact = false, ...profile }) {
27
+ export function TopBar({ app, appName, tone = "dark", actions, compact = false, showSearch = false, showInbox = false, showMembers = false, showShare = false, showOverflow = false, onSearch, onInbox, onMembers, onShare, onOverflow, ...profile }) {
28
+ const light = tone === "light";
18
29
  const bar = {
19
30
  display: "flex",
20
31
  alignItems: "center",
21
- justifyContent: "space-between",
22
- height: compact ? 48 : 54,
23
- padding: compact ? "0 12px" : "0 14px",
24
- background: "var(--skl-color-header-dark)",
32
+ gap: 12,
33
+ height: compact ? 52 : 56,
34
+ padding: compact ? "0 12px" : "0 16px",
35
+ background: light ? "#ffffff" : "var(--skl-color-header-dark)",
36
+ borderBottom: light ? "1px solid var(--skl-color-border)" : undefined,
37
+ fontFamily: "var(--skl-font-sans)",
25
38
  };
26
- return (_jsxs("div", { style: bar, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 10, minWidth: 0 }, children: [_jsx(AppLogo, { app: app, size: compact ? 26 : 27 }), _jsx("span", { style: {
27
- fontFamily: "var(--skl-font-sans)",
39
+ // Named actions in their fixed left→right order; only the enabled ones render.
40
+ const named = [
41
+ showSearch && { key: "search", title: "Search", Icon: Search, onClick: onSearch },
42
+ showInbox && { key: "inbox", title: "Inbox", Icon: Inbox, onClick: onInbox },
43
+ showMembers && { key: "members", title: "Members", Icon: Users, onClick: onMembers },
44
+ showShare && { key: "share", title: "Share this view", Icon: Upload, onClick: onShare },
45
+ showOverflow && { key: "overflow", title: "More", Icon: MoreHorizontal, onClick: onOverflow },
46
+ ].filter(Boolean);
47
+ return (_jsxs("div", { style: bar, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 10, minWidth: 0, flex: "0 1 auto" }, children: [_jsx(AppLogo, { app: app, size: compact ? 26 : 27 }), _jsx("span", { style: {
28
48
  fontWeight: 700,
29
49
  fontSize: compact ? 14 : 15,
30
- color: "var(--skl-color-on-dark)",
50
+ color: light ? "var(--skl-color-text)" : "var(--skl-color-on-dark)",
31
51
  whiteSpace: "nowrap",
32
52
  overflow: "hidden",
33
53
  textOverflow: "ellipsis",
34
- }, children: appName })] }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [actions, _jsx(ProfileMenu, { tone: "dark", ...profile })] })] }));
54
+ }, children: appName })] }), _jsx("div", { style: { flex: "1 1 auto", minWidth: 8 } }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8, flex: "0 0 auto" }, children: [named.map(({ key, title, Icon, onClick }) => (_jsx(TopBarIconButton, { tone: tone, compact: compact, title: title, "aria-label": title, onClick: onClick, children: _jsx(Icon, { size: 17 }) }, key))), actions, _jsx(ProfileMenu, { tone: tone, ...profile })] })] }));
35
55
  }
36
56
  /**
37
- * A contextual top-bar action button (Print, Share, …) styled for the dark bar.
38
- * Pass a 17–18px line icon (stroke 2, currentColor) as children.
57
+ * A ghost (borderless-fill) icon button for the app bar the named actions use
58
+ * it, and apps pass their own for one-off `actions`. Give it a 17px line icon
59
+ * (stroke 2, currentColor) as children. Styling tracks the bar `tone`.
39
60
  */
40
- export function TopBarIconButton({ compact = false, style, children, ...props }) {
61
+ export function TopBarIconButton({ tone = "dark", compact = false, style, children, ...props }) {
41
62
  const [hover, setHover] = useState(false);
42
63
  const dim = compact ? 36 : 38;
64
+ const light = tone === "light";
65
+ const palette = light
66
+ ? {
67
+ border: hover ? "#cbd5e1" : "var(--skl-color-border)",
68
+ background: hover ? "var(--skl-color-surface-muted)" : "#ffffff",
69
+ color: hover ? "var(--skl-color-text)" : "var(--skl-color-text-muted)",
70
+ }
71
+ : {
72
+ border: hover ? "rgba(94,234,212,0.5)" : "rgba(255,255,255,0.16)",
73
+ background: hover ? "rgba(255,255,255,0.08)" : "transparent",
74
+ color: hover ? "var(--skl-color-brand-bright)" : "var(--skl-color-on-dark-muted)",
75
+ };
43
76
  return (_jsx("button", { type: "button", onMouseEnter: () => setHover(true), onMouseLeave: () => setHover(false), style: {
44
77
  width: dim,
45
78
  height: dim,
@@ -48,10 +81,16 @@ export function TopBarIconButton({ compact = false, style, children, ...props })
48
81
  alignItems: "center",
49
82
  justifyContent: "center",
50
83
  cursor: "pointer",
51
- background: hover ? "rgba(255,255,255,0.08)" : "transparent",
52
- border: `1px solid ${hover ? "rgba(94,234,212,0.5)" : "rgba(255,255,255,0.16)"}`,
53
- color: hover ? "var(--skl-color-brand-bright)" : "var(--skl-color-on-dark-muted)",
54
- transition: "background .12s, border-color .12s, color .12s",
84
+ flex: "0 0 auto",
85
+ background: palette.background,
86
+ border: `1px solid ${palette.border}`,
87
+ color: palette.color,
88
+ transition: "background .14s, border-color .14s, color .14s",
55
89
  ...style,
56
90
  }, ...props, children: children }));
57
91
  }
92
+ /**
93
+ * Alias for {@link TopBar} under its platform-app-bar name. Same component,
94
+ * same props — use whichever import name the app standardizes on.
95
+ */
96
+ export { TopBar as DesktopActionBar };
package/dist/index.d.ts CHANGED
@@ -3,7 +3,10 @@ export type { ProfileMenuProps, ProfileMenuUser, ProfileMenuLinkArgs } from "./P
3
3
  export { AppBadge } from "./AppBadge.js";
4
4
  export { AppLogo } from "./AppLogo.js";
5
5
  export type { AppGlyph } from "./AppLogo.js";
6
- export { TopBar, TopBarIconButton } from "./TopBar.js";
6
+ export { TopBar, TopBarIconButton, DesktopActionBar } from "./TopBar.js";
7
+ export type { TopBarTone } from "./TopBar.js";
8
+ export { SidebarNav } from "./SidebarNav.js";
9
+ export type { SidebarNavProps, SidebarNavSection, SidebarNavItem, SidebarNavLinkArgs } from "./SidebarNav.js";
7
10
  export { BottomNav } from "./BottomNav.js";
8
11
  export type { BottomNavProps, BottomNavTab, BottomNavAction } from "./BottomNav.js";
9
12
  export { ShareMenu } from "./ShareMenu.js";
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  export { ProfileMenu } from "./ProfileMenu.js";
2
2
  export { AppBadge } from "./AppBadge.js";
3
3
  export { AppLogo } from "./AppLogo.js";
4
- export { TopBar, TopBarIconButton } from "./TopBar.js";
4
+ export { TopBar, TopBarIconButton, DesktopActionBar } from "./TopBar.js";
5
+ export { SidebarNav } from "./SidebarNav.js";
5
6
  export { BottomNav } from "./BottomNav.js";
6
7
  export { ShareMenu } from "./ShareMenu.js";
7
8
  export { Button } from "./Button.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skipleague/design",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "SkipUI — the SkipLeague design system: shared tokens and React components for every SkipLeague app.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,152 @@
1
+ import { useState, type CSSProperties, type ReactNode } from "react";
2
+
3
+ /** A single navigation row in the desktop sidebar. */
4
+ export interface SidebarNavItem {
5
+ /** Stable key (also used for React reconciliation). */
6
+ key: string;
7
+ label: string;
8
+ /** ~18px line icon (stroke 2, currentColor) — inherits the row's color. */
9
+ icon?: ReactNode;
10
+ /** Renders an `<a href>`; omit for a plain `<button>` (e.g. pure onClick). */
11
+ href?: string;
12
+ /** Current route — highlighted. Derive from your router. */
13
+ active?: boolean;
14
+ onClick?: () => void;
15
+ }
16
+
17
+ /** A run of items under an optional uppercase heading (e.g. "LISTS"). */
18
+ export interface SidebarNavSection {
19
+ /** Uppercase section heading. Omit for an ungrouped run of items. */
20
+ heading?: string;
21
+ items: SidebarNavItem[];
22
+ }
23
+
24
+ /** Args passed to a custom `renderLink` so apps can route with their own `<Link>`. */
25
+ export interface SidebarNavLinkArgs {
26
+ href: string;
27
+ style?: CSSProperties;
28
+ "aria-current"?: "page";
29
+ children: ReactNode;
30
+ }
31
+
32
+ export interface SidebarNavProps {
33
+ /** Sections rendered top→bottom; each an optional heading + its items. */
34
+ sections: SidebarNavSection[];
35
+ /** Sidebar width in px (default 210). */
36
+ width?: number;
37
+ /**
38
+ * Render item links via your own router (e.g. React Router's `<Link>`) instead
39
+ * of a full-page `<a href>`. Example:
40
+ * `renderLink={({ href, ...p }) => <Link to={href} {...p} />}`.
41
+ */
42
+ renderLink?: (args: SidebarNavLinkArgs) => ReactNode;
43
+ }
44
+
45
+ /**
46
+ * The desktop left navigation sidebar (design_handoff_desktop_action_bar).
47
+ *
48
+ * Sits BELOW the shared app bar ({@link TopBar}/`DesktopActionBar`), never
49
+ * beside it. Starts directly with nav items — it carries NO app-name heading;
50
+ * the app name lives only in the bar. Desktop-only: hide it below the app's
51
+ * desktop breakpoint and rely on the app's bottom tab bar instead.
52
+ *
53
+ * Which item is `active` is owned by the app's router, not this component.
54
+ */
55
+ export function SidebarNav({ sections, width = 210, renderLink }: SidebarNavProps) {
56
+ return (
57
+ <nav
58
+ style={{
59
+ width,
60
+ flex: "0 0 auto",
61
+ borderRight: "1px solid var(--skl-color-border)",
62
+ padding: "14px 12px",
63
+ background: "#ffffff",
64
+ fontFamily: "var(--skl-font-sans)",
65
+ }}
66
+ >
67
+ {sections.map((section, si) => (
68
+ <div key={section.heading ?? `s${si}`}>
69
+ {section.heading && <div style={headingStyle}>{section.heading}</div>}
70
+ {section.items.map((item) => (
71
+ <Item key={item.key} item={item} renderLink={renderLink} />
72
+ ))}
73
+ </div>
74
+ ))}
75
+ </nav>
76
+ );
77
+ }
78
+
79
+ function Item({
80
+ item,
81
+ renderLink,
82
+ }: {
83
+ item: SidebarNavItem;
84
+ renderLink?: (args: SidebarNavLinkArgs) => ReactNode;
85
+ }) {
86
+ const [hover, setHover] = useState(false);
87
+ const { label, icon, href, active, onClick } = item;
88
+
89
+ const style: CSSProperties = {
90
+ display: "flex",
91
+ alignItems: "center",
92
+ gap: 11,
93
+ padding: "9px 11px",
94
+ borderRadius: 9,
95
+ fontWeight: 600,
96
+ fontSize: 14,
97
+ textDecoration: "none",
98
+ cursor: "pointer",
99
+ // Active wins; otherwise a subtle hover fill for affordance.
100
+ background: active ? "var(--skl-color-current-bg)" : hover ? "var(--skl-color-surface-muted)" : "transparent",
101
+ color: active ? "var(--skl-color-brand)" : "#475569",
102
+ };
103
+
104
+ const inner = (
105
+ <>
106
+ {icon}
107
+ {label}
108
+ </>
109
+ );
110
+ const hoverHandlers = {
111
+ onMouseEnter: () => setHover(true),
112
+ onMouseLeave: () => setHover(false),
113
+ };
114
+
115
+ // Link row (href) — use the app's router when renderLink is supplied.
116
+ if (href) {
117
+ if (renderLink) {
118
+ return (
119
+ <div {...hoverHandlers}>
120
+ {renderLink({ href, style, "aria-current": active ? "page" : undefined, children: inner })}
121
+ </div>
122
+ );
123
+ }
124
+ return (
125
+ <a href={href} style={style} aria-current={active ? "page" : undefined} onClick={onClick} {...hoverHandlers}>
126
+ {inner}
127
+ </a>
128
+ );
129
+ }
130
+
131
+ // Button row (no href) — pure onClick.
132
+ return (
133
+ <button
134
+ type="button"
135
+ onClick={onClick}
136
+ aria-current={active ? "page" : undefined}
137
+ style={{ ...style, width: "100%", textAlign: "left", border: "none" }}
138
+ {...hoverHandlers}
139
+ >
140
+ {inner}
141
+ </button>
142
+ );
143
+ }
144
+
145
+ const headingStyle: CSSProperties = {
146
+ fontWeight: 700,
147
+ fontSize: 10,
148
+ textTransform: "uppercase",
149
+ letterSpacing: 0.7,
150
+ color: "var(--skl-color-text-faint)",
151
+ padding: "14px 11px 5px",
152
+ };
package/src/TopBar.tsx CHANGED
@@ -1,55 +1,105 @@
1
1
  import { useState, type ButtonHTMLAttributes, type CSSProperties, type ReactNode } from "react";
2
+ import { Inbox, MoreHorizontal, Search, Upload, Users, type LucideIcon } from "lucide-react";
2
3
 
3
4
  import { AppLogo, type AppGlyph } from "./AppLogo.js";
4
5
  import { ProfileMenu, type ProfileMenuProps } from "./ProfileMenu.js";
5
6
 
7
+ /** Bar surface tone: `dark` = the green header (default), `light` = white bar. */
8
+ export type TopBarTone = "dark" | "light";
9
+
6
10
  /**
7
- * The shared SkipLeague top bar (design_handoff_top_bar, Option A — dark).
11
+ * The shared SkipLeague app bar (design_handoff_top_bar design_handoff_desktop_action_bar).
12
+ * Also exported as `DesktopActionBar` — the same component under the newer name;
13
+ * migrate imports opportunistically and drop the alias once nothing uses `TopBar`.
14
+ *
15
+ * ONE bar for every app + breakpoint. It spans the full top row on both mobile
16
+ * and desktop; on desktop the app adds a left sidebar (see {@link SidebarNav})
17
+ * BELOW the bar — the bar itself is unchanged. Layout is identical across apps;
18
+ * only the logo glyph, wordmark, tone, and which actions are enabled change:
8
19
  *
9
- * Layout is identical across apps; only the logo glyph, wordmark, and the
10
- * contextual action slot change:
11
- * [AppLogo + wordmark] ........ [ ...actions ][ ProfileMenu ]
20
+ * [AppLogo + wordmark] ........ [ search inbox members share overflow ][ actions ][ ProfileMenu ]
12
21
  *
13
22
  * - ProfileMenu is pinned to the far right on every page (the persistent slot).
14
- * - `actions` is the contextual slot (Print / Share / …) — render zero or more
15
- * {@link TopBarIconButton}s; they appear left of the ProfileMenu only when the
16
- * current page offers them.
23
+ * - The named action toggles render in a FIXED left→right order
24
+ * (search inbox members share overflow), each only when its `show*`
25
+ * flag is set, so the same action looks identical in every app.
26
+ * - `actions` is an escape hatch for one-off contextual buttons; render zero or
27
+ * more {@link TopBarIconButton}s. They sit between the named cluster and the
28
+ * ProfileMenu.
29
+ * - The app name appears ONLY here, never repeated in the sidebar.
17
30
  */
18
31
  export function TopBar({
19
32
  app,
20
33
  appName,
34
+ tone = "dark",
21
35
  actions,
22
36
  compact = false,
37
+ showSearch = false,
38
+ showInbox = false,
39
+ showMembers = false,
40
+ showShare = false,
41
+ showOverflow = false,
42
+ onSearch,
43
+ onInbox,
44
+ onMembers,
45
+ onShare,
46
+ onOverflow,
23
47
  ...profile
24
48
  }: {
25
49
  app: AppGlyph;
26
50
  appName: string;
27
- /** Contextual page actions (Print/Share buttons) rendered left of ProfileMenu. */
51
+ /** Bar surface tone. `dark` (green header) is the default. */
52
+ tone?: TopBarTone;
53
+ /** One-off contextual buttons, rendered between the named cluster and ProfileMenu. */
28
54
  actions?: ReactNode;
29
- /** Mobile sizing (48px bar) vs the default 54px. */
55
+ /** Mobile sizing (52px bar) vs the default 56px. */
30
56
  compact?: boolean;
57
+ /** Named action toggles — each renders a ghost icon button when true, in fixed order. */
58
+ showSearch?: boolean;
59
+ showInbox?: boolean;
60
+ showMembers?: boolean;
61
+ showShare?: boolean;
62
+ showOverflow?: boolean;
63
+ onSearch?: () => void;
64
+ onInbox?: () => void;
65
+ onMembers?: () => void;
66
+ onShare?: () => void;
67
+ onOverflow?: () => void;
31
68
  } & Pick<
32
69
  ProfileMenuProps,
33
70
  "user" | "currentSlug" | "apps" | "enabledSlugs" | "accountUrl" | "onSignOut" | "renderLink" | "onSignIn"
34
71
  >) {
72
+ const light = tone === "light";
35
73
  const bar: CSSProperties = {
36
74
  display: "flex",
37
75
  alignItems: "center",
38
- justifyContent: "space-between",
39
- height: compact ? 48 : 54,
40
- padding: compact ? "0 12px" : "0 14px",
41
- background: "var(--skl-color-header-dark)",
76
+ gap: 12,
77
+ height: compact ? 52 : 56,
78
+ padding: compact ? "0 12px" : "0 16px",
79
+ background: light ? "#ffffff" : "var(--skl-color-header-dark)",
80
+ borderBottom: light ? "1px solid var(--skl-color-border)" : undefined,
81
+ fontFamily: "var(--skl-font-sans)",
42
82
  };
83
+
84
+ // Named actions in their fixed left→right order; only the enabled ones render.
85
+ const named: Array<{ key: string; title: string; Icon: LucideIcon; onClick?: () => void }> = [
86
+ showSearch && { key: "search", title: "Search", Icon: Search, onClick: onSearch },
87
+ showInbox && { key: "inbox", title: "Inbox", Icon: Inbox, onClick: onInbox },
88
+ showMembers && { key: "members", title: "Members", Icon: Users, onClick: onMembers },
89
+ showShare && { key: "share", title: "Share this view", Icon: Upload, onClick: onShare },
90
+ showOverflow && { key: "overflow", title: "More", Icon: MoreHorizontal, onClick: onOverflow },
91
+ ].filter(Boolean) as Array<{ key: string; title: string; Icon: LucideIcon; onClick?: () => void }>;
92
+
43
93
  return (
44
94
  <div style={bar}>
45
- <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
95
+ {/* Left identity group. Shrinks and truncates before the action cluster does. */}
96
+ <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0, flex: "0 1 auto" }}>
46
97
  <AppLogo app={app} size={compact ? 26 : 27} />
47
98
  <span
48
99
  style={{
49
- fontFamily: "var(--skl-font-sans)",
50
100
  fontWeight: 700,
51
101
  fontSize: compact ? 14 : 15,
52
- color: "var(--skl-color-on-dark)",
102
+ color: light ? "var(--skl-color-text)" : "var(--skl-color-on-dark)",
53
103
  whiteSpace: "nowrap",
54
104
  overflow: "hidden",
55
105
  textOverflow: "ellipsis",
@@ -58,26 +108,52 @@ export function TopBar({
58
108
  {appName}
59
109
  </span>
60
110
  </div>
61
- <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
111
+
112
+ {/* Flexible spacer. */}
113
+ <div style={{ flex: "1 1 auto", minWidth: 8 }} />
114
+
115
+ {/* Right — action cluster + persistent ProfileMenu. Never shrinks. */}
116
+ <div style={{ display: "flex", alignItems: "center", gap: 8, flex: "0 0 auto" }}>
117
+ {named.map(({ key, title, Icon, onClick }) => (
118
+ <TopBarIconButton key={key} tone={tone} compact={compact} title={title} aria-label={title} onClick={onClick}>
119
+ <Icon size={17} />
120
+ </TopBarIconButton>
121
+ ))}
62
122
  {actions}
63
- <ProfileMenu tone="dark" {...profile} />
123
+ <ProfileMenu tone={tone} {...profile} />
64
124
  </div>
65
125
  </div>
66
126
  );
67
127
  }
68
128
 
69
129
  /**
70
- * A contextual top-bar action button (Print, Share, …) styled for the dark bar.
71
- * Pass a 17–18px line icon (stroke 2, currentColor) as children.
130
+ * A ghost (borderless-fill) icon button for the app bar the named actions use
131
+ * it, and apps pass their own for one-off `actions`. Give it a 17px line icon
132
+ * (stroke 2, currentColor) as children. Styling tracks the bar `tone`.
72
133
  */
73
134
  export function TopBarIconButton({
135
+ tone = "dark",
74
136
  compact = false,
75
137
  style,
76
138
  children,
77
139
  ...props
78
- }: ButtonHTMLAttributes<HTMLButtonElement> & { compact?: boolean }) {
140
+ }: ButtonHTMLAttributes<HTMLButtonElement> & { tone?: TopBarTone; compact?: boolean }) {
79
141
  const [hover, setHover] = useState(false);
80
142
  const dim = compact ? 36 : 38;
143
+ const light = tone === "light";
144
+
145
+ const palette = light
146
+ ? {
147
+ border: hover ? "#cbd5e1" : "var(--skl-color-border)",
148
+ background: hover ? "var(--skl-color-surface-muted)" : "#ffffff",
149
+ color: hover ? "var(--skl-color-text)" : "var(--skl-color-text-muted)",
150
+ }
151
+ : {
152
+ border: hover ? "rgba(94,234,212,0.5)" : "rgba(255,255,255,0.16)",
153
+ background: hover ? "rgba(255,255,255,0.08)" : "transparent",
154
+ color: hover ? "var(--skl-color-brand-bright)" : "var(--skl-color-on-dark-muted)",
155
+ };
156
+
81
157
  return (
82
158
  <button
83
159
  type="button"
@@ -91,10 +167,11 @@ export function TopBarIconButton({
91
167
  alignItems: "center",
92
168
  justifyContent: "center",
93
169
  cursor: "pointer",
94
- background: hover ? "rgba(255,255,255,0.08)" : "transparent",
95
- border: `1px solid ${hover ? "rgba(94,234,212,0.5)" : "rgba(255,255,255,0.16)"}`,
96
- color: hover ? "var(--skl-color-brand-bright)" : "var(--skl-color-on-dark-muted)",
97
- transition: "background .12s, border-color .12s, color .12s",
170
+ flex: "0 0 auto",
171
+ background: palette.background,
172
+ border: `1px solid ${palette.border}`,
173
+ color: palette.color,
174
+ transition: "background .14s, border-color .14s, color .14s",
98
175
  ...style,
99
176
  }}
100
177
  {...props}
@@ -103,3 +180,9 @@ export function TopBarIconButton({
103
180
  </button>
104
181
  );
105
182
  }
183
+
184
+ /**
185
+ * Alias for {@link TopBar} under its platform-app-bar name. Same component,
186
+ * same props — use whichever import name the app standardizes on.
187
+ */
188
+ export { TopBar as DesktopActionBar };
package/src/index.ts CHANGED
@@ -3,7 +3,10 @@ export type { ProfileMenuProps, ProfileMenuUser, ProfileMenuLinkArgs } from "./P
3
3
  export { AppBadge } from "./AppBadge.js";
4
4
  export { AppLogo } from "./AppLogo.js";
5
5
  export type { AppGlyph } from "./AppLogo.js";
6
- export { TopBar, TopBarIconButton } from "./TopBar.js";
6
+ export { TopBar, TopBarIconButton, DesktopActionBar } from "./TopBar.js";
7
+ export type { TopBarTone } from "./TopBar.js";
8
+ export { SidebarNav } from "./SidebarNav.js";
9
+ export type { SidebarNavProps, SidebarNavSection, SidebarNavItem, SidebarNavLinkArgs } from "./SidebarNav.js";
7
10
  export { BottomNav } from "./BottomNav.js";
8
11
  export type { BottomNavProps, BottomNavTab, BottomNavAction } from "./BottomNav.js";
9
12
  export { ShareMenu } from "./ShareMenu.js";