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:
@@ -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 9–15px 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user