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
+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>
);
}