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:
@@ -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-<tone>/15` with `text-<tone>`. 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.
|
||||
@@ -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%;
|
||||
|
||||
@@ -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">
|
||||
·
|
||||
</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} />;
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
};
|
||||
Reference in New Issue
Block a user