8seneca-app-launcher 0.1.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,46 @@
1
+ import * as react from 'react';
2
+ import { LucideIcon } from 'lucide-react';
3
+
4
+ type AppId = "hrm" | "plane" | "portal";
5
+ type AppTile = {
6
+ id: AppId;
7
+ name: string;
8
+ url: string;
9
+ icon: LucideIcon;
10
+ /** Any CSS colour string; painted behind the tile icon. */
11
+ color: string;
12
+ };
13
+ type AppLauncherProps = {
14
+ /** Override tile URLs by id — local development only. */
15
+ urls?: Partial<Record<AppId, string>>;
16
+ /** Panel heading. Default: "Your apps". */
17
+ label?: string;
18
+ /** Inline trigger only: which edge the panel aligns to. Default: "end". */
19
+ align?: "start" | "end";
20
+ };
21
+
22
+ /** Inline 9-dot trigger — mount inside an app's own header or top bar. */
23
+ declare function AppLauncher({ urls, label, align }?: AppLauncherProps): react.JSX.Element;
24
+ /** Floating trigger at bottom-right — mount once in an app's root layout. */
25
+ declare function AppLauncherFab({ urls, label }?: AppLauncherProps): react.JSX.Element;
26
+
27
+ type PanelProps = {
28
+ tiles: AppTile[];
29
+ hostname: string;
30
+ label: string;
31
+ id: string;
32
+ };
33
+ declare function LauncherPanel({ tiles, hostname, label, id }: PanelProps): react.JSX.Element;
34
+
35
+ /**
36
+ * The complete 8seneca app list. URLs live here on purpose: the apps' env
37
+ * conventions diverge (NEXT_PUBLIC_* vs VITE_*), so baking them keeps every
38
+ * consumer at zero configuration. Adding an app means publishing a version.
39
+ */
40
+ declare const APPS: AppTile[];
41
+ /** Applies per-id URL overrides. Unknown ids and empty strings are ignored. */
42
+ declare function resolveApps(urls?: Partial<Record<AppId, string>>): AppTile[];
43
+ /** True when the tile points at the page we are already on. */
44
+ declare function isCurrent(app: AppTile, hostname: string): boolean;
45
+
46
+ export { APPS, type AppId, AppLauncher, AppLauncherFab, type AppLauncherProps, type AppTile, LauncherPanel, isCurrent, resolveApps };
package/dist/index.js ADDED
@@ -0,0 +1,188 @@
1
+ "use client";
2
+
3
+ // src/launcher.tsx
4
+ import { Grid3x3 } from "lucide-react";
5
+
6
+ // src/apps.ts
7
+ import { Briefcase, FolderKanban, LayoutGrid } from "lucide-react";
8
+ var APPS = [
9
+ {
10
+ id: "hrm",
11
+ name: "8People",
12
+ url: "https://people.8seneca.com",
13
+ icon: Briefcase,
14
+ color: "oklch(0.65 0.15 240)"
15
+ },
16
+ {
17
+ id: "plane",
18
+ name: "8Projects",
19
+ url: "https://projects.8seneca.com",
20
+ icon: FolderKanban,
21
+ color: "oklch(0.65 0.15 30)"
22
+ },
23
+ {
24
+ id: "portal",
25
+ name: "8Seneca Hub",
26
+ url: "https://portal.8seneca.com",
27
+ icon: LayoutGrid,
28
+ color: "oklch(0.65 0.15 150)"
29
+ }
30
+ ];
31
+ function resolveApps(urls) {
32
+ if (!urls) return APPS;
33
+ return APPS.map((app) => {
34
+ const override = urls[app.id];
35
+ return override ? { ...app, url: override } : app;
36
+ });
37
+ }
38
+ function isCurrent(app, hostname) {
39
+ if (!hostname) return false;
40
+ try {
41
+ return new URL(app.url).hostname === hostname;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ // src/panel.tsx
48
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
49
+ var TILE_CLASS = "flex flex-col items-center gap-1.5 rounded-xl p-3 no-underline transition-colors text-launcher-fg hover:bg-launcher-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-launcher-ring";
50
+ function LauncherPanel({ tiles, hostname, label, id }) {
51
+ return /* @__PURE__ */ jsxs(
52
+ "div",
53
+ {
54
+ id,
55
+ role: "group",
56
+ "aria-label": label,
57
+ className: "w-72 rounded-2xl border border-launcher-border bg-launcher-surface p-4 shadow-lg",
58
+ children: [
59
+ /* @__PURE__ */ jsx("p", { className: "mb-3 px-2 text-xs font-medium uppercase tracking-wide text-launcher-muted", children: label }),
60
+ /* @__PURE__ */ jsx("div", { className: "grid grid-cols-3 gap-1", children: tiles.map((app) => {
61
+ const Icon = app.icon;
62
+ const current = isCurrent(app, hostname);
63
+ const body = /* @__PURE__ */ jsxs(Fragment, { children: [
64
+ /* @__PURE__ */ jsx(
65
+ "span",
66
+ {
67
+ className: "flex h-10 w-10 items-center justify-center rounded-xl",
68
+ style: { backgroundColor: app.color },
69
+ children: /* @__PURE__ */ jsx(Icon, { className: "h-5 w-5 text-white", strokeWidth: 2, "aria-hidden": "true" })
70
+ }
71
+ ),
72
+ /* @__PURE__ */ jsx("span", { className: "text-[11px] font-medium", children: app.name })
73
+ ] });
74
+ return current ? /* @__PURE__ */ jsx(
75
+ "span",
76
+ {
77
+ "aria-current": "page",
78
+ className: `${TILE_CLASS} cursor-default bg-launcher-hover`,
79
+ children: body
80
+ },
81
+ app.id
82
+ ) : /* @__PURE__ */ jsx(
83
+ "a",
84
+ {
85
+ href: app.url,
86
+ target: "_blank",
87
+ rel: "noopener noreferrer",
88
+ className: TILE_CLASS,
89
+ children: body
90
+ },
91
+ app.id
92
+ );
93
+ }) })
94
+ ]
95
+ }
96
+ );
97
+ }
98
+
99
+ // src/use-launcher.ts
100
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
101
+ function useLauncher() {
102
+ const [open, setOpen] = useState(false);
103
+ const rootRef = useRef(null);
104
+ const triggerRef = useRef(null);
105
+ const panelId = useId();
106
+ useEffect(() => {
107
+ if (!open) return;
108
+ const onPointerDown = (event) => {
109
+ if (!rootRef.current?.contains(event.target)) setOpen(false);
110
+ };
111
+ const onKeyDown = (event) => {
112
+ if (event.key !== "Escape") return;
113
+ setOpen(false);
114
+ triggerRef.current?.focus();
115
+ };
116
+ document.addEventListener("pointerdown", onPointerDown);
117
+ document.addEventListener("keydown", onKeyDown);
118
+ return () => {
119
+ document.removeEventListener("pointerdown", onPointerDown);
120
+ document.removeEventListener("keydown", onKeyDown);
121
+ };
122
+ }, [open]);
123
+ const toggle = useCallback(() => setOpen((value) => !value), []);
124
+ const close = useCallback(() => setOpen(false), []);
125
+ return { open, toggle, close, rootRef, triggerRef, panelId };
126
+ }
127
+ function useHostname() {
128
+ const [hostname, setHostname] = useState("");
129
+ useEffect(() => setHostname(window.location.hostname), []);
130
+ return hostname;
131
+ }
132
+
133
+ // src/launcher.tsx
134
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
135
+ var TRIGGER_BASE = "inline-flex items-center justify-center rounded-full text-launcher-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-launcher-ring";
136
+ function AppLauncher({ urls, label = "Your apps", align = "end" } = {}) {
137
+ const { open, toggle, rootRef, triggerRef, panelId } = useLauncher();
138
+ const hostname = useHostname();
139
+ const tiles = resolveApps(urls);
140
+ return /* @__PURE__ */ jsxs2("div", { ref: rootRef, className: "relative", children: [
141
+ /* @__PURE__ */ jsx2(
142
+ "button",
143
+ {
144
+ ref: triggerRef,
145
+ type: "button",
146
+ onClick: toggle,
147
+ "aria-haspopup": "true",
148
+ "aria-expanded": open,
149
+ "aria-controls": panelId,
150
+ "aria-label": "Open app launcher",
151
+ className: `${TRIGGER_BASE} h-9 w-9 hover:bg-launcher-hover`,
152
+ children: /* @__PURE__ */ jsx2(Grid3x3, { className: "h-5 w-5", "aria-hidden": "true" })
153
+ }
154
+ ),
155
+ open && /* @__PURE__ */ jsx2("div", { className: `absolute top-11 z-50 ${align === "end" ? "right-0" : "left-0"}`, children: /* @__PURE__ */ jsx2(LauncherPanel, { tiles, hostname, label, id: panelId }) })
156
+ ] });
157
+ }
158
+ function AppLauncherFab({ urls, label = "Your apps" } = {}) {
159
+ const { open, toggle, rootRef, triggerRef, panelId } = useLauncher();
160
+ const hostname = useHostname();
161
+ const tiles = resolveApps(urls);
162
+ return /* @__PURE__ */ jsxs2("div", { ref: rootRef, className: "fixed bottom-6 right-6 z-50", children: [
163
+ open && /* @__PURE__ */ jsx2("div", { className: "absolute bottom-16 right-0", children: /* @__PURE__ */ jsx2(LauncherPanel, { tiles, hostname, label, id: panelId }) }),
164
+ /* @__PURE__ */ jsx2(
165
+ "button",
166
+ {
167
+ ref: triggerRef,
168
+ type: "button",
169
+ onClick: toggle,
170
+ "aria-haspopup": "true",
171
+ "aria-expanded": open,
172
+ "aria-controls": panelId,
173
+ "aria-label": "Open app launcher",
174
+ title: "Open apps",
175
+ className: `${TRIGGER_BASE} size-12 bg-launcher-surface border border-launcher-border shadow-lg transition-transform hover:scale-105`,
176
+ children: /* @__PURE__ */ jsx2(Grid3x3, { className: "size-5", "aria-hidden": "true" })
177
+ }
178
+ )
179
+ ] });
180
+ }
181
+ export {
182
+ APPS,
183
+ AppLauncher,
184
+ AppLauncherFab,
185
+ LauncherPanel,
186
+ isCurrent,
187
+ resolveApps
188
+ };
@@ -0,0 +1,14 @@
1
+ /*
2
+ * Token namespace for the launcher. Resolution order per token:
3
+ * 1. --launcher-* an app's explicit override
4
+ * 2. the shadcn variable (hrm, portal — free)
5
+ * 3. a light-only literal (last resort)
6
+ */
7
+ @theme {
8
+ --color-launcher-surface: var(--launcher-surface, var(--popover, oklch(1 0 0)));
9
+ --color-launcher-fg: var(--launcher-fg, var(--popover-foreground, oklch(0.145 0 0)));
10
+ --color-launcher-muted: var(--launcher-muted, var(--muted-foreground, oklch(0.55 0 0)));
11
+ --color-launcher-hover: var(--launcher-hover, var(--accent, oklch(0.96 0 0)));
12
+ --color-launcher-border: var(--launcher-border, var(--border, oklch(0.92 0 0)));
13
+ --color-launcher-ring: var(--launcher-ring, var(--ring, currentColor));
14
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "8seneca-app-launcher",
3
+ "version": "0.1.0",
4
+ "description": "9-dot app launcher shared across the 8seneca apps",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/8seneca-hub/8seneca-ui.git",
9
+ "directory": "packages/app-launcher"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": [
13
+ "**/*.css"
14
+ ],
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./styles.css": "./dist/styles.css"
24
+ },
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "test": "pnpm build && node --test test/*.test.mjs",
28
+ "type-check": "tsc --noEmit",
29
+ "prepublishOnly": "pnpm test"
30
+ },
31
+ "peerDependencies": {
32
+ "react": ">=18",
33
+ "lucide-react": ">=0.454"
34
+ },
35
+ "devDependencies": {
36
+ "@types/react": "^19.0.0",
37
+ "lucide-react": "^0.454.0",
38
+ "react": "^19.0.0",
39
+ "tsup": "^8.3.5",
40
+ "typescript": "^5.7.0"
41
+ }
42
+ }