Work App: a dedicated browser for work tools

A Tauri 2 shell with one child webview per configured tool. Nav on the left
with groups and a collapsible icon rail; links between configured apps switch
tabs, everything else leaves for the real browser.

Design spec in docs/superpowers/specs/2026-09-01-work-app-design.md.
This commit is contained in:
2026-09-01 11:37:35 +02:00
commit a6c8e4336b
53 changed files with 15580 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import * as api from "./api";
import Nav, { navWidth } from "./components/Nav";
import Settings from "./components/Settings";
import TopBar from "./components/TopBar";
import { BTN_PRIMARY } from "./components/ui";
import { useAppearance, type Theme } from "./hooks/useAppearance";
import type { Config, Group, SwitchEvent, UrlEvent } from "./types";
export default function App() {
const [config, setConfig] = useState<Config | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [theme, setTheme] = useAppearance("system");
const stageRef = useRef<HTMLDivElement>(null);
const booted = useRef(false);
// Read inside listeners, which are registered once and would otherwise
// capture the first render's value forever.
const activeRef = useRef<string | null>(null);
activeRef.current = activeId;
const collapsed = config?.settings.navCollapsed ?? false;
const activeApp = config?.apps.find((a) => a.id === activeId) ?? null;
useEffect(() => {
api.getConfig().then((c) => {
setConfig(c);
setTheme(c.settings.theme);
const first = [...c.apps].sort((a, b) => a.order - b.order)[0];
setActiveId(first?.id ?? null);
});
}, [setTheme]);
/**
* Tells Rust where the stage is.
*
* An app's webview is a native view that takes no part in CSS layout, so the
* only way it lands in the right place is for the shell to measure the hole
* it left and report it.
*/
const report = useCallback(() => {
const el = stageRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
void api.setStage(r.x, r.y, r.width, r.height);
}, []);
useLayoutEffect(() => {
const el = stageRef.current;
if (!el || !config) return;
report();
const ro = new ResizeObserver(report);
ro.observe(el);
window.addEventListener("resize", report);
return () => {
ro.disconnect();
window.removeEventListener("resize", report);
};
}, [config, report]);
// Webviews are built only once the stage has been measured, so they are born
// at the right size instead of against a guess.
useEffect(() => {
if (!config || booted.current || config.apps.length === 0) return;
booted.current = true;
report();
void api.bootstrap();
}, [config, report]);
useEffect(() => {
if (!activeId) return;
void api.setActive(activeId);
void api.currentUrl(activeId).then(setUrl);
}, [activeId]);
// A link in one app that points at another: Rust decided, the shell moves.
useEffect(() => {
const unlisten = [
listen<SwitchEvent>("switch-app", async (e) => {
setActiveId(e.payload.appId);
await api.navigateApp(e.payload.appId, e.payload.url);
}),
listen<UrlEvent>("url-changed", (e) => {
if (e.payload.appId === activeRef.current) setUrl(e.payload.url);
}),
];
return () => {
unlisten.forEach((p) => p.then((f) => f()));
};
}, []);
// A native view paints over anything the shell draws, so a dialog needs the
// stage out of the way rather than merely on a higher z-index.
useEffect(() => {
if (!config) return;
void (settingsOpen ? api.hideStage() : api.showStage());
}, [settingsOpen, config]);
const toggleCollapse = () => {
if (!config) return;
const next = !collapsed;
setConfig({ ...config, settings: { ...config.settings, navCollapsed: next } });
void api.setNavCollapsed(next);
};
const toggleGroup = (g: Group) => {
if (!config) return;
const updated = { ...g, collapsed: !g.collapsed };
setConfig({
...config,
groups: config.groups.map((x) => (x.id === g.id ? updated : x)),
});
void api.updateGroup(updated);
};
const onTheme = (t: Theme) => {
setTheme(t);
void api.setTheme(t);
};
if (!config) return null;
return (
<div className="flex h-screen flex-col">
<TopBar
url={url}
appName={activeApp?.name ?? null}
onBack={() => activeId && api.historyGo(activeId, -1)}
onForward={() => activeId && api.historyGo(activeId, 1)}
onReload={() => activeId && api.historyGo(activeId, 0)}
onOpenExternal={() => url && api.openExternal(url)}
/>
<div className="flex min-h-0 flex-1">
<Nav
config={config}
activeId={activeId}
collapsed={collapsed}
onSelect={setActiveId}
onToggleCollapse={toggleCollapse}
onOpenSettings={() => setSettingsOpen(true)}
onToggleGroup={toggleGroup}
/>
{/* The hole an app's native webview is positioned into. It stays empty
on purpose — anything drawn here would be painted over. */}
<div ref={stageRef} className="min-w-0 flex-1 bg-slate-100 dark:bg-slate-950">
{config.apps.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p className="text-[13px] text-slate-500 dark:text-slate-400">
No apps yet. Add the tools you work in and they appear in the nav.
</p>
<button onClick={() => setSettingsOpen(true)} className={BTN_PRIMARY}>
Add your first app
</button>
</div>
)}
</div>
</div>
{settingsOpen && (
<Settings
config={config}
theme={theme}
onConfig={setConfig}
onTheme={onTheme}
onClose={() => setSettingsOpen(false)}
/>
)}
{/* Keeps the nav width honest for the stage measurement above. */}
<span hidden>{navWidth(collapsed)}</span>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
/** Every call into Rust, in one place. */
import { invoke } from "@tauri-apps/api/core";
import type { Config, Group, WorkApp } from "./types";
export const getConfig = () => invoke<Config>("get_config");
export const bootstrap = () => invoke<void>("bootstrap");
export const setStage = (x: number, y: number, width: number, height: number) =>
invoke<void>("set_stage", { x, y, width, height });
export const setActive = (appId: string) => invoke<void>("set_active", { appId });
export const hideStage = () => invoke<void>("hide_stage");
export const showStage = () => invoke<void>("show_stage");
export const navigateApp = (appId: string, url: string) =>
invoke<void>("navigate_app", { appId, url });
export const historyGo = (appId: string, delta: number) =>
invoke<void>("history_go", { appId, delta });
export const currentUrl = (appId: string) =>
invoke<string | null>("current_url", { appId });
export const openExternal = (url: string) => invoke<void>("open_external", { url });
export const addApp = (name: string, url: string, groupId: string | null) =>
invoke<Config>("add_app", { name, url, groupId });
export const updateApp = (updated: WorkApp) => invoke<Config>("update_app", { updated });
export const deleteApp = (appId: string) => invoke<Config>("delete_app", { appId });
export const reorderApps = (ordering: [string, string | null, number][]) =>
invoke<Config>("reorder_apps", { ordering });
export const addGroup = (name: string) => invoke<Config>("add_group", { name });
export const updateGroup = (updated: Group) => invoke<Config>("update_group", { updated });
export const deleteGroup = (groupId: string) => invoke<Config>("delete_group", { groupId });
export const setNavCollapsed = (collapsed: boolean) =>
invoke<void>("set_nav_collapsed", { collapsed });
export const setTheme = (theme: string) => invoke<void>("set_theme", { theme });
+185
View File
@@ -0,0 +1,185 @@
import type { Config, Group, WorkApp } from "../types";
import { Favicon, HEADING, ICON_CHROME } from "./ui";
interface Props {
config: Config;
activeId: string | null;
collapsed: boolean;
onSelect: (id: string) => void;
onToggleCollapse: () => void;
onOpenSettings: () => void;
onToggleGroup: (g: Group) => void;
}
const RAIL = 52;
const PANEL = 240;
export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
function Chevron({ dir }: { dir: "left" | "right" }) {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path
strokeLinecap="round"
strokeLinejoin="round"
d={dir === "left" ? "M15 6l-6 6 6 6" : "M9 6l6 6-6 6"}
/>
</svg>
);
}
function Cog() {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="3.2" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.14.34.4.62.73.79.24.13.51.2.78.21H21a2 2 0 110 4h-.09c-.7.01-1.33.43-1.51 1z" />
</svg>
);
}
export default function Nav({
config,
activeId,
collapsed,
onSelect,
onToggleCollapse,
onOpenSettings,
onToggleGroup,
}: Props) {
const groups = [...config.groups].sort((a, b) => a.order - b.order);
const inGroup = (id: string | null) =>
config.apps.filter((a) => a.groupId === id).sort((a, b) => a.order - b.order);
const ungrouped = inGroup(null);
const shell =
"flex shrink-0 flex-col overflow-hidden border-r border-slate-300 bg-white " +
"dark:border-slate-800 dark:bg-slate-900";
// ------------------------------------------------------------ icon rail
if (collapsed) {
const railBtn = (app: WorkApp) => {
const active = app.id === activeId;
return (
<button
key={app.id}
onClick={() => onSelect(app.id)}
title={app.name}
aria-label={app.name}
className={
"relative grid size-9 cursor-pointer place-items-center rounded-lg transition-colors " +
(active
? "bg-slate-100 dark:bg-slate-800"
: "opacity-70 hover:bg-slate-100 hover:opacity-100 dark:hover:bg-slate-800")
}
>
{active && (
<span className="absolute -left-2 h-5 w-[3px] rounded-full bg-sky-500" />
)}
<Favicon url={app.url} name={app.name} size={18} />
</button>
);
};
return (
<aside className={`${shell} w-[52px] items-center`} style={{ width: RAIL }}>
<div className="flex w-full flex-col items-center gap-1 border-b border-slate-200 py-2 dark:border-slate-800">
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
<Chevron dir="right" />
</button>
</div>
<nav className="flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto py-3">
{ungrouped.map(railBtn)}
{ungrouped.length > 0 && groups.length > 0 && (
<span className="my-1 h-px w-6 bg-slate-200 dark:bg-slate-800" />
)}
{groups.map((g, i) => {
const apps = inGroup(g.id);
if (apps.length === 0) return null;
return (
<div key={g.id} className="flex flex-col items-center gap-1">
{i > 0 && <span className="my-1 h-px w-6 bg-slate-200 dark:bg-slate-800" />}
{apps.map(railBtn)}
</div>
);
})}
</nav>
</aside>
);
}
// -------------------------------------------------------------- panel
const row =
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-[13px] transition-colors";
const inactive =
"text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800";
const active = "bg-slate-900 text-white dark:bg-white dark:text-slate-900";
const appRow = (app: WorkApp) => (
<li key={app.id}>
<button
onClick={() => onSelect(app.id)}
title={app.url}
className={`${row} ${app.id === activeId ? active : inactive}`}
>
<Favicon url={app.url} name={app.name} />
<span className="truncate">{app.name}</span>
</button>
</li>
);
return (
<aside className={shell} style={{ width: PANEL }}>
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-3 py-2 dark:border-slate-800">
<span className="truncate text-[13px] font-semibold tracking-tight">Apps</span>
<div className="flex shrink-0 items-center gap-1">
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
<button onClick={onToggleCollapse} title="Collapse" className={ICON_CHROME}>
<Chevron dir="left" />
</button>
</div>
</header>
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
{ungrouped.length > 0 && <ul className="space-y-0.5">{ungrouped.map(appRow)}</ul>}
{groups.map((g) => {
const apps = inGroup(g.id);
return (
<section key={g.id} className="pt-3 first:pt-0">
<button
onClick={() => onToggleGroup(g)}
className={`${HEADING} flex w-full cursor-pointer items-center gap-1 px-2 pb-1
hover:text-slate-700 dark:hover:text-slate-200`}
>
<svg
viewBox="0 0 24 24"
className={`size-3 transition-transform ${g.collapsed ? "" : "rotate-90"}`}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 6l6 6-6 6" />
</svg>
<span className="truncate">{g.name}</span>
<span className="ml-auto font-mono text-[10px] tracking-normal opacity-60">
{apps.length}
</span>
</button>
{!g.collapsed && <ul className="space-y-0.5">{apps.map(appRow)}</ul>}
</section>
);
})}
{config.apps.length === 0 && (
<p className="px-2 py-3 text-[11px] leading-snug text-slate-500 dark:text-slate-400">
No apps yet. Open Settings to add the first one.
</p>
)}
</nav>
</aside>
);
}
+265
View File
@@ -0,0 +1,265 @@
import { useState } from "react";
import * as api from "../api";
import type { Config, Group, WorkApp } from "../types";
import type { Theme } from "../hooks/useAppearance";
import {
BTN,
BTN_PRIMARY,
Dialog,
Favicon,
HELP,
ICON_CHROME,
INPUT,
LABEL,
SectionHeading,
Segmented,
SUBPANEL,
} from "./ui";
interface Props {
config: Config;
theme: Theme;
onConfig: (c: Config) => void;
onTheme: (t: Theme) => void;
onClose: () => void;
}
function TrashIcon() {
return (
<svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h16M10 11v6M14 11v6" />
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
);
}
export default function Settings({ config, theme, onConfig, onTheme, onClose }: Props) {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [groupId, setGroupId] = useState<string>("");
const [groupName, setGroupName] = useState("");
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
const groups = [...config.groups].sort((a, b) => a.order - b.order);
const run = async (fn: () => Promise<Config>) => {
try {
onConfig(await fn());
setError(null);
} catch (e) {
setError(String(e));
}
};
const addApp = async () => {
if (!name.trim() || !url.trim()) return;
await run(() => api.addApp(name, url, groupId || null));
setName("");
setUrl("");
};
const addGroup = async () => {
if (!groupName.trim()) return;
await run(() => api.addGroup(groupName));
setGroupName("");
};
const patch = (app: WorkApp, fields: Partial<WorkApp>) =>
run(() => api.updateApp({ ...app, ...fields }));
return (
<>
<Dialog title="Settings" onCancel={onClose} wide footer={<div />}>
<div className="space-y-6">
{error && (
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-[12px] text-red-600 dark:text-red-400">
{error}
</p>
)}
{/* ----------------------------------------------------- apps */}
<section className="space-y-2">
<SectionHeading>Apps</SectionHeading>
<div className="space-y-1.5">
{config.apps.length === 0 && (
<p className={HELP}>Nothing yet. Add the first one below.</p>
)}
{[...config.apps]
.sort((a, b) => a.order - b.order)
.map((app) => (
<div
key={app.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<Favicon url={app.url} name={app.name} />
<input
value={app.name}
onChange={(e) => patch(app, { name: e.target.value })}
className={`${INPUT} h-[26px] flex-1`}
/>
<input
value={app.url}
onChange={(e) => patch(app, { url: e.target.value })}
title="Changing this rebuilds the app's view"
className={`${INPUT} h-[26px] flex-[1.4] font-mono text-[11px]`}
/>
<select
value={app.groupId ?? ""}
onChange={(e) => patch(app, { groupId: e.target.value || null })}
className={`${INPUT} h-[26px] w-[110px] cursor-pointer`}
>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<button
onClick={() => setConfirmDelete(app)}
title={`Remove ${app.name}`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<TrashIcon />
</button>
</div>
))}
</div>
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>Name</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Odoo"
className={INPUT}
/>
</label>
<label className="flex-[1.4] space-y-1">
<span className={LABEL}>URL</span>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addApp()}
placeholder="example.com"
className={`${INPUT} font-mono text-[11px]`}
/>
</label>
<label className="w-[110px] space-y-1">
<span className={LABEL}>Group</span>
<select
value={groupId}
onChange={(e) => setGroupId(e.target.value)}
className={`${INPUT} cursor-pointer`}
>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</label>
<button onClick={addApp} className={BTN_PRIMARY}>
Add
</button>
</div>
<p className={HELP}>
An app owns its URL's host. Links to any other app on this list switch to it;
everything else opens in your browser.
</p>
</section>
{/* --------------------------------------------------- groups */}
<section className="space-y-2">
<SectionHeading>Groups</SectionHeading>
<div className="space-y-1.5">
{groups.length === 0 && <p className={HELP}>No groups yet.</p>}
{groups.map((g: Group) => (
<div
key={g.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<input
value={g.name}
onChange={(e) =>
run(() => api.updateGroup({ ...g, name: e.target.value }))
}
className={`${INPUT} h-[26px] flex-1`}
/>
<span className="shrink-0 font-mono text-[10px] text-slate-400">
{config.apps.filter((a) => a.groupId === g.id).length} apps
</span>
<button
onClick={() => run(() => api.deleteGroup(g.id))}
title={`Delete ${g.name} — its apps stay, ungrouped`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<TrashIcon />
</button>
</div>
))}
</div>
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>New group</span>
<input
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addGroup()}
placeholder="Finance"
className={INPUT}
/>
</label>
<button onClick={addGroup} className={BTN}>
Add group
</button>
</div>
</section>
{/* ----------------------------------------------- appearance */}
<section className="space-y-2">
<SectionHeading>Appearance</SectionHeading>
<Segmented<Theme>
value={theme}
onChange={onTheme}
options={[
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]}
/>
</section>
<div className="flex justify-end border-t border-slate-200 pt-4 dark:border-slate-800">
<button onClick={onClose} className={BTN}>
Done
</button>
</div>
</div>
</Dialog>
{confirmDelete && (
<Dialog
title={`Remove ${confirmDelete.name}?`}
onCancel={() => setConfirmDelete(null)}
onConfirm={async () => {
await run(() => api.deleteApp(confirmDelete.id));
setConfirmDelete(null);
}}
confirmLabel="Remove"
destructive
>
It disappears from the nav and its view is closed. The session it holds for{" "}
<span className="font-mono text-[12px]">{confirmDelete.url}</span> is kept, so
adding it back does not mean logging in again.
</Dialog>
)}
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { ICON_CHROME } from "./ui";
interface Props {
url: string | null;
appName: string | null;
onBack: () => void;
onForward: () => void;
onReload: () => void;
onOpenExternal: () => void;
}
/** Reserves the strip the macOS traffic lights sit in. */
const TRAFFIC_LIGHTS = 78;
function Icon({ d }: { d: string }) {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d={d} />
</svg>
);
}
/**
* Browser chrome, kept to the four things a tabless app still needs. It spans
* the whole window rather than sitting inside the stage, so the traffic lights
* have somewhere to live no matter how narrow the nav gets.
*/
export default function TopBar({
url,
appName,
onBack,
onForward,
onReload,
onOpenExternal,
}: Props) {
return (
<header
data-tauri-drag-region
className="flex h-[38px] shrink-0 items-center gap-1 border-b border-slate-300
bg-white px-2 dark:border-slate-800 dark:bg-slate-900"
style={{ paddingLeft: TRAFFIC_LIGHTS }}
>
<button onClick={onBack} title="Back" className={ICON_CHROME}>
<Icon d="M15 6l-6 6 6 6" />
</button>
<button onClick={onForward} title="Forward" className={ICON_CHROME}>
<Icon d="M9 6l6 6-6 6" />
</button>
<button onClick={onReload} title="Reload" className={ICON_CHROME}>
<Icon d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" />
</button>
<div
data-tauri-drag-region
className="mx-2 flex min-w-0 flex-1 items-center gap-2 text-[11px]"
>
{appName && (
<span className="shrink-0 font-medium text-slate-600 dark:text-slate-300">
{appName}
</span>
)}
<span className="truncate text-slate-400 dark:text-slate-500">{url ?? ""}</span>
</div>
<button
onClick={onOpenExternal}
title="Open this page in your browser"
className={ICON_CHROME}
disabled={!url}
>
<Icon d="M14 5h5v5M19 5l-8 8M18 14v4a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2h4" />
</button>
</header>
);
}
+233
View File
@@ -0,0 +1,233 @@
/**
* The design system's component vocabulary, in one place. Every other file
* composes these rather than re-spelling the class strings.
*
* Ported from FlightTube: slate and sky, a 915px type ladder, outline-first
* controls, borders for separation and shadows only for elevation.
*/
import type { ReactNode } from "react";
/** Every control in the app is this tall, so a row of mixed ones lines up. */
export const CONTROL_H = "h-[30px]";
export const ICON_BTN =
`grid ${CONTROL_H} w-[30px] shrink-0 place-items-center rounded-lg cursor-pointer ` +
"disabled:cursor-not-allowed disabled:opacity-40";
export const HEADING =
"text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400";
export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400";
export const HELP = "text-[11px] leading-snug text-slate-500 dark:text-slate-400";
export const SECTION = "border-b border-slate-200 px-4 py-4 dark:border-slate-800";
export const INPUT =
`w-full ${CONTROL_H} rounded-lg border border-slate-300 bg-white px-3 text-[12px] outline-none ` +
"placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500";
export const SUBPANEL = "rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50";
const BTN_BASE =
`inline-flex ${CONTROL_H} items-center justify-center rounded-lg text-[12px] ` +
"disabled:cursor-not-allowed cursor-pointer";
export const BTN =
`${BTN_BASE} border border-slate-300 px-2.5 font-medium ` +
"hover:border-sky-500 hover:text-sky-600 disabled:opacity-40 " +
"dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";
export const BTN_PRIMARY =
`${BTN_BASE} bg-sky-500 px-3 font-semibold text-white hover:bg-sky-400 disabled:opacity-40`;
export const BTN_DANGER =
`${BTN_BASE} bg-red-600 px-3 font-semibold text-white hover:bg-red-500`;
/** Header actions: quieter than a secondary button, still a real target. */
export const BTN_CHROME =
`inline-flex ${CONTROL_H} items-center rounded-lg px-2 text-[12px] font-medium text-slate-500 ` +
"cursor-pointer hover:bg-slate-100 hover:text-slate-900 disabled:opacity-40 " +
"disabled:hover:bg-transparent dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
export const BTN_QUIET =
"text-[11px] text-slate-500 underline underline-offset-2 cursor-pointer " +
"hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400";
/** Icon-button skin used throughout the chrome. */
export const ICON_CHROME =
`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 ` +
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
export function SectionHeading({ children }: { children: ReactNode }) {
return <h2 className={`flex items-center gap-2 ${HEADING}`}>{children}</h2>;
}
/** Bordered container, borderless children — the group's outline does the framing. */
export function Segmented<T extends string>({
options,
value,
onChange,
}: {
options: Array<{ value: T; label: ReactNode; title?: string }>;
value: T;
onChange: (v: T) => void;
}) {
return (
<div
role="group"
className={`flex ${CONTROL_H} items-center rounded-lg border border-slate-300 p-0.5 dark:border-slate-700`}
>
{options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
onClick={() => onChange(o.value)}
title={o.title}
className={
"inline-flex h-full cursor-pointer items-center justify-center rounded-md px-2.5 " +
"text-[12px] font-medium transition-colors " +
(active
? "bg-slate-900! text-white! dark:bg-white! dark:text-slate-900!"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-900 " +
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white")
}
>
{o.label}
</button>
);
})}
</div>
);
}
/** States a fact in passing — a quality, a status. Never a control. */
export function Badge({
children,
tone = "neutral",
title,
}: {
children: ReactNode;
tone?: "neutral" | "accent" | "danger";
title?: string;
}) {
const skin =
tone === "accent"
? "bg-sky-500/15 text-sky-700 dark:text-sky-300"
: tone === "danger"
? "bg-red-500/15 text-red-600 dark:text-red-400"
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300";
return (
<span
title={title}
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${skin}`}
>
{children}
</span>
);
}
/** A modal for anything needing a decision or reporting a failure. */
export function Dialog({
title,
children,
onCancel,
confirmLabel,
onConfirm,
destructive,
wide,
footer,
}: {
title: string;
children: ReactNode;
onCancel: () => void;
confirmLabel?: string;
onConfirm?: () => void;
destructive?: boolean;
wide?: boolean;
footer?: ReactNode;
}) {
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/70 p-5"
onClick={onCancel}
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className={`w-full ${wide ? "max-w-2xl max-h-[82vh] overflow-y-auto" : "max-w-sm"}
rounded-2xl border border-slate-300 bg-white p-5 shadow-2xl
dark:border-slate-700 dark:bg-slate-900`}
>
<h2 className="mb-1.5 text-[15px] font-semibold tracking-tight">{title}</h2>
<div className="mb-4 text-[13px] leading-relaxed text-slate-600 dark:text-slate-300">
{children}
</div>
{footer ?? (
<div className="flex justify-end gap-2">
<button onClick={onCancel} className={BTN}>
{onConfirm ? "Cancel" : "Close"}
</button>
{onConfirm && (
<button
onClick={onConfirm}
className={destructive ? BTN_DANGER : BTN_PRIMARY}
>
{confirmLabel ?? "Continue"}
</button>
)}
</div>
)}
</div>
</div>
);
}
/**
* An app's mark. Google's favicon service is used rather than the site's own
* /favicon.ico, because many work tools sit behind a login that would return
* the sign-in page's icon — or a 403 — to a request without a session.
*/
export function Favicon({
url,
name,
size = 16,
}: {
url: string;
name: string;
size?: number;
}) {
let host = "";
try {
host = new URL(url).hostname;
} catch {
host = "";
}
const letter = name.trim().charAt(0).toUpperCase() || "?";
return (
<span
className="relative grid shrink-0 place-items-center overflow-hidden rounded"
style={{ width: size, height: size }}
>
<span
aria-hidden
className="absolute inset-0 grid place-items-center rounded bg-slate-200
text-[9px] font-bold text-slate-500 dark:bg-slate-700 dark:text-slate-300"
style={{ fontSize: Math.max(9, size * 0.5) }}
>
{letter}
</span>
{host && (
<img
src={`https://www.google.com/s2/favicons?sz=64&domain=${host}`}
alt=""
width={size}
height={size}
loading="lazy"
className="relative rounded"
onError={(e) => {
// Leave the letter showing rather than a broken-image glyph.
e.currentTarget.style.display = "none";
}}
/>
)}
</span>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
export type Theme = "system" | "light" | "dark";
/**
* Applies System / Light / Dark to the document.
*
* System is a live subscription rather than a read at startup: the OS can flip
* at sunset while the window is open.
*/
export function useAppearance(initial: Theme) {
const [theme, setTheme] = useState<Theme>(initial);
useEffect(() => setTheme(initial), [initial]);
useEffect(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
const dark = theme === "dark" || (theme === "system" && media.matches);
document.documentElement.classList.toggle("dark", dark);
};
apply();
if (theme !== "system") return;
media.addEventListener("change", apply);
return () => media.removeEventListener("change", apply);
}, [theme]);
return [theme, setTheme] as const;
}
+56
View File
@@ -0,0 +1,56 @@
@import "tailwindcss";
/* Class-based dark mode, so the app can offer System / Light / Dark rather
than only following the OS. */
@custom-variant dark (&:where(.dark, .dark *));
@layer base {
[hidden] { display: none !important; }
/* It is a tool, not a document: dragging across it should not leave a
selection behind. Text fields opt back in. */
html { -webkit-user-select: none; user-select: none; }
input, textarea, [contenteditable="true"] { -webkit-user-select: text; user-select: text; }
img, a { -webkit-user-drag: none; }
/* No focus ring anywhere. :focus-visible has to go too — WebKit counts a
click on a select or checkbox as "focus worth showing" and draws its own,
which survives a :focus rule alone. */
*, *::before, *::after, :focus, :focus-visible, :focus-within {
outline: none !important;
outline-offset: 0 !important;
-webkit-tap-highlight-color: transparent;
}
button:focus, button:focus-visible, select:focus, [contenteditable]:focus {
outline: none !important;
}
html, body, #root { height: 100%; }
body {
margin: 0;
-webkit-font-smoothing: antialiased;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
background: var(--color-slate-100);
color: var(--color-slate-800);
}
.dark body {
background: var(--color-slate-950);
color: var(--color-slate-100);
}
/* Borders do the separating; the scrollbar should not compete. */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--color-slate-300);
border-radius: 9999px;
border: 3px solid transparent;
background-clip: content-box;
}
.dark ::-webkit-scrollbar-thumb {
background: var(--color-slate-700);
background-clip: content-box;
}
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+41
View File
@@ -0,0 +1,41 @@
export interface WorkApp {
id: string;
name: string;
url: string;
scope: string[];
groupId: string | null;
userAgent: string | null;
order: number;
}
export interface Group {
id: string;
name: string;
collapsed: boolean;
order: number;
}
export interface Settings {
navCollapsed: boolean;
theme: "system" | "light" | "dark";
pairedBrowser: string | null;
lastPairedAt: string | null;
}
export interface Config {
version: number;
groups: Group[];
apps: WorkApp[];
settings: Settings;
}
/** Rust asked the shell to switch apps because a link pointed at another one. */
export interface SwitchEvent {
appId: string;
url: string;
}
export interface UrlEvent {
appId: string;
url: string;
}