diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..8e95a4a
--- /dev/null
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index 61c3305..42e1d45 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
@@ -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
diff --git a/scripts/verify-ds.mjs b/scripts/verify-ds.mjs
index fdfb321..204fb82 100644
--- a/scripts/verify-ds.mjs
+++ b/scripts/verify-ds.mjs
@@ -153,8 +153,9 @@ scanClasses(
new RegExp(`(? DURATIONS.has(m[1]));
if (failures.length) {
diff --git a/scripts/verify-ds.test.mjs b/scripts/verify-ds.test.mjs
index b38e9be..af0d364 100644
--- a/scripts/verify-ds.test.mjs
+++ b/scripts/verify-ds.test.mjs
@@ -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 = () =>
;\n').code).toBe(0);
+ expect(runWith('export const A = () => ;\n').code).toBe(0);
});
test('refuse une duree hors systeme', () => {
- const { code, output } = runWith('export const A = () => ;\n');
+ const { code, output } = runWith('export const A = () => ;\n');
expect(code).toBe(1);
expect(output).toContain('motion-duration');
});
diff --git a/src/components/lumyx/app-header.tsx b/src/components/lumyx/app-header.tsx
index 34e1b53..d8ed315 100644
--- a/src/components/lumyx/app-header.tsx
+++ b/src/components/lumyx/app-header.tsx
@@ -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,
@@ -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 (
-
-
-
-
-
- {breadcrumb?.length ? (
-
- ) : null}
-
{title}
- {meta ?
{meta}
: null}
-
-
- {actions ?
{actions}
: null}
-
-
+
+ {typeof title !== "string" ? {title} : null}
+ {meta ? {meta} : null}
+ {actions}
+
);
}
diff --git a/src/components/lumyx/chart.test.ts b/src/components/lumyx/chart.test.ts
new file mode 100644
index 0000000..dcb0361
--- /dev/null
+++ b/src/components/lumyx/chart.test.ts
@@ -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");
+ });
+});
diff --git a/src/components/lumyx/chart.tsx b/src/components/lumyx/chart.tsx
new file mode 100644
index 0000000..523ef68
--- /dev/null
+++ b/src/components/lumyx/chart.tsx
@@ -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 (
+
+
+ {label}
+
+ );
+}
+
+/** 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 (
+
+ {items.map((it, i) => (
+
+ ))}
+
+ );
+}
+
+/** Mono x-axis labels spread evenly under a chart. */
+export function TimeAxis({ labels, className }: { labels: React.ReactNode[]; className?: string }) {
+ return (
+
+ {labels.map((t, i) => {t})}
+
+ );
+}
+
+/**
+ * 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(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 (
+
+
+ {xLabels.length ? : null}
+
+ );
+}
diff --git a/src/components/lumyx/dot.tsx b/src/components/lumyx/dot.tsx
new file mode 100644
index 0000000..bc0f7cd
--- /dev/null
+++ b/src/components/lumyx/dot.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/lumyx/event-feed.tsx b/src/components/lumyx/event-feed.tsx
new file mode 100644
index 0000000..fa39944
--- /dev/null
+++ b/src/components/lumyx/event-feed.tsx
@@ -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 (
+
);
@@ -50,29 +64,39 @@ export function AlertBanner({
/** Name what is absent and how data arrives. No "Oops", no exclamation marks. */
export function EmptyState({
- icon: Icon, title, body, action,
-}: { icon?: React.ElementType; title: string; body?: string; action?: React.ReactNode }) {
+ icon: Icon, title, body, action, className,
+}: { icon?: React.ElementType; title: string; body?: string; action?: React.ReactNode; className?: string }) {
return (
-
+
{Icon ? (
-
-
+
+
) : null}
{title}
- {body ?
{body}
: null}
- {action}
+ {body ?
{body}
: null}
+ {action ?
{action}
: null}
);
}
-/** A failure surfaces the code and the verbatim message in an inset block. */
+/** A failure surfaces the code and the verbatim message in an ink block. */
export function ErrorState({ code, message, onRetry }: { code: string; message: string; onRetry?: () => void }) {
return (
-
+
{code}
-
{message}
- {onRetry ? : null}
+
{message}
+ {onRetry ? : null}
);
}
+
+/** Code shown to be copied — token snippets, payloads. Ink block, mono, 13/20. */
+export function CodeBlock({ className, children }: { className?: string; children: React.ReactNode }) {
+ return (
+
{children}
+ );
+}
+
+// Re-exported for call sites that drew their own critical glyph.
+export const CriticalIcon = XCircle;
diff --git a/src/components/lumyx/index.ts b/src/components/lumyx/index.ts
index a9d8546..d846bf6 100644
--- a/src/components/lumyx/index.ts
+++ b/src/components/lumyx/index.ts
@@ -14,3 +14,11 @@ export * from "./sidebar-nav";
export * from "./app-header";
export * from "./page-body";
export * from "./status-strip";
+export * from "./top-bar";
+export * from "./sidebar-brand";
+export * from "./tone";
+export * from "./dot";
+export * from "./score-bar";
+export * from "./event-feed";
+export * from "./stats";
+export * from "./chart";
diff --git a/src/components/lumyx/metric.tsx b/src/components/lumyx/metric.tsx
index 8644552..c073ebb 100644
--- a/src/components/lumyx/metric.tsx
+++ b/src/components/lumyx/metric.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import { cn } from '../../lib/utils';
import { Sparkline } from "./sparkline";
+import { Progress } from "../ui/progress";
export type MetricState = "ok" | "warn" | "danger" | "neutral";
@@ -11,51 +12,77 @@ const INK: Record = {
neutral: "text-strong",
};
-/** `sm` is for metrics packed several-to-a-card, where 34px runs out of column. */
+/** `lg` is a KPI card (24/32), `sm` a metric packed several to a card (20/28). */
const SIZE = {
- lg: { value: "text-34", unit: "text-14" },
- sm: { value: "text-20", unit: "text-13" },
+ lg: { value: "text-24", unit: "text-14" },
+ sm: { value: "text-20", unit: "text-14" },
} as const;
/**
- * A coloured number always means a threshold was crossed — never decoration.
- * Label recedes (11px uppercase muted) so the value leads (34px, or 20px when packed).
+ * v3 KPI card: label 12px muted, value in mono, unit faint, then a context line on the left
+ * and a 64×20 sparkline on the right. A coloured number always means a threshold was crossed.
*/
export function MetricCard({
- label, value, unit, state = "ok", size = "lg", threshold, series, seriesColor, className,
+ label, value, unit, state = "ok", size = "lg", threshold, sub, subState, series, seriesColor, bare = false, progress, className,
}: {
label: string;
value: string | number;
unit?: string;
state?: MetricState;
size?: keyof typeof SIZE;
+ /** @deprecated since 0.4.0 — use `sub`. Rendered as the context line when `sub` is absent. */
threshold?: string;
+ /** Context line under the value: a delta, a threshold, a projection. */
+ sub?: React.ReactNode;
+ /** Colours the context line; defaults to `state` when it crossed a threshold. */
+ subState?: MetricState;
series?: number[];
seriesColor?: string;
+ /** Drop the card frame — for metrics placed inside another card. */
+ bare?: boolean;
+ /** An 8px meter under the value — capacity, quota. `projected` stacks a lighter segment. */
+ progress?: { value: number; projected?: number; indicatorClassName?: string };
className?: string;
}) {
+ const context = sub ?? threshold;
+ const subTone = subState ?? state;
return (
-
);
}
/**
- * A 1px gap over --border-subtle: the cells' own white paints the hairlines,
- * so a group of metrics reads as one instrument.
+ * A responsive row of KPI cards: two columns on narrow screens, `columns` from `lg` up.
*/
export function MetricGrid({ columns = 4, className, children }: { columns?: number; className?: string; children: React.ReactNode }) {
return (
{children}
diff --git a/src/components/lumyx/page-body.tsx b/src/components/lumyx/page-body.tsx
index c037fdd..8cc3c45 100644
--- a/src/components/lumyx/page-body.tsx
+++ b/src/components/lumyx/page-body.tsx
@@ -1,11 +1,11 @@
import * as React from "react";
import { cn } from "../../lib/utils";
-/** Ligne 2 de la grille du SidebarInset — la seule zone qui scrolle. */
+/** Row 2 of the SidebarInset grid — the only area that scrolls. 1400px, 24px gutter, 16px rhythm. */
export function PageBody({ className, children }: { className?: string; children: React.ReactNode }) {
return (
-
{children}
+
{children}
);
}
diff --git a/src/components/lumyx/peer-card.tsx b/src/components/lumyx/peer-card.tsx
index 714cc26..aef156b 100644
--- a/src/components/lumyx/peer-card.tsx
+++ b/src/components/lumyx/peer-card.tsx
@@ -16,15 +16,15 @@ export type Peer = {
/** Peers are indigo. Rooms and sessions are coral. They are never blended. */
export function PeerCard({ peer, className }: { peer: Peer; className?: string }) {
return (
-
diff --git a/src/components/lumyx/score-bar.tsx b/src/components/lumyx/score-bar.tsx
new file mode 100644
index 0000000..095a420
--- /dev/null
+++ b/src/components/lumyx/score-bar.tsx
@@ -0,0 +1,16 @@
+import { cn } from "../../lib/utils";
+import { scoreTone, toneBg, toneText, type Tone } from "./tone";
+
+/** Quality score cell: a 48×4 meter and the score in mono, both in the score's tone. */
+export function ScoreBar({ score, tone, className }: { score: number; tone?: Tone; className?: string }) {
+ const t = tone ?? scoreTone(score);
+ const v = Math.min(100, Math.max(0, score));
+ return (
+
+
+
+
+ {score}
+
+ );
+}
diff --git a/src/components/lumyx/sidebar-brand.tsx b/src/components/lumyx/sidebar-brand.tsx
new file mode 100644
index 0000000..2a94bc4
--- /dev/null
+++ b/src/components/lumyx/sidebar-brand.tsx
@@ -0,0 +1,45 @@
+import * as React from "react";
+import { cn } from "../../lib/utils";
+import { LumyxMark } from "./mark";
+
+/**
+ * The sidebar's head: a 32px tile carrying the mark, the product name, and one line of
+ * context (organisation, or instance and region). `accent` is Lumyx Cloud, `ink` the
+ * self-hosted dashboard — the tile is how the two tell themselves apart at a glance.
+ */
+export function SidebarBrand({
+ name, meta, tone = "accent", monoMeta = false, className,
+}: {
+ name: string;
+ meta?: React.ReactNode;
+ tone?: "accent" | "ink";
+ /** Set `meta` in mono when it is an identifier (`sfu-eu-3 · eu-west-3`). */
+ monoMeta?: boolean;
+ className?: string;
+}) {
+ return (
+
+
+
+
+
+ {name}
+ {meta ? {meta} : null}
+
+
+ );
+}
+
+/** The rounded panel at the foot of the sidebar — plan usage, connection status. */
+export function SidebarPanel({ className, children }: { className?: string; children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/lumyx/sidebar-nav.tsx b/src/components/lumyx/sidebar-nav.tsx
index 537f2df..94af7cf 100644
--- a/src/components/lumyx/sidebar-nav.tsx
+++ b/src/components/lumyx/sidebar-nav.tsx
@@ -11,45 +11,71 @@ import {
SidebarMenuItem,
} from "../ui/sidebar";
-export type NavSection = {
+export type NavDot = "danger" | "warn" | "ok" | "accent";
+
+export type NavItem = {
+ href: string;
label: string;
- items: { href: string; label: string; icon: React.ElementType }[];
+ icon: React.ElementType;
+ /** A count shown at the right edge, in mono — peers, rooms, open alerts. */
+ count?: string | number;
+ /** An 8px dot at the right edge — something on that screen needs a look. */
+ dot?: NavDot;
+};
+
+export type NavSection = {
+ /** Omit for the first group when it needs no heading. */
+ label?: string;
+ items: NavItem[];
+};
+
+const DOT: Record = {
+ danger: "bg-danger-solid",
+ warn: "bg-warn-solid",
+ ok: "bg-ok-solid",
+ accent: "bg-accent",
};
-/** La nav de la sidebar : groupes, item actif, identite visuelle Lumyx. */
+/** v3 sidebar nav: 14px rows on an 8px radius; the current page is an indigo tint, semibold. */
export function SidebarNav({
sections,
ariaLabel,
activeHref,
+ isActive,
}: {
sections: NavSection[];
ariaLabel: string;
/** Force l'item actif ; par defaut, le `pathname` du `LumyxLinkProvider`. */
activeHref?: string;
+ /** Matching rule when an item owns nested routes; defaults to strict equality. */
+ isActive?: (href: string, pathname: string) => boolean;
}) {
const { Link, pathname: contextPathname } = useLumyxLink();
const pathname = activeHref ?? contextPathname;
+ const match = isActive ?? ((href: string, path: string) => href === path);
return (
-