Files
work-app/src/components/ui.tsx
T
Vincent a6c8e4336b 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.
2026-09-01 11:37:35 +02:00

234 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
);
}