add the interface half of the duck suite design language

DuckSuite_Design_Language.md covers icons only — mascot geometry, lighting,
one-idea-per-icon, readable at 64px. It says nothing about type, density or
data, which is why the chrome looks considered and every data view does not.
This is the companion document plus the primitives that enforce it.

The diagnosis it is written against, from grepping two apps: 11 uses of
text-[10px], 7 of text-[11px], 52 of text-xs and 3 of text-base, with no rule
about which meant what; six radius values; hand-picked emerald/amber/red/purple
next to unused --success/--warning/--destructive tokens; text-black/50 and
ring-black/5, which are invisible in dark mode. None of that was a bad decision,
it was the absence of one thirty times over — so the fix is to remove the choice
rather than to have better taste.

docs/design-language-interface.md sets four type ranks with a 12px floor, one
focal point per row, five state tones, three radii and a spacing rhythm. The
principles are lifted from the icon language rather than invented, because
"one focal point, reads instantly, no unnecessary decorations, if it needs
explanation it is too complicated" is already the right rule for a dense list.

components/Data/ is how you spend that vocabulary: DataRow/DataList/RowMeta,
StatusPill/StatusIcon, LoadingBlock/ErrorBlock/EmptyBlock, RelativeTime. Rules
you have to remember are rules thirty views already broke, so the shape encodes
them — DataRow takes exactly one title and everything else is meta, RowMeta
puts separators between items so a trailing dot cannot appear, RelativeTime
carries the absolute timestamp as a hover title.

Adds --info (violet) as the fifth semantic tone, light and dark. "Merged" and
"in progress" are neither good news nor bad, and painting them with --success
makes a merged PR and an open one look like the same thing.

Entirely additive: nothing imports these yet, so no existing view changes and
there is no collision with the other agent working in this tree. Typechecks
clean. Not yet rendered in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 21:16:42 +00:00
co-authored by Claude Opus 5
parent 1e3b64c6c2
commit 88652910d7
8 changed files with 492 additions and 0 deletions
@@ -0,0 +1,72 @@
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { Loader2, TriangleAlert } from 'lucide-react';
import { cn } from 'helpers/cn';
// Loading, failed, empty. See docs/design-language-interface.md § 6.
//
// These are the states a new user meets FIRST — before a single record exists, before a token is
// pasted, when something is misconfigured. Treating them as filler is how an app ends up saying "No
// results" to someone who cannot tell whether that means "nothing here" or "it is broken".
//
// One implementation, so all three agree on size, spacing and centring across every app. They fill
// their container rather than sitting at the top, because a half-empty pane with a line of grey text
// stuck to the ceiling reads as a rendering bug.
const Shell = ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={cn('flex h-full flex-col items-center justify-center gap-2 p-6 text-center', className)}>
{children}
</div>
);
export const LoadingBlock = ({ label = 'Loading…' }: { label?: string }) => (
<Shell>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{label}</p>
</Shell>
);
/**
* The real message is shown, not a friendly substitute for it. On a self-hosted platform the person
* reading an error is almost always the person who can fix it, and "Something went wrong" costs them
* the one clue they needed.
*/
export const ErrorBlock = ({ title, message, action }: { title: string; message?: ReactNode; action?: ReactNode }) => (
<Shell>
<TriangleAlert className="h-5 w-5 text-warning" />
<div className="flex flex-col gap-1">
<p className="text-base font-semibold">{title}</p>
{message && <p className="max-w-sm text-sm text-muted-foreground">{message}</p>}
</div>
{action}
</Shell>
);
/**
* `hint` is not decoration — it is where the state says what WOULD be here and why it is not, which
* is the difference between an empty state and a shrug.
*/
export const EmptyBlock = ({
icon: Icon,
title,
hint,
action,
}: {
icon?: LucideIcon;
title: string;
hint?: ReactNode;
action?: ReactNode;
}) => (
<Shell>
{Icon && (
<div className="mb-1 flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon className="h-5 w-5" />
</div>
)}
<div className="flex flex-col gap-1">
<p className="text-base font-semibold">{title}</p>
{hint && <p className="max-w-sm text-sm text-muted-foreground">{hint}</p>}
</div>
{action}
</Shell>
);