diff --git a/docs/design-language-interface.md b/docs/design-language-interface.md new file mode 100644 index 00000000..65bc9c43 --- /dev/null +++ b/docs/design-language-interface.md @@ -0,0 +1,119 @@ +# DuckSuite Design Language — Interface + +The companion to `DuckSuite_Design_Language.md`, which covers icons. That document defines how a +DuckSuite app looks **on a home screen**. This one defines how it looks **once opened**. + +They share principles, because the icon language's rules are good interface rules: + +> One focal point. Reads instantly. Readable at small sizes. No unnecessary decorations. +> Minimal clutter. If it needs explanation, it is too complicated. + +This document governs **data surfaces** — lists, rows, tables, detail panes, anything rendering +records from a service. It does not govern the chrome (dock, panels, workspace shell), which is +settled and stays as it is. + +## Why this exists + +Every data view in Officer was built on its own, and it shows. A survey of two apps found 11 uses of +`text-[10px]`, 7 of `text-[11px]`, 52 of `text-xs` and 3 of `text-base` — a range of 10–16px with no +rule about which meant what. Six radius values. Green used both for "this is open" and for "this is +a heading icon", so green signalled nothing. + +None of that was a bad decision. It was the absence of one, thirty times over. The fix is not taste, +it is removing the choice: **the ranks below are the whole vocabulary, and the primitives in +`components/Data/` are how you spend it.** + +## 1. Type — four ranks, and a floor + +| Rank | Class | Size | Use | +| ------------- | ------------------------- | ---- | ------------------------------------------------------------ | +| **Title** | `text-base font-semibold` | 16px | The one thing a screen is about. One per view. | +| **Row title** | `text-sm font-medium` | 14px | The focal point of a row — repo name, issue title. | +| **Body** | `text-sm` | 14px | Descriptions, prose, form values, anything read as language. | +| **Meta** | `text-xs` | 12px | Secondary facts: timestamps, counts, authors, hints. | + +**12px is the floor.** No `text-[10px]`, no `text-[11px]`, no arbitrary sizes. If something feels +like it needs to be smaller than meta, it is not important enough to be on the screen — cut it +instead. This single rule is most of the readability fix. + +`font-medium` marks the focal point. `font-semibold` is for titles only. Never both in one row. + +## 2. One focal point per row + +Straight from the icon language. A row has exactly **one** element at row-title rank; everything +else is meta. A list is scanned, not read, and scanning works by finding the one bold thing per +line. Two competing emphases per row and the eye has nowhere to land — which is why the current +lists feel busy at a glance despite containing very little. + +Meta facts sit on **one line**, separated by a `·` dot, in source order of importance. They do not +each get their own badge. + +## 3. Colour means state, never decoration + +Colour on a data surface is **information**. If it isn't telling you something you'd act on, it is +noise, and it dilutes the colour that is. + +Five tones, and no others: + +| Tone | Token | Means | +| --------- | ------------- | --------------------------------------------------------------------- | +| `neutral` | `muted` | No state, or a state with no valence. The default. | +| `success` | `success` | Open, healthy, passing, connected, done. | +| `warning` | `warning` | Needs attention, degraded, archived, stale. | +| `danger` | `destructive` | Failed, rejected, closed-unresolved, error. | +| `info` | `info` | In progress, merged, informational. Distinct from success on purpose. | + +Always the semantic token, never a raw Tailwind palette number. `emerald-500` and `success` may +render the same pixel today, but only one of them follows the theme, survives dark mode, and can be +re-tuned in one place. **`text-black/50` and `ring-black/5` are bugs** — they are invisible in dark +mode. + +Fills are `bg-/15` with `text-`. Never a solid saturated fill on a small element. + +Brand colour (`brand`, the DuckSuite forest green) is for identity and primary actions. It is never +a state. Keeping it out of the state vocabulary is what lets it read as "Officer" rather than as +"good". + +## 4. One radius rule + +| Shape | Radius | +| -------------------------------- | -------------- | +| Containers, cards, panels | `rounded-lg` | +| Controls, inputs, buttons, tiles | `rounded-md` | +| Pills, badges, avatars | `rounded-full` | + +Three values. Nothing else. A radius is not an expressive choice. + +## 5. Spacing rhythm + +On a 4px grid, and each step means something: + +- `gap-1.5` — an icon and its own label. They are one object. +- `gap-2` — items inside one cluster. +- `gap-3` — between clusters in a row; the row's own `px-4 py-3`. +- `gap-4` / `gap-6` — between sections. + +Dense list rows are `px-4 py-3`. Detail panes are `p-4` inside `gap-6` sections. Rows separate with +`divide-y`, not with margins and not with a border per row. + +## 6. The three non-content states are not an afterthought + +Every data surface can be loading, failed, or empty, and those are the states a new user sees first. +They get the same care as the populated view, and they come from `StateBlock` so they are identical +everywhere. + +An empty state says **what would be here** and **why it isn't** — never just "No results". An error +state shows the real message, because the person reading it is usually the person who can fix it. + +## 7. Numbers + +Anything columnar or comparable is `tabular-nums`. Counts, sizes, durations, dates. Digits that +change width make a list twitch. + +## Applying it + +Use `components/Data/` primitives rather than re-implementing a row. They encode everything above, +which means adopting them is how a view complies — not a separate step afterwards. + +Adoption is per-view and additive; nothing breaks by not having adopted yet. Gitea went first +because it is the first surface shown to people outside this machine. diff --git a/src/apps/officer-web/styles/globals.css b/src/apps/officer-web/styles/globals.css index 76726f71..cfa38350 100644 --- a/src/apps/officer-web/styles/globals.css +++ b/src/apps/officer-web/styles/globals.css @@ -48,6 +48,9 @@ --color-warning: hsl(var(--warning)); --color-warning-foreground: hsl(var(--warning-foreground)); + --color-info: hsl(var(--info)); + --color-info-foreground: hsl(var(--info-foreground)); + --color-muted: hsl(var(--muted)); --color-muted-foreground: hsl(var(--muted-foreground)); @@ -184,6 +187,12 @@ --warning: 43 96% 56%; --warning-foreground: 0 0% 0%; + /* The fifth state tone. "In progress" and "merged" are neither good news nor bad, and painting + them with --success makes a merged PR and an open one look like the same thing. Violet because + it has to stay legible next to success green and destructive red at pill size. */ + --info: 263 70% 50%; + --info-foreground: 0 0% 100%; + --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; @@ -267,6 +276,11 @@ --warning: 43 96% 56%; --warning-foreground: 0 0% 0%; + /* Lifted well above the light value — 50% lightness violet on a near-black background reads as + muddy grey at 12px, which is exactly the size it is used at. */ + --info: 263 90% 70%; + --info-foreground: 0 0% 100%; + --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; diff --git a/src/workspaces/components/Data/DataRow.tsx b/src/workspaces/components/Data/DataRow.tsx new file mode 100644 index 00000000..d6ef2b7f --- /dev/null +++ b/src/workspaces/components/Data/DataRow.tsx @@ -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 &&
{lead}
} +
+
+ {typeof title === 'string' ? {title} : title} +
+ {description &&

{description}

} + {meta && } +
+ {trail &&
{trail}
} + + ); + + 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 ( + + {body} + + ); + } + if (onClick) { + return ( + + ); + } + return
{body}
; +}; + +/** + * 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 ( +
+ {shown.map((item, index) => ( + + {index > 0 && ( + + · + + )} + {item} + + ))} +
+ ); +}; + +/** 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 }) => ( + + {Icon && } + {children} + +); + +/** 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 }) => ( +
+
{children}
+
+); diff --git a/src/workspaces/components/Data/RelativeTime.tsx b/src/workspaces/components/Data/RelativeTime.tsx new file mode 100644 index 00000000..fe5fc852 --- /dev/null +++ b/src/workspaces/components/Data/RelativeTime.tsx @@ -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 ( + + ); +}; diff --git a/src/workspaces/components/Data/StateBlock.tsx b/src/workspaces/components/Data/StateBlock.tsx new file mode 100644 index 00000000..9f6869d7 --- /dev/null +++ b/src/workspaces/components/Data/StateBlock.tsx @@ -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 }) => ( +
+ {children} +
+); + +export const LoadingBlock = ({ label = 'Loading…' }: { label?: string }) => ( + + +

{label}

+
+); + +/** + * 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 }) => ( + + +
+

{title}

+ {message &&

{message}

} +
+ {action} +
+); + +/** + * `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; +}) => ( + + {Icon && ( +
+ +
+ )} +
+

{title}

+ {hint &&

{hint}

} +
+ {action} +
+); diff --git a/src/workspaces/components/Data/StatusPill.tsx b/src/workspaces/components/Data/StatusPill.tsx new file mode 100644 index 00000000..0c69861c --- /dev/null +++ b/src/workspaces/components/Data/StatusPill.tsx @@ -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) => ( + + {Icon && } + {children} + +); + +/** + * 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; +}) => ; diff --git a/src/workspaces/components/Data/index.tsx b/src/workspaces/components/Data/index.tsx new file mode 100644 index 00000000..900fc8dd --- /dev/null +++ b/src/workspaces/components/Data/index.tsx @@ -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'; diff --git a/src/workspaces/components/Data/tone.ts b/src/workspaces/components/Data/tone.ts new file mode 100644 index 00000000..cfcec618 --- /dev/null +++ b/src/workspaces/components/Data/tone.ts @@ -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 = { + 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 = { + 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 = { + neutral: 'border-border', + success: 'border-success', + warning: 'border-warning', + danger: 'border-destructive', + info: 'border-info', +};