Northstar
Workspace · 12 members
The team’s shared space for planning the autumn release.
Read-only presentation of records and values. Tones reinforce text; they never replace it.
header · title · description · body · footer · elevation
Workspace · 12 members
The team’s shared space for planning the autumn release.
For a card that sits above the page.
A body alone, with no header or footer.
Footers line up. A footer sticks to the bottom, so cards of different heights in one row align their actions.
components/display/card/doc.ts
/**
* Card — a framed unit of content.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* Card as? ("article" | "section" | "div", default article),
* elevation? ("flat" | "raised", default flat)
* CardHeader children (title, description), actions?
* CardTitle a Heading; level defaults to 3, size to "sm"
* CardDescription a paragraph under the title
* CardBody the content
* CardFooter actions or metadata, end-aligned, above a rule
*
* # Behaviour
*
* R1 `flat` sits on `--surface-panel` with a `--line` border and `--radius-3`;
* `raised` sits on `--surface-raised` with `--shadow`.
* R2 Every part pads with `--space-7`; a body following a header drops its
* top padding so the two read as one block.
* R3 The footer sticks to the bottom, so cards of different content heights
* in one grid row align their footers.
* R4 Header actions stay at the end of the title row and never wrap under
* the title's first line.
* R5 A card is an article by default: something that stands on its own. Use
* a div when it only frames part of a page.
*
* # Deliberately absent (for now)
*
* A whole-card link and a media slot. A picture on a card is an AspectRatio
* inside CardBody until a real screen asks for more.
*/
export {};components/display/card/card.tsx
import type {
ComponentPropsWithoutRef,
ComponentPropsWithRef,
ReactNode,
} from "react";
import { Heading, type HeadingProps } from "@/components/typography/heading";
import { cn } from "@/lib/utils/cn";
import styles from "./card.module.css";
import { cardVariants, type CardVariants } from "./card.variants";
// Without a ref: one ref type cannot fit all three `as` elements.
export type CardProps = ComponentPropsWithoutRef<"article"> &
CardVariants & {
/** An article when it stands on its own (a record, a post); a div when it
* is only a frame around part of a page. */
as?: "article" | "section" | "div";
};
export function Card({
as: Tag = "article",
elevation,
className,
...props
}: CardProps) {
return (
<Tag {...props} className={cn(cardVariants({ elevation }), className)} />
);
}
export type CardHeaderProps = ComponentPropsWithRef<"div"> & {
/** Controls at the end of the header: a menu, a status, a link. */
actions?: ReactNode;
};
export function CardHeader({
actions,
className,
children,
...props
}: CardHeaderProps) {
return (
<div {...props} className={cn(styles.header, className)}>
<div className={styles.heading}>{children}</div>
{actions ? <div className={styles.actions}>{actions}</div> : null}
</div>
);
}
export type CardTitleProps = Omit<HeadingProps, "level"> & {
level?: HeadingProps["level"];
};
/** A heading, level 3 unless the page outline needs another. */
export function CardTitle({
level = 3,
size = "sm",
...props
}: CardTitleProps) {
return <Heading {...props} level={level} size={size} />;
}
export function CardDescription({
className,
...props
}: ComponentPropsWithRef<"p">) {
return <p {...props} className={cn(styles.description, className)} />;
}
export function CardBody({
className,
...props
}: ComponentPropsWithRef<"div">) {
return <div {...props} className={cn(styles.body, className)} />;
}
export function CardFooter({
className,
...props
}: ComponentPropsWithRef<"div">) {
return <div {...props} className={cn(styles.footer, className)} />;
}components/display/card/card.variants.ts
import { cva, type VariantProps } from "class-variance-authority";
import styles from "./card.module.css";
export const cardVariants = cva(styles.root, {
variants: {
elevation: { flat: null, raised: styles.raised },
},
defaultVariants: { elevation: "flat" },
});
export type CardVariants = VariantProps<typeof cardVariants>;components/display/card/card.module.css
@layer primitive {
.root {
display: flex;
min-width: 0;
flex-direction: column;
border: 1px solid var(--line);
border-radius: var(--radius-3);
background: var(--surface-panel);
color: var(--ink);
overflow-wrap: anywhere;
}
.raised {
background: var(--surface-raised);
box-shadow: var(--shadow);
}
.header {
display: flex;
align-items: flex-start;
gap: var(--space-5);
padding: var(--space-7);
}
.heading {
display: grid;
min-width: 0;
flex: 1;
gap: var(--space-2);
}
.actions {
display: flex;
flex: none;
align-items: center;
gap: var(--space-3);
}
.description {
margin: 0;
color: var(--ink-2);
font-size: var(--text-12);
line-height: var(--leading-body);
}
.body {
display: grid;
min-width: 0;
flex: 1;
align-content: start;
gap: var(--space-5);
padding: var(--space-7);
}
/* The header already spaces the body from the card's top edge. */
.header + .body {
padding-top: 0;
}
.footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--space-4);
margin-top: auto;
padding: var(--space-5) var(--space-7);
border-top: 1px solid var(--line);
}
}tone · dot
components/display/badge/doc.ts
/**
* Badge — a short status or category.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* tone? "neutral" | "accent" | "info" | "warn" | "crit" default neutral
* dot? boolean
*
* # Behaviour
*
* R1 A pill: `--text-11`, strong, on the tone's tint with its line colour.
* R2 The text is the meaning; tone and dot only reinforce it. `dot` adds a
* mark so the tone survives greyscale, hidden from assistive technology.
* R3 One line, never wrapping.
*/
export {};components/display/badge/badge.tsx
import type { ComponentPropsWithRef } from "react";
import { cn } from "@/lib/utils/cn";
import styles from "./badge.module.css";
import { badgeVariants, type BadgeVariants } from "./badge.variants";
export type BadgeProps = ComponentPropsWithRef<"span"> &
BadgeVariants & {
/** A dot before the text, so the tone does not rest on colour alone. */
dot?: boolean;
};
export function Badge({
tone,
dot = false,
className,
children,
...props
}: BadgeProps) {
return (
<span {...props} className={cn(badgeVariants({ tone }), className)}>
{dot ? <span className={styles.dot} aria-hidden="true" /> : null}
{children}
</span>
);
}components/display/badge/badge.variants.ts
import { cva, type VariantProps } from "class-variance-authority";
import styles from "./badge.module.css";
export const badgeVariants = cva(styles.root, {
variants: {
tone: {
neutral: styles.neutral,
accent: styles.accent,
info: styles.info,
warn: styles.warn,
crit: styles.crit,
},
},
defaultVariants: { tone: "neutral" },
});
export type BadgeVariants = VariantProps<typeof badgeVariants>;components/display/badge/badge.module.css
@layer primitive {
.root {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 2px var(--space-4);
border: 1px solid;
border-radius: var(--radius-pill);
font-size: var(--text-11);
font-weight: var(--weight-strong);
line-height: var(--leading-snug);
white-space: nowrap;
}
.neutral {
border-color: var(--line);
background: var(--surface-hover);
color: var(--ink-2);
}
.accent {
border-color: var(--accent-line);
background: var(--accent-tint);
color: var(--accent);
}
.info {
border-color: var(--info-line);
background: var(--info-tint);
color: var(--info);
}
.warn {
border-color: var(--warn-line);
background: var(--warn-tint);
color: var(--warn);
}
.crit {
border-color: var(--crit-line);
background: var(--crit-tint);
color: var(--crit);
}
/* A mark beside the colour, so the tone survives greyscale and colour
blindness. The text is still the announcement. */
.dot {
width: 6px;
height: 6px;
flex: none;
border-radius: var(--radius-pill);
background: currentColor;
}
}label · value · hint — unmeasured is not zero
A missing value is a dash, never 0. “Errors today” measured zero; “Churn” was not measured, and is read as “Not measured”.
components/display/stat/doc.ts
/**
* Stat — one headline number with its label.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* label content, REQUIRED
* value? content — undefined means "not measured"
* hint? content — a comparison or a unit
*
* # Behaviour
*
* R1 The label sits above the value in the uppercase label style; the value
* is `--text-30` in the display face with tabular figures.
* R2 An undefined value renders as a dash in `--ink-4` and is read as "Not
* measured". It never renders as 0: a zero is a measurement, and showing
* one for an unmeasured total asserts something nobody checked.
*/
export {};components/display/stat/stat.tsx
import type { ReactNode } from "react";
import { VisuallyHidden } from "@/components/utility/visually-hidden";
import { cn } from "@/lib/utils/cn";
import styles from "./stat.module.css";
export type StatProps = {
label: ReactNode;
/** `undefined` means nobody measured it, and renders as a dash rather than
* 0: a zero is a measurement, a dash is the absence of one. */
value?: ReactNode;
hint?: ReactNode;
className?: string;
};
export function Stat({ label, value, hint, className }: StatProps) {
const unmeasured = value === undefined;
return (
<div className={cn(styles.root, className)}>
<span className={styles.label}>{label}</span>
<span className={cn(styles.value, unmeasured && styles.unmeasured)}>
{unmeasured ? (
<>
<span aria-hidden="true">–</span>
<VisuallyHidden>Not measured</VisuallyHidden>
</>
) : (
value
)}
</span>
{hint ? <span className={styles.hint}>{hint}</span> : null}
</div>
);
}components/display/stat/stat.module.css
@layer primitive {
.root {
display: grid;
min-width: 0;
gap: var(--space-2);
}
.label {
color: var(--ink-3);
font-size: var(--text-11);
font-weight: var(--weight-strong);
letter-spacing: var(--tracking-label);
text-transform: uppercase;
}
.value {
color: var(--ink);
font-family: var(--font-display, var(--font-sans));
font-size: var(--text-30);
font-weight: var(--heading-weight, var(--weight-bold));
font-variant-numeric: tabular-nums;
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
}
.unmeasured {
color: var(--ink-4);
font-weight: var(--weight-regular);
}
.hint {
color: var(--ink-3);
font-size: var(--text-12);
}
}name · src · size
Alex Kim
Owner
components/display/avatar/doc.ts
/**
* Avatar — a person, as an image or initials.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* name string, REQUIRED
* src? image URL
* size? "sm" (20px) | "md" (28px) | "lg" (40px) default "md"
*
* # Behaviour
*
* R1 With `src`, the image fills a circle and `name` is its alternative
* text.
* R2 Without it, up to two initials from `name` show on `--accent-tint`;
* the initials are hidden from assistive technology and `name` is read
* instead, so the avatar always identifies someone.
* R3 `name` also shows as a tooltip on hover.
*/
export {};components/display/avatar/avatar.tsx
import { VisuallyHidden } from "@/components/utility/visually-hidden";
import { cn } from "@/lib/utils/cn";
import styles from "./avatar.module.css";
import { avatarVariants, type AvatarVariants } from "./avatar.variants";
export type AvatarProps = AvatarVariants & {
/** The person's name. Required even with an image: it is the image's
* alternative text and the source of the initials. */
name: string;
src?: string;
className?: string;
};
export const initials = (name: string) =>
name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((word) => word[0] ?? "")
.join("")
.toUpperCase();
export function Avatar({ name, src, size, className }: AvatarProps) {
return (
<span className={cn(avatarVariants({ size }), className)} title={name}>
{src ? (
// A plain img keeps this portable to the Svelte twin; swap in the
// framework's optimised image here if a product needs it.
// eslint-disable-next-line @next/next/no-img-element
<img className={styles.image} src={src} alt={name} />
) : (
<>
<span aria-hidden="true">{initials(name)}</span>
<VisuallyHidden>{name}</VisuallyHidden>
</>
)}
</span>
);
}components/display/avatar/avatar.variants.ts
import { cva, type VariantProps } from "class-variance-authority";
import styles from "./avatar.module.css";
export const avatarVariants = cva(styles.root, {
variants: { size: { sm: styles.sm, md: styles.md, lg: styles.lg } },
defaultVariants: { size: "md" },
});
export type AvatarVariants = VariantProps<typeof avatarVariants>;components/display/avatar/avatar.module.css
@layer primitive {
.root {
position: relative;
display: inline-grid;
flex: none;
place-items: center;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
background: var(--accent-tint);
color: var(--accent);
font-weight: var(--weight-strong);
user-select: none;
}
.sm {
width: 20px;
height: 20px;
font-size: var(--text-9);
}
.md {
width: 28px;
height: 28px;
font-size: var(--text-11);
}
.lg {
width: 40px;
height: 40px;
font-size: var(--text-15);
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
}
}terms and values
components/display/description-list/doc.ts
/**
* DescriptionList — terms and their values.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* DescriptionList the list
* DescriptionItem term, REQUIRED; children are the value
*
* # Behaviour
*
* R1 Each pair is one row: the term in `--ink-3`, the value in `--ink`,
* separated from the next pair by a `--line` rule.
* R2 Below 480px the term stacks above its value.
* R3 Semantically a description list, so readers announce each term with
* its value.
*/
export {};components/display/description-list/description-list.tsx
import type { ComponentPropsWithRef, ReactNode } from "react";
import { cn } from "@/lib/utils/cn";
import styles from "./description-list.module.css";
export function DescriptionList({
className,
...props
}: ComponentPropsWithRef<"dl">) {
return <dl {...props} className={cn(styles.root, className)} />;
}
export type DescriptionItemProps = ComponentPropsWithRef<"div"> & {
term: ReactNode;
};
/** One term and its value. A div around each pair is valid inside a dl and
* keeps the pair on one row. */
export function DescriptionItem({
term,
className,
children,
...props
}: DescriptionItemProps) {
return (
<div {...props} className={cn(styles.item, className)}>
<dt className={styles.term}>{term}</dt>
<dd className={styles.value}>{children}</dd>
</div>
);
}components/display/description-list/description-list.module.css
@layer primitive {
.root {
margin: 0;
}
.item {
display: grid;
grid-template-columns: minmax(8rem, 1fr) minmax(0, 2fr);
gap: var(--space-7);
padding-block: var(--space-5);
border-bottom: 1px solid var(--line);
font-size: var(--text-body, var(--text-13));
line-height: var(--leading-body);
}
.item:last-child {
border-bottom: 0;
}
.term {
color: var(--ink-3);
}
.value {
min-width: 0;
margin: 0;
color: var(--ink);
overflow-wrap: anywhere;
}
@media (max-width: 480px) {
.item {
grid-template-columns: 1fr;
gap: var(--space-2);
}
}
}nothing here, said calmly
No members yet
Invite people by email. They join this workspace as members.
components/display/empty/doc.ts
/**
* Empty — a successful answer of "nothing here".
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* title content, REQUIRED — what is absent, said plainly
* children? one or two sentences on why, or what fills it
* action? the thing to do about it
*
* # Behaviour
*
* R1 Centred, calm, in ink and muted tones. Never styled as an error: an
* empty list is a working system, and red teaches people it is broken.
* R2 The body text is capped at 44 characters per line.
*/
export {};components/display/empty/empty.tsx
import type { ReactNode } from "react";
import { cn } from "@/lib/utils/cn";
import styles from "./empty.module.css";
export type EmptyProps = {
/** What is absent, said plainly: "No members yet", not "No data". */
title: ReactNode;
children?: ReactNode;
/** The thing to do about it, when there is one. */
action?: ReactNode;
className?: string;
};
/** A successful answer of "nothing here". Styled as calm content, never as an
* error: an empty list is a working system. */
export function Empty({ title, children, action, className }: EmptyProps) {
return (
<div className={cn(styles.root, className)}>
<p className={styles.title}>{title}</p>
{children ? <p className={styles.body}>{children}</p> : null}
{action ? <div className={styles.action}>{action}</div> : null}
</div>
);
}components/display/empty/empty.module.css
@layer primitive {
.root {
display: grid;
justify-items: center;
gap: var(--space-4);
padding: var(--space-9) var(--space-7);
color: var(--ink-3);
text-align: center;
}
.title {
margin: 0;
color: var(--ink);
font-size: var(--text-15);
font-weight: var(--weight-strong);
}
.body {
max-width: 44ch;
margin: 0;
font-size: var(--text-body, var(--text-13));
line-height: var(--leading-body);
}
.action {
margin-top: var(--space-3);
}
}