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.
src/lib/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 {};src/lib/components/display/card/Card.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import { cardVariants, type CardVariants } from './card.variants';
export type CardProps = Omit<HTMLAttributes<HTMLElement>, 'class'> &
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';
class?: string;
children?: Snippet;
};
let { as = 'article', elevation, class: className = '', children, ...rest }: CardProps = $props();
</script>
<svelte:element this={as} {...rest} class={cn(cardVariants({ elevation }), className)}>
{@render children?.()}
</svelte:element>src/lib/components/display/card/CardHeader.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import styles from './card.module.css';
let {
actions,
class: className = '',
children,
...rest
}: Omit<HTMLAttributes<HTMLDivElement>, 'class'> & {
/** Controls at the end of the header: a menu, a status, a link. */
actions?: Snippet;
class?: string;
children?: Snippet;
} = $props();
</script>
<div {...rest} class={cn(styles.header, className)}>
<div class={styles.heading}>{@render children?.()}</div>
{#if actions}<div class={styles.actions}>{@render actions()}</div>{/if}
</div>src/lib/components/display/card/CardTitle.svelte
<script lang="ts">
import Heading, { type HeadingProps } from '$lib/components/typography/heading/Heading.svelte';
/* A heading, level 3 unless the page outline needs another. */
let {
level = 3,
size = 'sm',
...rest
}: Omit<HeadingProps, 'level'> & { level?: HeadingProps['level'] } = $props();
</script>
<Heading {...rest} {level} {size} />src/lib/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>;src/lib/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
src/lib/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 {};src/lib/components/display/badge/Badge.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import styles from './badge.module.css';
import { badgeVariants, type BadgeVariants } from './badge.variants';
export type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, 'class'> &
BadgeVariants & {
/** A dot before the text, so the tone does not rest on colour alone. */
dot?: boolean;
class?: string;
children?: Snippet;
};
let { tone, dot = false, class: className = '', children, ...rest }: BadgeProps = $props();
</script>
<span {...rest} class={cn(badgeVariants({ tone }), className)}>
{#if dot}<span class={styles.dot} aria-hidden="true"></span>{/if}
{@render children?.()}
</span>src/lib/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>;src/lib/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”.
src/lib/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 {};src/lib/components/display/stat/Stat.svelte
<script lang="ts">
import { VisuallyHidden } from '$lib/components/utility/visually-hidden';
import { cn } from '$lib/utils/cn';
import styles from './stat.module.css';
export type StatProps = {
label: string;
/** `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?: string | number;
hint?: string;
class?: string;
};
let { label, value, hint, class: className = '' }: StatProps = $props();
const unmeasured = $derived(value === undefined);
</script>
<div class={cn(styles.root, className)}>
<span class={styles.label}>{label}</span>
<span class={cn(styles.value, unmeasured && styles.unmeasured)}>
{#if unmeasured}<span aria-hidden="true">–</span><VisuallyHidden>Not measured</VisuallyHidden
>{:else}{value}{/if}
</span>
{#if hint}<span class={styles.hint}>{hint}</span>{/if}
</div>src/lib/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
src/lib/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 {};src/lib/components/display/avatar/Avatar.svelte
<script lang="ts">
import { VisuallyHidden } from '$lib/components/utility/visually-hidden';
import { cn } from '$lib/utils/cn';
import { initials } from './avatar';
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;
class?: string;
};
let { name, src, size, class: className = '' }: AvatarProps = $props();
</script>
<span class={cn(avatarVariants({ size }), className)} title={name}>
{#if src}
<img class={styles.image} {src} alt={name} />
{:else}
<span aria-hidden="true">{initials(name)}</span>
<VisuallyHidden>{name}</VisuallyHidden>
{/if}
</span>src/lib/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>;src/lib/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
src/lib/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 {};src/lib/components/display/description-list/DescriptionList.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import styles from './description-list.module.css';
let {
class: className = '',
children,
...rest
}: Omit<HTMLAttributes<HTMLDListElement>, 'class'> & {
class?: string;
children?: Snippet;
} = $props();
</script>
<dl {...rest} class={cn(styles.root, className)}>{@render children?.()}</dl>src/lib/components/display/description-list/DescriptionItem.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import styles from './description-list.module.css';
/* One term and its value. A div around each pair is valid inside a dl and
keeps the pair on one row. */
let {
term,
class: className = '',
children,
...rest
}: Omit<HTMLAttributes<HTMLDivElement>, 'class'> & {
term: string;
class?: string;
children?: Snippet;
} = $props();
</script>
<div {...rest} class={cn(styles.item, className)}>
<dt class={styles.term}>{term}</dt>
<dd class={styles.value}>{@render children?.()}</dd>
</div>src/lib/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.
src/lib/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 {};src/lib/components/display/empty/Empty.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils/cn';
import styles from './empty.module.css';
/* A successful answer of "nothing here". Styled as calm content, never as
an error: an empty list is a working system. */
let {
title,
action,
class: className = '',
children
}: {
/** What is absent, said plainly: "No members yet", not "No data". */
title: string;
/** The thing to do about it, when there is one. */
action?: Snippet;
class?: string;
children?: Snippet;
} = $props();
</script>
<div class={cn(styles.root, className)}>
<p class={styles.title}>{title}</p>
{#if children}<p class={styles.body}>{@render children()}</p>{/if}
{#if action}<div class={styles.action}>{@render action()}</div>{/if}
</div>src/lib/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);
}
}