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 ( +
+ + `M0 ${H * f}H${W}`).join("")} stroke="var(--border-subtle)" vectorEffect="non-scaling-stroke" /> + {[...bands].reverse().map((b) => ( + + ))} + {pace ? ( + + ) : null} + + {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 ( +
    + {events.map((e, i) => ( +
  • + + + {e.message} + {e.detail != null ? {e.detail} : null} + + {e.time} +
  • + ))} +
+ ); +} diff --git a/src/components/lumyx/event-list.tsx b/src/components/lumyx/event-list.tsx index b6fea6b..f9efc1a 100644 --- a/src/components/lumyx/event-list.tsx +++ b/src/components/lumyx/event-list.tsx @@ -8,9 +8,9 @@ export function EventList({ events, className }: { events: LumyxEvent[]; classNa return (
    {events.map((e, i) => ( -
  • +
  • - {e.message} + {e.message} {e.detail ? · {e.detail} : null} {e.time}
  • diff --git a/src/components/lumyx/feedback.tsx b/src/components/lumyx/feedback.tsx index 09f0866..1e1eeaf 100644 --- a/src/components/lumyx/feedback.tsx +++ b/src/components/lumyx/feedback.tsx @@ -1,22 +1,21 @@ import * as React from "react"; -import { AlertTriangle, Info, CheckCircle2, XCircle } from "lucide-react"; +import { AlertTriangle, Info, CheckCircle2, XCircle, X } from "lucide-react"; import { cn } from '../../lib/utils'; import { Button } from '../ui/button'; export type Severity = "critical" | "warning" | "info" | "resolved"; const SEV = { - critical: { tint: "bg-danger-tint", ink: "text-danger", Icon: XCircle, label: "Critical" }, - warning: { tint: "bg-warn-tint", ink: "text-warn", Icon: AlertTriangle, label: "Warning" }, - info: { tint: "bg-info-tint", ink: "text-info", Icon: Info, label: "Info" }, - resolved: { tint: "bg-ok-tint", ink: "text-ok", Icon: CheckCircle2, label: "Resolved" }, + critical: { tint: "bg-danger-tint", ink: "text-danger", border: "border-danger-border", glow: "shadow-[var(--shadow-danger)]", Icon: AlertTriangle, label: "Critical" }, + warning: { tint: "bg-warn-tint", ink: "text-warn", border: "border-warn-border", glow: "", Icon: AlertTriangle, label: "Warning" }, + info: { tint: "bg-info-tint", ink: "text-info", border: "border-info-border", glow: "", Icon: Info, label: "Info" }, + resolved: { tint: "bg-ok-tint", ink: "text-ok", border: "border-ok-border", glow: "", Icon: CheckCircle2, label: "Resolved" }, } as const; export function SeverityBadge({ severity, children }: { severity: Severity; children?: React.ReactNode }) { const s = SEV[severity]; return ( - - + {children ?? s.label} ); @@ -24,25 +23,40 @@ export function SeverityBadge({ severity, children }: { severity: Severity; chil /** * State the condition, the duration and the fix — then the machine context underneath. - * Always the value AND the threshold. + * v3: a white card stroked in the tone, a 40px tinted icon tile, actions and a dismiss on the right. */ export function AlertBanner({ - severity = "warning", title, body, context, actionLabel, onAction, + severity = "warning", title, body, context, icon: Icon, actionLabel, onAction, action, onDismiss, dismissLabel = "Dismiss", className, }: { - severity?: Severity; title: string; body?: string; context?: string; + severity?: Severity; title: React.ReactNode; body?: React.ReactNode; context?: React.ReactNode; + /** Replaces the severity glyph — a meter for a quota, a lock for 2FA. */ + icon?: React.ElementType; actionLabel?: string; onAction?: () => void; + /** Free-form actions, rendered after `actionLabel`. */ + action?: React.ReactNode; + onDismiss?: () => void; dismissLabel?: string; + className?: string; }) { const s = SEV[severity]; + const Glyph = Icon ?? s.Icon; return ( -
    - -
    -

    {title}

    - {body ?

    {body}

    : null} - {context ?

    {context}

    : null} +
    + + + +
    +

    {title}

    + {body ?

    {body}

    : null} + {context ?

    {context}

    : null}
    {actionLabel ? ( - + + ) : null} + {action} + {onDismiss ? ( + ) : null}
    ); @@ -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 ( -
    - {label} -
    - {value} - {unit ? {unit} : null} -
    - {series ? : null} - {threshold ? {threshold} : null} +
    + {label} + + {value} + {unit ? {unit} : null} + + {progress ? ( + + ) : null} + {context != null || series ? ( + + {context != null ? ( + + {context} + + ) : } + {series ? : null} + + ) : null}
    ); } /** - * 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 ( -
    +
    - {peer.id} + {peer.id}
    - {peer.name ?

    {peer.name}

    : null} + {peer.name ?

    {peer.name}

    : null}
    {[ ["Rtt", `${peer.rttMs}ms`], @@ -33,14 +33,14 @@ export function PeerCard({ peer, className }: { peer: Peer; className?: string } ].map(([k, v]) => (
    {k}
    -
    {v}
    +
    {v}
    ))}
    {peer.tracks?.length ? (
    {peer.tracks.map((t) => ( - {t} + {t} ))}
    ) : null} diff --git a/src/components/lumyx/quality.tsx b/src/components/lumyx/quality.tsx index 6d1cd9f..5c22497 100644 --- a/src/components/lumyx/quality.tsx +++ b/src/components/lumyx/quality.tsx @@ -20,7 +20,7 @@ export function QualityIndicator({ quality, showLabel = false, className }: { qu ))} @@ -33,7 +33,7 @@ export function QualityIndicator({ quality, showLabel = false, className }: { qu export function LatencyChip({ ms, plain = false, className }: { ms: number; plain?: boolean; className?: string }) { const state = ms < 80 ? "ok" : ms < 150 ? "warn" : "danger"; const ink = state === "ok" ? "text-muted" : state === "warn" ? "text-warn" : "text-danger"; - if (plain) return {ms}ms; + if (plain) return {ms}ms; const tint = state === "ok" ? "bg-ok-tint text-ok" : state === "warn" ? "bg-warn-tint text-warn" : "bg-danger-tint text-danger"; - return {ms}ms; + return {ms}ms; } diff --git a/src/components/lumyx/room-card.tsx b/src/components/lumyx/room-card.tsx index e741c01..74c90da 100644 --- a/src/components/lumyx/room-card.tsx +++ b/src/components/lumyx/room-card.tsx @@ -17,25 +17,25 @@ export type Room = { export function RoomCard({ room, href, className }: { room: Room; href?: string; className?: string }) { const { Link } = useLumyxLink(); const body = ( -
    +
    - {room.id} + {room.id} - {room.region} · {room.durationLabel} + {room.region} · {room.durationLabel}
    {room.peers} peers
    -
    +
    Bitrate - {room.bitrateLabel} + {room.bitrateLabel}
    Worst loss - = 2 ? "text-danger" : "text-strong")}> + = 2 ? "text-danger" : "text-strong")}> {room.worstLossPct.toFixed(2)}%
    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 ( -