INPUT carried w-full, which lost the specificity coin-toss against the group select's w-[110px] and starved the name and URL fields to stubs. Width now belongs to the call site. Settings lists apps in the nav's order rather than by position alone, so the two never disagree. scripts/ship.sh builds, replaces /Applications/Work.app and relaunches.
236 lines
7.7 KiB
TypeScript
236 lines
7.7 KiB
TypeScript
/**
|
||
* 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";
|
||
/* No width: callers set it. A `w-full` baked in here loses the specificity
|
||
coin-toss against a `w-[110px]` or a `flex-1` at the call site. */
|
||
export const INPUT =
|
||
`${CONTROL_H} min-w-0 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>
|
||
);
|
||
}
|