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
+118
View File
@@ -0,0 +1,118 @@
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { cn } from 'helpers/cn';
// The row every list is made of. See docs/design-language-interface.md § 2 and § 5.
//
// The rule this exists to enforce is "one focal point per row": exactly one element at row-title
// rank, everything else meta. That is not a rule you can follow by remembering it — thirty views
// proved that — so the shape encodes it. `title` is the focal point and the only thing that gets
// weight; `meta` is a single dotted line; `lead` and `trail` are for a state marker and an action,
// not for more text.
//
// Rows carry their own padding and rely on the list's `divide-y` for separation. A border per row
// double-draws at every boundary and is why some lists have hairlines twice as dark as others.
type DataRowProps = {
/** The focal point. A string is styled for you; a node is passed through for when it needs marks. */
title: ReactNode;
/** One line of prose under the title. Truncated — a row is scanned, not read. */
description?: ReactNode;
/** Secondary facts, joined with a dot. Falsy entries are dropped, so callers can inline conditions. */
meta?: ReactNode[];
/** State marker or avatar, vertically aligned to the title. */
lead?: ReactNode;
/** Counts, actions, chevrons. Right-aligned, never wraps. */
trail?: ReactNode;
/** Makes the whole row a link. Rows are real links so cmd-click and the back button work. */
to?: string;
onClick?: () => void;
/** Marks the row as the current selection when the list is a master beside a detail pane. */
selected?: boolean;
className?: string;
};
export const DataRow = ({ title, description, meta, lead, trail, to, onClick, selected, className }: DataRowProps) => {
const interactive = !!to || !!onClick;
const body = (
<>
{lead && <div className="mt-0.5 flex shrink-0 items-center">{lead}</div>}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{typeof title === 'string' ? <span className="truncate text-sm font-medium">{title}</span> : title}
</div>
{description && <p className="truncate text-sm text-muted-foreground">{description}</p>}
{meta && <RowMeta items={meta} />}
</div>
{trail && <div className="flex shrink-0 items-center gap-2 whitespace-nowrap">{trail}</div>}
</>
);
const classes = cn(
'flex items-start gap-3 px-4 py-3 text-left transition-colors',
interactive && 'hover:bg-muted/50',
selected && 'bg-muted',
className,
);
if (to) {
return (
<Link to={to} className={classes}>
{body}
</Link>
);
}
if (onClick) {
return (
<button type="button" onClick={onClick} className={cn(classes, 'w-full')}>
{body}
</button>
);
}
return <div className={classes}>{body}</div>;
};
/**
* The dotted meta line. Separators are rendered between items rather than after each, so a trailing
* dot can never appear when the last entry is conditional — the failure mode of joining with a
* string. Numbers get `tabular-nums` here for free (§ 7).
*/
export const RowMeta = ({ items, className }: { items: ReactNode[]; className?: string }) => {
const shown = items.filter(Boolean);
if (!shown.length) return null;
return (
<div
className={cn(
'mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs tabular-nums text-muted-foreground',
className,
)}
>
{shown.map((item, index) => (
<span key={index} className="flex items-center gap-1.5">
{index > 0 && (
<span aria-hidden className="text-muted-foreground/40">
&middot;
</span>
)}
{item}
</span>
))}
</div>
);
};
/** An icon paired with its own value inside a meta line — they are one object, hence `gap-1.5`. */
export const MetaItem = ({ icon: Icon, children }: { icon?: LucideIcon; children: ReactNode }) => (
<span className="flex items-center gap-1.5">
{Icon && <Icon className="h-3.5 w-3.5" />}
{children}
</span>
);
/** The container a list of DataRows goes in: scrolls, and draws the one hairline between rows. */
export const DataList = ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={cn('h-full overflow-y-auto', className)}>
<div className="flex flex-col divide-y">{children}</div>
</div>
);
@@ -0,0 +1,68 @@
import { cn } from 'helpers/cn';
// "3 days ago", with the exact timestamp on hover.
//
// The hover title is the whole reason this is a component rather than a string helper. Relative time
// is right for scanning and useless for anything else — "2 months ago" cannot be compared to a log
// line or quoted in a message — and every list that renders it loses the real value. Keeping both
// costs one attribute.
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
export function timeAgo(value: string | number | Date | null | undefined): string {
if (!value) return '';
const then = new Date(value).getTime();
if (Number.isNaN(then)) return '';
const delta = Date.now() - then;
if (delta < 0) return 'just now';
if (delta < MINUTE) return 'just now';
if (delta < HOUR) {
const n = Math.floor(delta / MINUTE);
return `${n}m ago`;
}
if (delta < DAY) {
const n = Math.floor(delta / HOUR);
return `${n}h ago`;
}
if (delta < 30 * DAY) {
const n = Math.floor(delta / DAY);
return n === 1 ? 'yesterday' : `${n}d ago`;
}
if (delta < 365 * DAY) {
const n = Math.floor(delta / (30 * DAY));
return `${n}mo ago`;
}
return `${Math.floor(delta / (365 * DAY))}y ago`;
}
export function absoluteTime(value: string | number | Date): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '' : date.toLocaleString();
}
export const RelativeTime = ({
value,
className,
prefix,
}: {
value: string | number | Date | null | undefined;
className?: string;
/** e.g. "updated" → "updated 3d ago". Kept out of the value so it is not repeated per list item. */
prefix?: string;
}) => {
if (!value) return null;
const relative = timeAgo(value);
if (!relative) return null;
return (
<time
dateTime={new Date(value).toISOString()}
title={absoluteTime(value)}
className={cn('tabular-nums', className)}
>
{prefix ? `${prefix} ${relative}` : relative}
</time>
);
};
@@ -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>
);
@@ -0,0 +1,49 @@
import type { LucideIcon } from 'lucide-react';
import { cn } from 'helpers/cn';
import { type Tone, toneFill, toneText } from './tone';
// The state marker, in the two densities every list needs: the full pill where there is room to say
// the word, and the bare icon where a row is too dense to spend a pill on.
//
// Both take the same `tone` + `icon`, so a view can switch between them without the colours drifting
// apart — which is exactly how the Gitea list and detail views ended up disagreeing about what
// colour "closed" was.
type StatusPillProps = {
tone: Tone;
icon?: LucideIcon;
children: React.ReactNode;
className?: string;
title?: string;
};
export const StatusPill = ({ tone, icon: Icon, children, className, title }: StatusPillProps) => (
<span
title={title}
className={cn(
'inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
toneFill[tone],
className,
)}
>
{Icon && <Icon className="h-3.5 w-3.5" />}
{children}
</span>
);
/**
* The same state as a bare icon. `title` is not optional in spirit — an icon with no text is the one
* place the icon language's "if it needs explanation it is too complicated" rule can be broken by
* accident, and a tooltip is the cheapest repair.
*/
export const StatusIcon = ({
tone,
icon: Icon,
title,
className,
}: {
tone: Tone;
icon: LucideIcon;
title: string;
className?: string;
}) => <Icon className={cn('h-4 w-4 shrink-0', toneText[tone], className)} aria-label={title} />;
+11
View File
@@ -0,0 +1,11 @@
// Data-surface primitives for the DuckSuite interface design language.
// The language itself is docs/design-language-interface.md; this is how you spend it.
//
// Additive on purpose. Nothing that already exists imports these, so adoption is per-view and a view
// that has not adopted yet is not broken — it is just still on the old ad-hoc styling.
export { DataList, DataRow, MetaItem, RowMeta } from './DataRow';
export { EmptyBlock, ErrorBlock, LoadingBlock } from './StateBlock';
export { RelativeTime, absoluteTime, timeAgo } from './RelativeTime';
export { StatusIcon, StatusPill } from './StatusPill';
export { type Tone, toneBorder, toneFill, toneText } from './tone';
+41
View File
@@ -0,0 +1,41 @@
// The state vocabulary for data surfaces. See docs/design-language-interface.md § 3.
//
// Five tones and no others. The point of a closed set is that colour keeps meaning something: when a
// view can reach for any of Tailwind's twenty-two palettes, green stops reading as "open" and starts
// reading as "the author liked green here". Every tone maps to a semantic token, never a palette
// number, so it follows the theme and survives dark mode.
//
// Class strings are written out in full rather than composed (`text-${tone}`) because Tailwind's
// scanner reads source text — an interpolated class name is not in the output CSS at all.
export type Tone = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
/** Foreground only — for an icon or a word carrying state inside otherwise normal text. */
export const toneText: Record<Tone, string> = {
neutral: 'text-muted-foreground',
success: 'text-success',
warning: 'text-warning',
danger: 'text-destructive',
info: 'text-info',
};
/**
* The tinted fill used by pills and badges. Always /15 — a solid saturated fill on something this
* small fights the text on top of it and shouts louder than the state deserves.
*/
export const toneFill: Record<Tone, string> = {
neutral: 'bg-muted text-muted-foreground',
success: 'bg-success/15 text-success',
warning: 'bg-warning/15 text-warning',
danger: 'bg-destructive/15 text-destructive',
info: 'bg-info/15 text-info',
};
/** A left border or rule carrying state, for rows that mark status without a pill. */
export const toneBorder: Record<Tone, string> = {
neutral: 'border-border',
success: 'border-success',
warning: 'border-warning',
danger: 'border-destructive',
info: 'border-info',
};