Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Changelog

All notable changes to `@lumyx/ui`. Versions follow [SemVer](https://semver.org/); before 1.0 a
minor bump may break the API or the look. Releases are cut from `main` with `npm version`
(see Releasing in the README) and tagged `vX.Y.Z`.

## 0.4.0

The v3 visual language, from the Cloud UI v3 and Dashboard UI v3 designs. See "Migrating from 0.3"
in the README.

### Breaking

- Token values move to true greys, an ink primary, `#16a34a` / `#d97706` / `#e5484d` semantics and
the coral `#ff8f70` second series. Token and class names are unchanged.
- Radii become `xs` 6 · `sm` 8 · `md` 12 · `lg` 16 (were 8 · 12 · 14 · 18).
- Body text is 14/20 and line heights sit on a 4px grid. `sl-num` sets Geist Mono; `sl-label` is
12px sentence case.
- `Button` `primary` is ink; the indigo button is `accent`.
- `Card` is flat at rest; `CardHeader` has no rule and pads 16px.
- `TabsList` is segmented by default (`variant="underline"` for the old look).
- `MetricCard` is a card of its own and `MetricGrid` a gapped grid.
- Focus is a 2px accent outline instead of a 3px ring.

### Added

- Chrome: `TopBar`, `PageHeader`, `SidebarBrand`, `SidebarPanel`; `SidebarNav` items take `count`
and `dot`, and the nav takes an `isActive` matcher.
- Controls: `SegmentedControl`, `SearchField`, `UnitInput`; `Button` `danger-outline`; `Badge`
`shape="chip"`; `Card` `interactive`; `Progress` `projected`.
- Data viz: `StackedAreaChart`, `ChartLegend`, `LegendItem`, `TimeAxis`, `linePath` / `areaPath` /
`bandPath`; `TimeSeriesChart` gains `area`, `dashed`, `thresholds`, `band`, `playhead`,
`playheadColor`, `max`, `yAxis` and `legend`, and draws its labels in HTML so they keep their
shape at any width.
- Tones: `Tone` (incl. `muted`), `toneText` / `toneBg` / `toneVar`, `thresholdTone` (with a
`healthy` tone for in-range values), `scoreTone`, `Dot`,
`ScoreBar`, `EventFeed`, `InsetStat`, `MeterRow`; `MetricCard` `sub`, `subState`, `progress`,
`bare`; `AlertBanner` `icon`, `action`, `onDismiss`; `CodeBlock`.
- Tokens: `ink`, `on-ink`, `on-ink-accent`, `on-ink-border`, `chrome`, `chrome-border`,
`stroke-hover`, `accent-subtle`, `accent-soft`, `accent-muted`, `{ok,warn,danger,info}-border`,
`rounded-2xs`, type sizes 24 / 30 / 36, durations 300 / 400 / 500 / 600.

### Deprecated

- `AppHeader` (use `TopBar` + `PageHeader`) and `MetricCard` `threshold` (use `sub`).

## 0.3.0

- **Breaking:** links go through `LumyxLinkProvider`; the package no longer depends on Next.js.
- Components aligned with the design system tokens; release flow with tagged GitHub releases.
43 changes: 41 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ The Lumyx design system — React components and Tailwind CSS v4 tokens, built o

```bash
bun add @lumyx/ui
# until the first npm release:
bun add github:lumyx-dev/ui
# until the first npm release, pin a tag — never the moving default branch:
bun add github:lumyx-dev/ui#v0.4.0
```

Peer dependencies: `react@^19` and `react-dom@^19`. The package does not depend on any
Expand Down Expand Up @@ -41,6 +41,9 @@ Import the tokens and point Tailwind at the component sources so their classes a

Adjust the `@source` path to where `node_modules` sits relative to the CSS file.

Numbers and identifiers use Geist Mono through `--font-mono` (`sl-num`). With Next.js, load
both families with `next/font` and expose them as `--font-geist-sans` and `--font-geist-mono`.

## Usage

```tsx
Expand All @@ -56,6 +59,42 @@ export function Example() {
}
```

## Migrating from 0.3

0.4 moves the system to the v3 visual language (Cloud UI v3, Dashboard UI v3). Token and
class names are unchanged, their values are not:

- **Palette.** True greys (`#fafafa` page, `#eaeaea` card stroke, `#0a0a0a` ink); semantic inks
are `#16a34a` / `#d97706` / `#e5484d`. New tokens: `bg-ink` / `text-on-ink`, `bg-chrome`,
`border-stroke-hover`, `border-chrome-border`, `bg-accent-subtle`, `bg-accent-soft`,
`border-{ok,warn,danger,info}-border`.
- **Radii.** `xs` 6 · `sm` 8 · `md` 12 · `lg` 16 (were 8 · 12 · 14 · 18).
- **Type.** Body is 14/20; `text-24`, `text-30`, `text-36` join the scale; line heights sit on a
4px grid. `sl-num` now switches to Geist Mono (use `sl-tnum` for tabular sans);
`sl-label` is 12px sentence case, no longer uppercase.
- **Motion.** One curve, `cubic-bezier(0.32,0.72,0,1)`; 300 / 400 / 500 / 600ms are allowed.
Focus is a 2px accent outline offset by 2px.
- **Button.** `primary` is ink. The former indigo primary is `accent`. New `danger-outline`.
- **Badge.** Pills by default; `shape="chip"` for mono machine values.
- **Card.** Flat at rest, `interactive` lifts on hover; header and content pad 16px.
- **Tabs.** `TabsList` is segmented by default; pass `variant="underline"` for the old look.
`SegmentedControl` covers toggles that are not navigation.
- **Metrics.** `MetricCard` is a card of its own (`bare` drops the frame) with `sub`,
`subState` and an inline 64×20 sparkline; `MetricGrid` is a gapped responsive grid.
- **Chrome.** `TopBar` (row 1) and `PageHeader` (inside `PageBody`) replace `AppHeader`,
which still renders but is deprecated. `SidebarBrand` and `SidebarPanel` build the
sidebar head and foot; `SidebarNav` items take `count` and `dot`.
- **Feedback.** `AlertBanner` takes `icon`, `action` and `onDismiss`; `CodeBlock` is new.
- **Data viz.** `TimeSeriesChart` takes per-series `area` and `dashed`, `thresholds`, a `band`
(p50–p95), a `playhead`, a fixed `max`, and `legend={false}` to move the legend into the card
header with `ChartLegend`. New: `StackedAreaChart`, `LegendItem`, `TimeAxis`, and the
`linePath` / `areaPath` / `bandPath` helpers for hand-drawn charts.
- **Tones.** `Tone` with `toneText` / `toneBg` / `toneVar`, `thresholdTone` and `scoreTone`.
`Dot` is a still tone dot (`StatusDot` breathes while live). `ScoreBar`, `EventFeed` (two-line),
`InsetStat`, `MeterRow`, `SearchField`, `UnitInput`; `MetricCard` takes `progress`.
- **Tokens added.** `accent-muted` (second indigo step), `text-on-ink-accent`, `on-ink-border`,
and a 4px `rounded-2xs`.

## Routing

`SidebarNav`, `AppHeader` breadcrumbs and `RoomCard` render internal links. By default they are
Expand Down
5 changes: 3 additions & 2 deletions scripts/verify-ds.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,9 @@ scanClasses(
new RegExp(`(?<![\\w-])(?:${COLOR_UTILS})-(?:black|white|(?:${PALETTE})-\\d{2,3})(?![\\w-])`, 'g'),
);

// 9. Durees de motion : 120ms teintes · 180ms par defaut · 260ms taille & position (styles.css).
const DURATIONS = new Set(['0', '120', '180', '260']);
// 9. Durees de motion (styles.css) : 120 / 180 / 260 pour les micro-teintes et les tailles,
// 300 lignes · 400 controles · 500 lift de carte · 600 entree d'ecran (v3).
const DURATIONS = new Set(['0', '120', '180', '260', '300', '400', '500', '600']);
scanClasses('motion-duration', /(?<![\w-])duration-(\d+|\[[^\]]*\])/g, (m) => DURATIONS.has(m[1]));

if (failures.length) {
Expand Down
5 changes: 3 additions & 2 deletions scripts/verify-ds.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,13 @@ describe('verify-ds regle 8 — couleurs par token', () => {
});

describe('verify-ds regle 9 — durees de motion', () => {
test('accepte 120 / 180 / 260', () => {
test('accepte 120 / 180 / 260 et 300 / 400 / 500 / 600', () => {
expect(runWith('export const A = () => <p className="duration-120 hover:duration-260" />;\n').code).toBe(0);
expect(runWith('export const A = () => <p className="duration-300 hover:duration-400 duration-500 duration-600" />;\n').code).toBe(0);
});

test('refuse une duree hors systeme', () => {
const { code, output } = runWith('export const A = () => <p className="duration-300" />;\n');
const { code, output } = runWith('export const A = () => <p className="duration-200" />;\n');
expect(code).toBe(1);
expect(output).toContain('motion-duration');
});
Expand Down
46 changes: 11 additions & 35 deletions src/components/lumyx/app-header.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
"use client";
import * as React from "react";
import { useLumyxLink } from "../../lib/link";
import { cn } from "../../lib/utils";
import { SidebarTrigger } from "../ui/sidebar";
import { TopBar, type Crumb } from "./top-bar";

export type Crumb = { href?: string; label: string };
export type { Crumb } from "./top-bar";

/**
* Ligne 1 de la grille du SidebarInset. Rendu par la page, pas par le chrome :
* le titre vit avec les donnees qui le remplissent.
* @deprecated since 0.4.0 — compose `TopBar` (row 1) with `PageHeader` (inside `PageBody`).
* Kept so 0.3 call sites still render: the title becomes the last breadcrumb, `meta` and
* `actions` sit on the right of the bar.
*/
export function AppHeader({
breadcrumb,
Expand All @@ -23,35 +22,12 @@ export function AppHeader({
actions?: React.ReactNode;
className?: string;
}) {
const { Link } = useLumyxLink();
const crumbs: Crumb[] = [...(breadcrumb ?? []), { label: typeof title === "string" ? title : "" }].filter((c) => c.label);
return (
<header className={cn("row-start-1 border-b border-hairline bg-card", className)}>
<div className="mx-auto flex w-full max-w-[1360px] items-center justify-between gap-6 px-4 py-4 lg:px-8">
<div className="flex min-w-0 items-center gap-3">
<SidebarTrigger className="lg:hidden" />
<div className="flex min-w-0 flex-col gap-0.5">
{breadcrumb?.length ? (
<nav aria-label="Breadcrumb" className="flex items-center gap-1.5 text-12 text-muted">
{breadcrumb.map((crumb, i) => (
<React.Fragment key={`${crumb.label}-${i}`}>
{i > 0 ? <span aria-hidden="true" className="text-faint">/</span> : null}
{crumb.href ? (
<Link href={crumb.href} className="text-muted no-underline hover:text-strong">
{crumb.label}
</Link>
) : (
<span aria-current="page">{crumb.label}</span>
)}
</React.Fragment>
))}
</nav>
) : null}
<h1 className="truncate text-20 font-semibold text-strong">{title}</h1>
{meta ? <div className="truncate text-12 text-muted">{meta}</div> : null}
</div>
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
</header>
<TopBar breadcrumb={crumbs} className={className}>
{typeof title !== "string" ? <span className="text-14 font-semibold text-strong">{title}</span> : null}
{meta ? <span className="hidden truncate text-12 text-faint md:inline">{meta}</span> : null}
{actions}
</TopBar>
);
}
31 changes: 31 additions & 0 deletions src/components/lumyx/chart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { areaPath, linePath } from "./chart";
import { scoreTone, thresholdTone } from "./tone";

describe("linePath / areaPath", () => {
test("maps values into the box, max at the top", () => {
expect(linePath([0, 50, 100], 100, 200, 100)).toBe("M0.0 100.0 L100.0 50.0 L200.0 0.0");
});
test("clamps values outside [0, max]", () => {
expect(linePath([-10, 150], 100, 100, 100)).toBe("M0.0 100.0 L100.0 0.0");
});
test("closes the area down to the baseline", () => {
expect(areaPath([0, 100], 100, 100, 50)).toBe("M0.0 50.0 L100.0 0.0 L100 50 L0 50 Z");
});
});

describe("tones", () => {
test("thresholdTone: higher is worse", () => {
expect(thresholdTone(1, 2, 5)).toBe("ok");
expect(thresholdTone(2, 2, 5)).toBe("warn");
expect(thresholdTone(5, 2, 5)).toBe("danger");
expect(thresholdTone(1, 2, 5, "neutral")).toBe("neutral");
});
test("scoreTone: lower is worse, defaults 80 / 50, overridable", () => {
expect(scoreTone(90)).toBe("ok");
expect(scoreTone(79)).toBe("warn");
expect(scoreTone(49)).toBe("danger");
expect(scoreTone(65, 70, 40)).toBe("warn");
expect(scoreTone(72, 70, 40)).toBe("ok");
});
});
115 changes: 115 additions & 0 deletions src/components/lumyx/chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as React from "react";
import { cn } from "../../lib/utils";

/** SVG path through `data` in a W×H box, `max` at the top, values clamped to [0, max]. */
export const linePath = (data: number[], max: number, W: number, H: number) =>
data
.map((v, i) => `${i ? "L" : "M"}${((i * W) / Math.max(data.length - 1, 1)).toFixed(1)} ${(H - (Math.min(max, Math.max(0, v)) / (max || 1)) * H).toFixed(1)}`)
.join(" ");

/** The same path closed down to the baseline, for a 6–8% area fill. */
export const areaPath = (data: number[], max: number, W: number, H: number) => `${linePath(data, max, W, H)} L${W} ${H} L0 ${H} Z`;

/** A band between two series (p50–p95, min–max), closed along `lo` reversed. */
export const bandPath = (lo: number[], hi: number[], max: number, W: number, H: number) => {
const back = lo
.map((v, i) => [i, v] as const)
.reverse()
.map(([i, v]) => `L${((i * W) / Math.max(lo.length - 1, 1)).toFixed(1)} ${(H - (Math.min(max, Math.max(0, v)) / (max || 1)) * H).toFixed(1)}`)
.join(" ");
return `${linePath(hi, max, W, H)} ${back} Z`;
};

export type LegendSwatch = "line" | "dashed" | "square";

/** One legend entry: a 12px line, a dashed line (threshold, pace) or a 8px square (stacked band). */
export function LegendItem({
color, label, swatch = "line", mono = false,
}: { color: string; label: React.ReactNode; swatch?: LegendSwatch; mono?: boolean }) {
return (
<span className="flex items-center gap-2 text-12 text-muted">
<span
aria-hidden
className={cn(swatch === "square" ? "size-2 rounded-2xs" : swatch === "dashed" ? "h-0 w-3 border-t-2 border-dashed" : "h-0.5 w-3")}
style={swatch === "dashed" ? { borderColor: color } : { background: color }}
/>
<span className={cn(mono && "sl-num")}>{label}</span>
</span>
);
}

/** A row of legend entries, usually placed in a card header next to the title. */
export function ChartLegend({
items, swatch, className,
}: { items: { name: React.ReactNode; color: string; swatch?: LegendSwatch; mono?: boolean }[]; swatch?: LegendSwatch; className?: string }) {
return (
<span className={cn("flex flex-wrap items-center gap-4", className)}>
{items.map((it, i) => (
<LegendItem key={i} color={it.color} label={it.name} swatch={it.swatch ?? swatch} mono={it.mono} />
))}
</span>
);
}

/** Mono x-axis labels spread evenly under a chart. */
export function TimeAxis({ labels, className }: { labels: React.ReactNode[]; className?: string }) {
return (
<div className={cn("sl-num mt-2 flex justify-between text-12 text-faint", className)}>
{labels.map((t, i) => <span key={i}>{t}</span>)}
</div>
);
}

/**
* Stacked areas, one band per series (bottom band first), with an optional dashed ink line for
* the pace to hold, given as fractions of the height from the top at the left and right edges.
*/
export function StackedAreaChart({
series, colors, max, height = 200, pace, xLabels = [], label, className,
}: {
series: number[][];
colors: string[];
max: number;
height?: number;
pace?: [number, number];
xLabels?: React.ReactNode[];
label: string;
className?: string;
}) {
const W = 600;
const H = height;
const n = series[0]?.length ?? 0;
const x = (i: number) => ((i * W) / Math.max(n - 1, 1)).toFixed(1);
const y = (v: number) => (H - (v / (max || 1)) * H).toFixed(1);

const acc = new Array<number>(n).fill(0);
const bands = series.map((data, k) => {
const lo = acc.slice();
data.forEach((v, i) => { acc[i] += v; });
const top = acc.map((v, i) => `${i ? "L" : "M"}${x(i)} ${y(v)}`).join(" ");
const bottom = lo.map((_, i) => `L${x(n - 1 - i)} ${y(lo[n - 1 - i])}`).join(" ");
return { d: `${top} ${bottom} Z`, color: colors[k] ?? "var(--series-5)", key: k };
});

return (
<div className={className}>
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label={label} className="block w-full" style={{ height: H }}>
<path d={[0.25, 0.5, 0.75].map((f) => `M0 ${H * f}H${W}`).join("")} stroke="var(--border-subtle)" vectorEffect="non-scaling-stroke" />
{[...bands].reverse().map((b) => (
<path key={b.key} d={b.d} fill={b.color} fillOpacity={0.9} />
))}
{pace ? (
<path
d={`M0 ${H * pace[0]} L${W} ${H * pace[1]}`}
stroke="var(--surface-ink)"
strokeWidth={1.5}
strokeDasharray="6 6"
fill="none"
vectorEffect="non-scaling-stroke"
/>
) : null}
</svg>
{xLabels.length ? <TimeAxis labels={xLabels} /> : null}
</div>
);
}
17 changes: 17 additions & 0 deletions src/components/lumyx/dot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { cn } from "../../lib/utils";
import { toneBg, type Tone } from "./tone";

/**
* A static dot in a tone. `StatusDot` is for a connection state and breathes while live; most
* dots in a table or a feed only mark a tone and must stay still.
*/
export function Dot({ tone = "idle", size = "md", label, className }: { tone?: Tone; size?: "sm" | "md"; label?: string; className?: string }) {
return (
<span
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn("inline-block shrink-0 rounded-pill", size === "sm" ? "size-1.5" : "size-2", toneBg[tone], className)}
/>
);
}
27 changes: 27 additions & 0 deletions src/components/lumyx/event-feed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as React from "react";
import { cn } from "../../lib/utils";
import { Dot } from "./dot";
import type { Tone } from "./tone";

export type FeedEvent = { time: string; message: React.ReactNode; detail?: React.ReactNode; tone?: Tone };

/**
* Two-line event feed: a still dot, the fact, its identifier in mono underneath, the time on the
* right. `EventList` stays the dense one-line variant.
*/
export function EventFeed({ events, className }: { events: FeedEvent[]; className?: string }) {
return (
<ul className={cn("flex flex-col", className)}>
{events.map((e, i) => (
<li key={`${e.time}-${i}`} className="grid grid-cols-[16px_minmax(0,1fr)_auto] gap-2 py-2">
<Dot tone={e.tone ?? "idle"} className="mt-1.5" />
<span className="flex min-w-0 flex-col">
<span className="text-14 font-medium text-strong">{e.message}</span>
{e.detail != null ? <span className="sl-num truncate text-12 text-faint">{e.detail}</span> : null}
</span>
<span className="sl-num text-12 leading-5 text-faint">{e.time}</span>
</li>
))}
</ul>
);
}
Loading
Loading