CodeCarbon
diff --git a/webapp/public/fonts/Disket-mono_EULA.pdf b/webapp/public/fonts/Disket-mono_EULA.pdf
new file mode 100644
index 000000000..026af39e0
Binary files /dev/null and b/webapp/public/fonts/Disket-mono_EULA.pdf differ
diff --git a/webapp/public/fonts/DisketMono-Bold.ttf b/webapp/public/fonts/DisketMono-Bold.ttf
new file mode 100644
index 000000000..a33bfccb3
Binary files /dev/null and b/webapp/public/fonts/DisketMono-Bold.ttf differ
diff --git a/webapp/public/fonts/DisketMono-Regular.ttf b/webapp/public/fonts/DisketMono-Regular.ttf
new file mode 100644
index 000000000..b564c60ac
Binary files /dev/null and b/webapp/public/fonts/DisketMono-Regular.ttf differ
diff --git a/webapp/src/api/mock/data.ts b/webapp/src/api/mock/data.ts
index 0d6f0a748..d768e4c95 100644
--- a/webapp/src/api/mock/data.ts
+++ b/webapp/src/api/mock/data.ts
@@ -4,6 +4,7 @@ import type {
ExperimentReport,
Organization,
OrganizationReport,
+ OrganizationUser,
IProjectToken,
RunMetadata,
User,
@@ -242,6 +243,30 @@ function makeRunRow(args: {
};
}
+/*
+ * Fixture timestamps are anchored to the current date rather than hardcoded, so
+ * the mock always has data inside the dashboards' default 30-day window. They are
+ * spread across that window so that narrowing the date range visibly changes the
+ * numbers — which is the point of having a date filter to exercise.
+ */
+const DAY_MS = 24 * 60 * 60 * 1000;
+const daysAgo = (days: number, hour = 10) => {
+ const d = new Date(Date.now() - days * DAY_MS);
+ d.setUTCHours(hour, 0, 0, 0);
+ return d.toISOString();
+};
+
+/** Spacing between samples in `makeEmissionSeries`, in seconds. */
+export const EMISSION_INTERVAL_SECONDS = 5 * 60;
+
+const AT = {
+ baseline1: daysAgo(20),
+ baseline2: daysAgo(20, 11),
+ optimized1: daysAgo(5),
+ production: daysAgo(2),
+ tokenLastUsed: daysAgo(1, 8),
+};
+
// ─── Composed data (built top-down from the aggregate root) ────────────────
const organization = makeOrganization({
@@ -294,7 +319,7 @@ const experimentBaseline = makeExperiment({
projectId: ID.projects.training,
name: "Baseline run",
description: "First experiment baseline",
- timestamp: "2026-04-01T10:00:00Z",
+ timestamp: AT.baseline1,
});
const experimentOptimized = makeExperiment({
@@ -302,7 +327,7 @@ const experimentOptimized = makeExperiment({
projectId: ID.projects.training,
name: "Optimized model",
description: "Quantized variant",
- timestamp: "2026-04-15T10:00:00Z",
+ timestamp: AT.optimized1,
onCloud: true,
cloudProvider: "aws",
cloudRegion: "eu-west-3",
@@ -313,7 +338,7 @@ const experimentProduction = makeExperiment({
projectId: ID.projects.inference,
name: "Production rollout",
description: "Live inference",
- timestamp: "2026-04-20T10:00:00Z",
+ timestamp: AT.production,
onCloud: true,
cloudProvider: "gcp",
cloudRegion: "europe-west1",
@@ -340,7 +365,7 @@ const optimizedReport = makeExperimentReport({
const runBaseline1 = makeRunRow({
runId: ID.runs.baseline1,
experimentId: ID.experiments.baseline,
- timestamp: "2026-04-01T10:00:00Z",
+ timestamp: AT.baseline1,
emissions: 0.617,
energyConsumed: 2.839,
durationSeconds: 1800,
@@ -349,7 +374,7 @@ const runBaseline1 = makeRunRow({
const runBaseline2 = makeRunRow({
runId: ID.runs.baseline2,
experimentId: ID.experiments.baseline,
- timestamp: "2026-04-01T11:00:00Z",
+ timestamp: AT.baseline2,
emissions: 0.617,
energyConsumed: 2.839,
durationSeconds: 1800,
@@ -358,7 +383,7 @@ const runBaseline2 = makeRunRow({
const runOptimized1 = makeRunRow({
runId: ID.runs.optimized1,
experimentId: ID.experiments.optimized,
- timestamp: "2026-04-15T10:00:00Z",
+ timestamp: AT.optimized1,
emissions: 0.567,
energyConsumed: 2.345,
durationSeconds: 1800,
@@ -368,11 +393,63 @@ const ciToken = makeProjectToken({
id: ID.tokens.ci,
projectId: ID.projects.training,
name: "Local dev token",
- lastUsed: "2026-04-30T08:00:00Z",
+ lastUsed: AT.tokenLastUsed,
});
// ─── Exported aggregate (consumed by handlers.ts) ──────────────────────────
+/*
+ * The organization's emission rows, flattened across every run. The backend's
+ * `/organizations/{id}/sums` filters this table by `emissions.timestamp` and
+ * aggregates the matches, so the mock does the same rather than returning a
+ * constant — otherwise a date filter cannot be exercised locally at all.
+ */
+function organizationEmissionRows(): Emission[] {
+ return [
+ ...makeEmissionSeries({
+ runId: ID.runs.baseline1,
+ samples: 12,
+ startedAt: new Date(AT.baseline1),
+ }),
+ ...makeEmissionSeries({
+ runId: ID.runs.baseline2,
+ samples: 12,
+ startedAt: new Date(AT.baseline2),
+ }),
+ ...makeEmissionSeries({
+ runId: ID.runs.optimized1,
+ samples: 6,
+ startedAt: new Date(AT.optimized1),
+ }),
+ ];
+}
+
+const round = (n: number, dp = 3) => Number(n.toFixed(dp));
+
+/**
+ * Aggregate the organization's emissions over a date range, the way
+ * `read_organization_detailed_sums` does. Bounds are inclusive; either may be
+ * omitted, matching the endpoint's optional query parameters.
+ */
+export function organizationReportBetween(
+ start?: Date | null,
+ end?: Date | null,
+): OrganizationReport {
+ const rows = organizationEmissionRows().filter((e) => {
+ const t = new Date(e.timestamp).getTime();
+ if (start && t < start.getTime()) return false;
+ if (end && t > end.getTime()) return false;
+ return true;
+ });
+ return {
+ name: organization.name,
+ emissions: round(rows.reduce((a, e) => a + e.emissions_sum, 0)),
+ energy_consumed: round(rows.reduce((a, e) => a + e.energy_consumed, 0)),
+ // Each row covers one sampling interval.
+ duration: rows.length * EMISSION_INTERVAL_SECONDS,
+ };
+}
+
export const MOCK = {
user: adminUser,
@@ -382,9 +459,26 @@ export const MOCK = {
[organization.id]: organization,
} as Record,
report: organizationReport,
+ /*
+ * `GET /organizations/{id}/users` returns the backend's
+ * `OrganizationUser`: a user plus their membership of that organization,
+ * including `is_admin`. The admin/member split mirrors the two fixture
+ * users.
+ */
usersByOrgId: {
- [organization.id]: [adminUser, memberUser],
- } as Record,
+ [organization.id]: [
+ {
+ ...adminUser,
+ organization_id: organization.id,
+ is_admin: true,
+ },
+ {
+ ...memberUser,
+ organization_id: organization.id,
+ is_admin: false,
+ },
+ ],
+ } as Record,
},
project: {
diff --git a/webapp/src/api/mock/handlers.ts b/webapp/src/api/mock/handlers.ts
index f2d49b18f..964a9be98 100644
--- a/webapp/src/api/mock/handlers.ts
+++ b/webapp/src/api/mock/handlers.ts
@@ -1,4 +1,4 @@
-import { ID, MOCK, MockProjectWire } from "./data";
+import { ID, MOCK, MockProjectWire, organizationReportBetween } from "./data";
export type MockResponse = { status: number; body?: unknown };
@@ -27,7 +27,7 @@ const handlers: Handler[] = [
},
// ─── Organizations ─────────────────────────────────────────────────────
- ({ pathname, method, body }) => {
+ ({ pathname, method, searchParams, body }) => {
if (method === "GET" && pathname === "/organizations") {
return ok(MOCK.organization.list);
}
@@ -49,7 +49,20 @@ const handlers: Handler[] = [
}
const sums = pathname.match(/^\/organizations\/([^/]+)\/sums$/);
if (method === "GET" && sums) {
- return ok(MOCK.organization.report);
+ // Honour the same query parameters as the real endpoint, so the
+ // dashboard's date picker actually changes the figures locally.
+ const parse = (key: string) => {
+ const raw = searchParams.get(key);
+ if (!raw) return null;
+ const d = new Date(raw);
+ return Number.isNaN(d.getTime()) ? null : d;
+ };
+ return ok(
+ organizationReportBetween(
+ parse("start_date"),
+ parse("end_date"),
+ ),
+ );
}
const users = pathname.match(/^\/organizations\/([^/]+)\/users$/);
if (method === "GET" && users) {
diff --git a/webapp/src/api/organizations.ts b/webapp/src/api/organizations.ts
index ede313dee..e064c9b32 100644
--- a/webapp/src/api/organizations.ts
+++ b/webapp/src/api/organizations.ts
@@ -1,4 +1,4 @@
-import { fetchApi } from "./client";
+import { fetchApi, fetchApiVoid } from "./client";
import {
Organization,
OrganizationSchema,
@@ -42,3 +42,21 @@ export async function createOrganization(organization: {
body: JSON.stringify(organization),
});
}
+
+/*
+ * Add a member to an organization by email address.
+ *
+ * The endpoint looks the address up among existing accounts and subscribes it
+ * to the organization; it does not send an invitation, and it answers with a
+ * bare status object rather than the member it added, so there is nothing to
+ * validate and the caller refetches the list.
+ */
+export async function addOrganizationUser(
+ organizationId: string,
+ email: string,
+): Promise {
+ await fetchApiVoid(`/organizations/${organizationId}/add-user`, {
+ method: "POST",
+ body: JSON.stringify({ email }),
+ });
+}
diff --git a/webapp/src/api/schemas.ts b/webapp/src/api/schemas.ts
index f7fe8772d..e70c6c9a7 100644
--- a/webapp/src/api/schemas.ts
+++ b/webapp/src/api/schemas.ts
@@ -16,6 +16,20 @@ export const UserSchema = z.object({
});
export type User = z.infer;
+/*
+ * `GET /organizations/{id}/users` returns the backend's `OrganizationUser`: a
+ * user plus their membership of that organization. `is_admin` is the only place
+ * the API exposes admin rights, and it is per-organization.
+ */
+export const OrganizationUserSchema = z.object({
+ id: z.string(),
+ email: z.string(),
+ name: z.string(),
+ organization_id: z.string(),
+ is_admin: z.boolean(),
+});
+export type OrganizationUser = z.infer;
+
// Backend returns snake_case keys (`organization_id`); the rest of the
// codebase consumes camelCase (`organizationId`). Zod's `.transform` lets
// us validate the wire shape and expose the camelCase shape to the app.
@@ -190,7 +204,6 @@ export interface ProjectDashboardProps {
selectedRunId: string;
onExperimentClick: (experimentId: string) => void;
onRunClick: (runId: string) => void;
- onSettingsClick: () => void;
onRefresh: () => void;
isLoading?: boolean;
}
diff --git a/webapp/src/components/account-menu.tsx b/webapp/src/components/account-menu.tsx
new file mode 100644
index 000000000..7344cd173
--- /dev/null
+++ b/webapp/src/components/account-menu.tsx
@@ -0,0 +1,171 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import useSWR from "swr";
+
+import { getOrganizations } from "@/api/organizations";
+import { Organization, OrganizationUser, User } from "@/api/schemas";
+import { fetcher } from "@/api/swr";
+import { cn } from "@/helpers/utils";
+import { useModal } from "@/hooks/useModal";
+import CreateOrganizationModal from "./createOrganizationModal";
+import { LogoutIcon } from "./icons/logout-icon";
+import { SettingsIcon } from "./icons/settings-icon";
+import { OrganizationIcon } from "./icons/organization-icon";
+import { PlusIcon } from "./icons/plus-icon";
+import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu";
+import { MenuItem, MenuPanel } from "./ui/menu";
+
+export default function AccountMenu({
+ orgs,
+ selectedOrg,
+ onSelectOrg,
+ children,
+}: Readonly<{
+ orgs: Organization[] | undefined;
+ selectedOrg: string | null;
+ onSelectOrg: (organizationId: string) => void;
+ /** The rail's "Account" item, used as the trigger. */
+ children: React.ReactNode;
+}>) {
+ const [open, setOpen] = useState(false);
+ // Keyed on instead of `open`, so closing the menu does not drop the admin
+ // lookup below and regroup the organizations as it animates out.
+ const [hasOpened, setHasOpened] = useState(false);
+ const navigate = useNavigate();
+ const newOrgModal = useModal();
+ const [organizationList, setOrganizationList] = useState<
+ Organization[] | undefined
+ >(undefined);
+
+ const { data: auth } = useSWR<{ user?: User }>("/auth/check", fetcher, {
+ revalidateOnFocus: false,
+ });
+
+ const list = organizationList ?? orgs;
+
+ // Admin rights are exposed only per organization, on its member list, so
+ // this asks each in turn. Dashboards the user administers go below the rule.
+ const userId = auth?.user?.id;
+ const { data: adminOrgIds } = useSWR(
+ hasOpened && userId && list && list.length > 0
+ ? ["organization-admin", userId, list.map((o) => o.id).join(",")]
+ : null,
+ async () => {
+ const ids = await Promise.all(
+ (list ?? []).map(async (org) => {
+ try {
+ const members: OrganizationUser[] = await fetcher(
+ `/organizations/${org.id}/users`,
+ );
+ return members.some(
+ (m) => m.id === userId && m.is_admin,
+ )
+ ? org.id
+ : null;
+ } catch {
+ // A membership that cannot be read counts as non-admin
+ // rather than failing the whole menu.
+ return null;
+ }
+ }),
+ );
+ return new Set(ids.filter((id): id is string => id !== null));
+ },
+ { revalidateOnFocus: false },
+ );
+
+ const owned = list?.filter((org) => adminOrgIds?.has(org.id)) ?? [];
+ const invited = list?.filter((org) => !adminOrgIds?.has(org.id)) ?? [];
+
+ const refreshOrgList = async () => {
+ setOrganizationList(await getOrganizations());
+ };
+
+ return (
+ <>
+ {
+ setOpen(next);
+ if (next) setHasOpened(true);
+ }}
+ >
+ {children}
+
+ {invited.length > 0 && (
+
+ Dashboards you've been invited to
+
+ )}
+
+ {invited.map((org) => (
+
+ ))}
+
+
+
+
+
+ {owned.map((org) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/webapp/src/components/breadcrumb.tsx b/webapp/src/components/breadcrumb.tsx
deleted file mode 100644
index c823c90c8..000000000
--- a/webapp/src/components/breadcrumb.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import {
- Breadcrumb,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbList,
- BreadcrumbSeparator,
-} from "@/components/ui/breadcrumb";
-import React from "react";
-
-export default function BreadcrumbHeader({
- pathSegments,
-}: {
- pathSegments: {
- title: string;
- href: string | null;
- }[];
-}) {
- return (
-
-
- {pathSegments.map((segment, index) => {
- const isLast = index === pathSegments.length - 1;
- const title = segment.title;
- const href = segment.href;
- return (
-
-
- {href ? (
-
- {title}
-
- ) : (
- {title}
- )}
-
- {!isLast && }
-
- );
- })}
-
-
- );
-}
diff --git a/webapp/src/components/chart-row.tsx b/webapp/src/components/chart-row.tsx
new file mode 100644
index 000000000..f986b9f81
--- /dev/null
+++ b/webapp/src/components/chart-row.tsx
@@ -0,0 +1,55 @@
+import { cn } from "@/helpers/utils";
+
+/*
+ * Two charts side by side, separated by a rule.
+ *
+ * The rules read as one cross across the four charts, so nothing here is spaced
+ * with margins: the gap around a rule is padding *inside* the cells, which leaves
+ * the rule running the full height of the row. A margin would end the line early
+ * and break the cross at its centre.
+ *
+ * The inset is a prop rather than something a caller passes through `className`:
+ * these are arbitrary variants, so a `[&>*:first-child]` rule outranks a `[&>*]`
+ * one whatever the class order, and an override would reach one cell only.
+ *
+ * The cells arrive wrapped in `Suspense` and fragments, which render no DOM node,
+ * so the inset reaches them through child selectors rather than by the caller
+ * putting it on each chart.
+ *
+ * Below `md` the columns stack and the rule turns horizontal with them.
+ */
+export default function ChartRow({
+ insetTop = false,
+ insetBottom = false,
+ className,
+ children,
+}: Readonly<{
+ /** Space above the charts, for a row sitting under a rule. */
+ insetTop?: boolean;
+ /** Space below them, for a row sitting above one. */
+ insetBottom?: boolean;
+ className?: string;
+ children: React.ReactNode;
+}>) {
+ return (
+
*:first-child]` reset outranks the `[&>*]` inset below and
+ // would silently apply it to one cell only.
+ "max-md:[&>*:first-child]:pb-10 max-md:[&>*:last-child]:pt-10",
+ // Side by side: equal space either side of the vertical rule.
+ "md:[&>*:first-child]:pr-10 md:[&>*:last-child]:pl-10",
+ "lg:[&>*:first-child]:pr-16 lg:[&>*:last-child]:pl-16",
+ // Applied to both cells, so the row's own edges stay level.
+ insetTop && "md:[&>*]:pt-10 lg:[&>*]:pt-16",
+ insetBottom && "md:[&>*]:pb-10 lg:[&>*]:pb-16",
+ className,
+ )}
+ >
+ {children}
+
+ );
+}
diff --git a/webapp/src/components/chart-section.tsx b/webapp/src/components/chart-section.tsx
new file mode 100644
index 000000000..09f4e2183
--- /dev/null
+++ b/webapp/src/components/chart-section.tsx
@@ -0,0 +1,44 @@
+import { cn } from "@/helpers/utils";
+
+/*
+ * A titled block of the dashboard: heading, a line saying what it is for, and
+ * whatever action belongs to it, above the thing itself.
+ *
+ * The charts each drew this by hand inside a `Card`. They share it now, so the
+ * page reads as one surface with sections on it rather than a wall of boxes, and
+ * the three headings cannot drift apart. It carries no border or fill of its own:
+ * the panels behind the redesign are the page, not the card.
+ */
+export default function ChartSection({
+ title,
+ description,
+ action,
+ className,
+ children,
+}: Readonly<{
+ title: string;
+ description?: string;
+ /** Sits on the title's row, at its end. */
+ action?: React.ReactNode;
+ className?: string;
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+
+
+ {title}
+
+ {action}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {children}
+
+ );
+}
diff --git a/webapp/src/components/consumed-energy-gauge.tsx b/webapp/src/components/consumed-energy-gauge.tsx
new file mode 100644
index 000000000..0420f299e
--- /dev/null
+++ b/webapp/src/components/consumed-energy-gauge.tsx
@@ -0,0 +1,105 @@
+import { cn } from "@/helpers/utils";
+
+/*
+ * The "Consumed energy" gauge: one SVG whose ring, value and caption are placed
+ * in the design's own coordinate space, so the geometry lives in the viewBox
+ * and the gauge scales to whatever box its parent gives it.
+ *
+ * The arc is a fixed decorative sweep, not a proportion of the value: these
+ * metrics are unbounded running totals with no maximum anywhere in the API, so
+ * there is nothing to take a fraction of. The number in the middle is the data.
+ * It is drawn only when there is a value — a zero gauge shows bare track, so an
+ * empty range reads as empty rather than as some amount.
+ */
+
+const VIEWBOX = 199.23;
+const CENTER = 99.6152;
+const RADIUS = 87.0511;
+const STROKE = 25.1281;
+
+/** 12 o'clock, in the screen degrees used below (0 = 3 o'clock, clockwise). */
+const START_ANGLE = 270;
+/** The sweep the previous gauges drew, kept so the rings look unchanged. */
+const ARC_SWEEP = 100;
+
+const point = (angleDeg: number) => {
+ const a = (angleDeg * Math.PI) / 180;
+ return [CENTER + RADIUS * Math.cos(a), CENTER + RADIUS * Math.sin(a)];
+};
+
+/** Arc sweeping anti-clockwise from 12 o'clock by `sweep` degrees. */
+function arcPath(sweep: number) {
+ const [x0, y0] = point(START_ANGLE);
+ const [x1, y1] = point(START_ANGLE - sweep);
+ const largeArc = sweep > 180 ? 1 : 0;
+ // sweep-flag 0 draws anti-clockwise in SVG's y-down coordinate system.
+ return `M ${x0} ${y0} A ${RADIUS} ${RADIUS} 0 ${largeArc} 0 ${x1} ${y1}`;
+}
+
+export default function ConsumedEnergyGauge({
+ value,
+ label,
+ className,
+}: Readonly<{
+ /** The metric's value, shown in the middle of the ring. */
+ value: number;
+ /** Unit caption, e.g. "kWh". */
+ label: string;
+ className?: string;
+}>) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/consumed-energy-gauges.tsx b/webapp/src/components/consumed-energy-gauges.tsx
new file mode 100644
index 000000000..de4c9a1c9
--- /dev/null
+++ b/webapp/src/components/consumed-energy-gauges.tsx
@@ -0,0 +1,37 @@
+import ConsumedEnergyGauge from "./consumed-energy-gauge";
+import { cn } from "@/helpers/utils";
+
+/*
+ * The "Consumed energy" gauges: energy, emissions and duration, each a ring with
+ * its figure and unit.
+ *
+ * One component for both dashboards, as with the equivalences beside them — the
+ * design draws the same three rings on each. The gauges keep their own size and
+ * wrap when the row runs out of width, which is how the design lays them out (a
+ * gap between them, not a distribution across the width).
+ */
+export type Gauge = {
+ label: string;
+ value: number;
+};
+
+export default function ConsumedEnergyGauges({
+ gauges,
+ className,
+}: Readonly<{
+ gauges: Gauge[];
+ className?: string;
+}>) {
+ return (
+
+ {gauges.map((gauge) => (
+
+
+
+ ))}
+
+ );
+}
diff --git a/webapp/src/components/create-experiment-modal.tsx b/webapp/src/components/create-experiment-modal.tsx
new file mode 100644
index 000000000..a5482e996
--- /dev/null
+++ b/webapp/src/components/create-experiment-modal.tsx
@@ -0,0 +1,221 @@
+import { useEffect, useRef, useState } from "react";
+import { ClipboardCheck, ClipboardCopy, Loader2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { createExperiment } from "@/api/experiments";
+import { Experiment, ExperimentInput } from "@/api/schemas";
+import { Dialog, DialogContent } from "./ui/dialog";
+import { FormField } from "./ui/form-field";
+import { IconButton } from "./ui/icon-button";
+import ModalHeader from "./ui/modal-header";
+import { PrimaryButton } from "./ui/primary-button";
+
+/*
+ * Create an experiment, in the same panel as the Create-project dialog: the two
+ * are the same object in the design, so they share the shell, the fields and the
+ * button rather than each describing them.
+ *
+ * It has a second state the other does not: once the experiment exists, the
+ * dialog stays open to hand over its id, since that is what the tracker needs and
+ * the page never shows it again.
+ */
+export default function CreateExperimentModal({
+ projectId,
+ isOpen,
+ onClose,
+ onExperimentCreated,
+}: {
+ projectId: string;
+ isOpen: boolean;
+ onClose: () => void;
+ onExperimentCreated?: () => void | Promise;
+}) {
+ const [isCopied, setIsCopied] = useState(false);
+ const copyTimerRef = useRef | null>(null);
+ const [isSaving, setIsSaving] = useState(false);
+ const [isCreated, setIsCreated] = useState(false);
+ const [experimentData, setExperimentData] = useState({
+ name: "",
+ description: "",
+ on_cloud: false,
+ project_id: "",
+ });
+ const [createdExperiment, setCreatedExperiment] =
+ useState(null);
+
+ useEffect(() => {
+ if (projectId && !experimentData.project_id) {
+ setExperimentData({
+ ...experimentData,
+ project_id: projectId,
+ });
+ }
+ }, [projectId, experimentData]);
+
+ useEffect(() => {
+ return () => {
+ if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
+ };
+ }, []);
+
+ const resetForm = () => {
+ setExperimentData({
+ name: "",
+ description: "",
+ on_cloud: false,
+ project_id: projectId,
+ });
+ setIsCreated(false);
+ setCreatedExperiment(null);
+ };
+
+ const handleClose = () => {
+ resetForm();
+ onClose();
+ };
+
+ const handleSave = async () => {
+ if (!experimentData.name.trim()) {
+ toast.error("Experiment name is required");
+ return;
+ }
+
+ setIsSaving(true);
+
+ try {
+ const newExperiment = await createExperiment(experimentData);
+ setCreatedExperiment(newExperiment);
+ setIsCreated(true);
+ await onExperimentCreated?.();
+ toast.success(
+ `Experiment ${experimentData.name} created successfully`,
+ );
+ } catch (error) {
+ console.error("Failed to create experiment:", error);
+ toast.error("Failed to create experiment");
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const handleCopy = (token: string | undefined) => {
+ if (!token) return;
+ navigator.clipboard
+ .writeText(token)
+ .then(() => {
+ setIsCopied(true);
+ toast.success("Experiment ID copied to clipboard");
+ copyTimerRef.current = setTimeout(
+ () => setIsCopied(false),
+ 2000,
+ );
+ })
+ .catch((err) => {
+ console.error("Failed to copy experiment id:", err);
+ toast.error("Failed to copy experiment ID");
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/webapp/src/components/create-project-modal.tsx b/webapp/src/components/create-project-modal.tsx
new file mode 100644
index 000000000..47ee0b7b2
--- /dev/null
+++ b/webapp/src/components/create-project-modal.tsx
@@ -0,0 +1,135 @@
+import { useState } from "react";
+import { toast } from "sonner";
+
+import { createProject } from "@/api/projects";
+import { Dialog, DialogContent } from "./ui/dialog";
+import ModalHeader from "./ui/modal-header";
+import { FormField } from "./ui/form-field";
+import { PrimaryButton } from "./ui/primary-button";
+
+/*
+ * Create a project: a dialog holding a name and a description.
+ *
+ * The design's fixed 664x524 panel is not reproduced as a size — its own
+ * numbers do not add up, with the form starting below where the panel ends — so
+ * the panel keeps its proportion instead, a little wider than tall, and narrows
+ * below its maximum.
+ */
+
+interface ModalProps {
+ organizationId: string;
+ isOpen: boolean;
+ onClose: () => void;
+ onProjectCreated: () => Promise;
+}
+
+interface CreateProjectInput {
+ name: string;
+ description: string;
+}
+
+const CreateProjectModal: React.FC = ({
+ organizationId,
+ isOpen,
+ onClose,
+ onProjectCreated,
+}) => {
+ const [formData, setFormData] = useState({
+ name: "",
+ description: "",
+ });
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleClose = () => {
+ // Reset state when closing
+ setFormData({ name: "", description: "" });
+ onClose();
+ };
+
+ const handleSave = async () => {
+ toast.promise(
+ async () => {
+ setIsLoading(true);
+ try {
+ const newProject = await createProject(
+ organizationId,
+ formData,
+ );
+ await onProjectCreated(); // Call the callback to refresh the project list
+ handleClose(); // Automatically close the modal after successful creation
+ return newProject; // Return for the success message
+ } catch (error) {
+ console.error("Failed to create project:", error);
+ throw error; // Rethrow for the error message
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ {
+ loading: "Creating project...",
+ success: "Project created successfully!",
+ error: "Failed to create project",
+ },
+ );
+ };
+
+ return (
+
+ );
+};
+
+export default CreateProjectModal;
diff --git a/webapp/src/components/createExperimentModal.tsx b/webapp/src/components/createExperimentModal.tsx
deleted file mode 100644
index e189a43d1..000000000
--- a/webapp/src/components/createExperimentModal.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { createExperiment } from "@/api/experiments";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Experiment, ExperimentInput } from "@/api/schemas";
-import { Separator } from "./ui/separator";
-import { ClipboardCheck, ClipboardCopy, Loader2 } from "lucide-react";
-import { toast } from "sonner";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogFooter,
-} from "@/components/ui/dialog";
-
-export default function CreateExperimentModal({
- projectId,
- isOpen,
- onClose,
- onExperimentCreated,
-}: {
- projectId: string;
- isOpen: boolean;
- onClose: () => void;
- onExperimentCreated?: () => void | Promise;
-}) {
- const [isCopied, setIsCopied] = useState(false);
- const copyTimerRef = useRef | null>(null);
- const [isSaving, setIsSaving] = useState(false);
- const [isCreated, setIsCreated] = useState(false);
- const [experimentData, setExperimentData] = useState({
- name: "",
- description: "",
- on_cloud: false,
- project_id: "",
- });
- const [createdExperiment, setCreatedExperiment] =
- useState(null);
-
- useEffect(() => {
- if (projectId && !experimentData.project_id) {
- setExperimentData({
- ...experimentData,
- project_id: projectId,
- });
- }
- }, [projectId, experimentData]);
-
- useEffect(() => {
- return () => {
- if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
- };
- }, []);
-
- const resetForm = () => {
- setExperimentData({
- name: "",
- description: "",
- on_cloud: false,
- project_id: projectId,
- });
- setIsCreated(false);
- setCreatedExperiment(null);
- };
-
- const handleClose = () => {
- resetForm();
- onClose();
- };
-
- const handleSave = async () => {
- if (!experimentData.name.trim()) {
- toast.error("Experiment name is required");
- return;
- }
-
- setIsSaving(true);
-
- try {
- const newExperiment = await createExperiment(experimentData);
- setCreatedExperiment(newExperiment);
- setIsCreated(true);
- await onExperimentCreated?.();
- toast.success(
- `Experiment ${experimentData.name} created successfully`,
- );
- } catch (error) {
- console.error("Failed to create experiment:", error);
- toast.error("Failed to create experiment");
- } finally {
- setIsSaving(false);
- }
- };
-
- const handleCopy = (token: string | undefined) => {
- if (!token) return;
- navigator.clipboard
- .writeText(token)
- .then(() => {
- setIsCopied(true);
- toast.success("Experiment ID copied to clipboard");
- copyTimerRef.current = setTimeout(
- () => setIsCopied(false),
- 2000,
- );
- })
- .catch((err) => {
- console.error("Failed to copy experiment id:", err);
- toast.error("Failed to copy experiment ID");
- });
- };
-
- return (
-
- );
-}
diff --git a/webapp/src/components/createProjectModal.tsx b/webapp/src/components/createProjectModal.tsx
deleted file mode 100644
index f4ccce028..000000000
--- a/webapp/src/components/createProjectModal.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-import { useState } from "react";
-import { createProject } from "@/api/projects";
-import { Separator } from "./ui/separator";
-import { Input } from "./ui/input";
-import { Label } from "./ui/label";
-import { Button } from "./ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "./ui/dialog";
-import { toast } from "sonner";
-
-interface ModalProps {
- organizationId: string;
- isOpen: boolean;
- onClose: () => void;
- onProjectCreated: () => Promise;
-}
-
-interface CreateProjectInput {
- name: string;
- description: string;
-}
-
-const CreateProjectModal: React.FC = ({
- organizationId,
- isOpen,
- onClose,
- onProjectCreated,
-}) => {
- const [formData, setFormData] = useState({
- name: "",
- description: "",
- });
- const [isLoading, setIsLoading] = useState(false);
-
- const handleSave = async () => {
- toast.promise(
- async () => {
- setIsLoading(true);
- try {
- const newProject = await createProject(
- organizationId,
- formData,
- );
- await onProjectCreated(); // Call the callback to refresh the project list
- handleClose(); // Automatically close the modal after successful creation
- return newProject; // Return for the success message
- } catch (error) {
- console.error("Failed to create project:", error);
- throw error; // Rethrow for the error message
- } finally {
- setIsLoading(false);
- }
- },
- {
- loading: "Creating project...",
- success: "Project created successfully!",
- error: "Failed to create project",
- },
- );
- };
-
- const handleClose = () => {
- // Reset state when closing
- setFormData({ name: "", description: "" });
- onClose();
- };
-
- return (
-
- );
-};
-
-export default CreateProjectModal;
diff --git a/webapp/src/components/date-range-picker.tsx b/webapp/src/components/date-range-picker.tsx
index 8f39839d6..685ecfe2b 100644
--- a/webapp/src/components/date-range-picker.tsx
+++ b/webapp/src/components/date-range-picker.tsx
@@ -14,9 +14,24 @@ import { format } from "date-fns";
interface DateRangePickerProps {
date: DateRange;
onDateChange: (newDate: DateRange | undefined) => void;
+ /**
+ * `default` keeps the outline button used elsewhere in the app.
+ * `dashboard` matches the design's "Input" component: a 16px
+ * IBM Plex Mono Regular "Dates" label above a 46px field with a
+ * rgba(255,255,255,0.05) fill, 2px radius, 16px horizontal padding and a
+ * #666666 numeric value. The design shows no calendar glyph in the field.
+ */
+ variant?: "default" | "dashboard";
+ /** Field label, used by the `dashboard` variant. */
+ label?: string;
}
-export function DateRangePicker({ date, onDateChange }: DateRangePickerProps) {
+export function DateRangePicker({
+ date,
+ onDateChange,
+ variant = "default",
+ label = "Dates",
+}: DateRangePickerProps) {
const [open, setOpen] = useState(false);
const [tempDateRange, setTempDateRange] = useState(
date,
@@ -35,8 +50,40 @@ export function DateRangePicker({ date, onDateChange }: DateRangePickerProps) {
setOpen(false);
};
- return (
-
+ /*
+ * The design renders the range as "01/01/2021 - 01/02/2021". A one-month
+ * span is dd/MM/yyyy (1 Jan to 1 Feb), which also matches this dashboard's
+ * default 30-day range; read as MM/dd/yyyy it would be a single day.
+ */
+ const formatted = (pattern: string) =>
+ date?.from
+ ? date.to
+ ? `${format(date.from, pattern)} - ${format(date.to, pattern)}`
+ : format(date.from, pattern)
+ : null;
+
+ const trigger =
+ variant === "dashboard" ? (
+
+
+
+
+
+
+ ) : (
+ );
+
+ return (
+
+ {trigger}
-
-
- Run Metadata
-
- Hardware and environment details
-
-
-
-
+
+
);
}
diff --git a/webapp/src/components/equivalence-list.tsx b/webapp/src/components/equivalence-list.tsx
new file mode 100644
index 000000000..d6ceda295
--- /dev/null
+++ b/webapp/src/components/equivalence-list.tsx
@@ -0,0 +1,113 @@
+import { cn } from "@/helpers/utils";
+
+/*
+ * The "Equal to" list: an icon, the figure it stands for, and a caption saying
+ * what the figure means.
+ *
+ * One component for both dashboards, since the design draws the same item in
+ * each. Only the direction differs: the global dashboard spreads them across the
+ * width of its section, and the project dashboard stacks them in a column beside
+ * its gauges. That is the `direction` prop, and it is the only thing a caller
+ * decides — an item always looks the same.
+ *
+ * The captions wrap to two lines at the measure the design gives them, so the
+ * column keeps its shape rather than stretching to the longest caption.
+ */
+export type Equivalence = {
+ icon: string;
+ alt: string;
+ value: string;
+ caption: string;
+};
+
+/*
+ * The icon, wording and unit for each equivalence, declared once.
+ *
+ * Both dashboards compute these from the same helpers, so they must read the
+ * same. They did not: the two pages had drifted to different captions for the
+ * same number, and one of them rendered kilometres with no unit at all.
+ *
+ * The caption for the first is per-capita emissions, not household energy — that
+ * is what `getEquivalentCitizenPercentage` divides by (a US citizen's yearly
+ * CO2e, over 52 weeks). The design's own copy says "an american household weekly
+ * energy consumption", which describes neither half of that.
+ */
+export function equivalences({
+ citizen,
+ transportation,
+ tvTime,
+}: {
+ /** Percentage of a citizen's weekly emissions, already rounded. */
+ citizen: string;
+ /** Kilometres, already rounded. */
+ transportation: string;
+ /** Days, already rounded. */
+ tvTime: string;
+}): Equivalence[] {
+ return [
+ {
+ icon: "/icons/household_consumption.svg",
+ alt: "Household consumption icon",
+ value: `${citizen}%`,
+ caption: "Of a U.S. citizen's weekly emissions",
+ },
+ {
+ icon: "/icons/transportation.svg",
+ alt: "Transportation icon",
+ value: `${transportation} km`,
+ caption: "Kilometers ridden",
+ },
+ {
+ icon: "/icons/tv.svg",
+ alt: "TV icon",
+ value: `${tvTime} days`,
+ caption: "Of watching TV",
+ },
+ ];
+}
+
+export default function EquivalenceList({
+ items,
+ direction = "row",
+ className,
+}: Readonly<{
+ items: Equivalence[];
+ direction?: "row" | "column";
+ className?: string;
+}>) {
+ return (
+
+ {items.map((item) => (
+
+
+
+
+ {item.value}
+
+
+ {item.caption}
+
+
+
+ ))}
+
+ );
+}
diff --git a/webapp/src/components/experiment-bar-chart.tsx b/webapp/src/components/experiment-bar-chart.tsx
index e8a6d0920..739b88daf 100644
--- a/webapp/src/components/experiment-bar-chart.tsx
+++ b/webapp/src/components/experiment-bar-chart.tsx
@@ -1,13 +1,6 @@
import { ExperimentReport } from "@/api/schemas";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
import {
ChartConfig,
ChartContainer,
@@ -16,6 +9,7 @@ import {
} from "@/components/ui/chart";
import { exportExperimentsToCsv } from "@/utils/export";
import { useMemo, useState } from "react";
+import ChartSection from "./chart-section";
import ChartSkeleton from "./chart-skeleton";
import { ExportCsvButton } from "./export-csv-button";
@@ -84,16 +78,11 @@ export default function ExperimentsBarChart({
}
return (
-
-
-
- Project experiment runs
-
- Click an experiment to see the runs on the chart on the
- right
-
-
- )}
-
-
-
+
+
);
}
diff --git a/webapp/src/components/export-csv-button.tsx b/webapp/src/components/export-csv-button.tsx
index d480114b4..cce269d0e 100644
--- a/webapp/src/components/export-csv-button.tsx
+++ b/webapp/src/components/export-csv-button.tsx
@@ -1,5 +1,5 @@
-import { Button } from "@/components/ui/button";
-import { Download, Loader2 } from "lucide-react";
+import { Loader2 } from "lucide-react";
+import { DownloadIcon } from "@/components/icons/download-icon";
import { useState } from "react";
import { toast } from "sonner";
import {
@@ -45,19 +45,19 @@ export function ExportCsvButton({
-
+
Download .csv export
diff --git a/webapp/src/components/icons/account-circle-icon.tsx b/webapp/src/components/icons/account-circle-icon.tsx
new file mode 100644
index 000000000..2f18adee2
--- /dev/null
+++ b/webapp/src/components/icons/account-circle-icon.tsx
@@ -0,0 +1,24 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Account in circle icon — a head and shoulders inside a circle.
+ */
+
+export function AccountCircleIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/account-icon.tsx b/webapp/src/components/icons/account-icon.tsx
new file mode 100644
index 000000000..88cd957bf
--- /dev/null
+++ b/webapp/src/components/icons/account-icon.tsx
@@ -0,0 +1,62 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Account icon — a head and shoulders in a circle, drawn in the same pixel-art
+ * style as the rail's other icons.
+ */
+
+export function AccountIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/arrow-back-ios-icon.tsx b/webapp/src/components/icons/arrow-back-ios-icon.tsx
new file mode 100644
index 000000000..aa4f30d2d
--- /dev/null
+++ b/webapp/src/components/icons/arrow-back-ios-icon.tsx
@@ -0,0 +1,24 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Back icon — a chevron pointing left.
+ */
+
+export function ArrowBackIosIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/download-icon.tsx b/webapp/src/components/icons/download-icon.tsx
new file mode 100644
index 000000000..aee4d1183
--- /dev/null
+++ b/webapp/src/components/icons/download-icon.tsx
@@ -0,0 +1,24 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Download icon — an arrow pointing down onto a line, drawn in the same
+ * pixel-art style as the rail's icons.
+ */
+
+export function DownloadIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/global-icon.tsx b/webapp/src/components/icons/global-icon.tsx
new file mode 100644
index 000000000..648369142
--- /dev/null
+++ b/webapp/src/components/icons/global-icon.tsx
@@ -0,0 +1,33 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Global icon — a globe, drawn in the same pixel-art style as the rail's other
+ * icons.
+ */
+
+export function GlobalIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/glossary-icon.tsx b/webapp/src/components/icons/glossary-icon.tsx
new file mode 100644
index 000000000..5efda6241
--- /dev/null
+++ b/webapp/src/components/icons/glossary-icon.tsx
@@ -0,0 +1,39 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Glossary icon — an org chart with a smiling robot at the top branching down
+ * to three nodes, drawn in the same pixel-art style as the rail's other icons.
+ */
+
+export function GlossaryIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/lock-icon.tsx b/webapp/src/components/icons/lock-icon.tsx
new file mode 100644
index 000000000..04ab6c302
--- /dev/null
+++ b/webapp/src/components/icons/lock-icon.tsx
@@ -0,0 +1,23 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Lock icon — a closed padlock, drawn in the same pixel-art style as the rail's
+ * icons.
+ */
+
+export function LockIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/logout-icon.tsx b/webapp/src/components/icons/logout-icon.tsx
new file mode 100644
index 000000000..b8d2b1cd4
--- /dev/null
+++ b/webapp/src/components/icons/logout-icon.tsx
@@ -0,0 +1,24 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Log-out icon — an arrow leaving through a doorway, drawn in the same pixel-art
+ * style as the rail's icons.
+ */
+
+export function LogoutIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/members-icon.tsx b/webapp/src/components/icons/members-icon.tsx
new file mode 100644
index 000000000..837ef4eba
--- /dev/null
+++ b/webapp/src/components/icons/members-icon.tsx
@@ -0,0 +1,73 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Members icon — three figures side by side, middle one popping out,
+ * drawn in the same pixel-art style as the rail's other icons.
+ */
+
+export function MembersIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/more-vert-icon.tsx b/webapp/src/components/icons/more-vert-icon.tsx
new file mode 100644
index 000000000..cf3b0ecb9
--- /dev/null
+++ b/webapp/src/components/icons/more-vert-icon.tsx
@@ -0,0 +1,22 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Vertical more icon — three dots in a vertical line, pixel-art style.
+ */
+
+export function MoreVertIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/organization-icon.tsx b/webapp/src/components/icons/organization-icon.tsx
new file mode 100644
index 000000000..1f9dfc74d
--- /dev/null
+++ b/webapp/src/components/icons/organization-icon.tsx
@@ -0,0 +1,72 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Organization icon — an office building.
+ */
+
+export function OrganizationIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/plus-icon.tsx b/webapp/src/components/icons/plus-icon.tsx
new file mode 100644
index 000000000..f82fe3574
--- /dev/null
+++ b/webapp/src/components/icons/plus-icon.tsx
@@ -0,0 +1,24 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Plus icon — a plus sign.
+ */
+
+export function PlusIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/projects-icon.tsx b/webapp/src/components/icons/projects-icon.tsx
new file mode 100644
index 000000000..23877c57e
--- /dev/null
+++ b/webapp/src/components/icons/projects-icon.tsx
@@ -0,0 +1,35 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Projects icon — two magazine files with documents standing in them, a smiley face
+ * on the front one, drawn in the same pixel-art style as the rail's other icons.
+ */
+
+export function ProjectsIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/refresh-icon.tsx b/webapp/src/components/icons/refresh-icon.tsx
new file mode 100644
index 000000000..425f362da
--- /dev/null
+++ b/webapp/src/components/icons/refresh-icon.tsx
@@ -0,0 +1,24 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Refresh icon — two arrows chasing each other in a circle, drawn in the same
+ * pixel-art style as the rail's icons.
+ */
+
+export function RefreshIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/settings-icon.tsx b/webapp/src/components/icons/settings-icon.tsx
new file mode 100644
index 000000000..b5c6663f8
--- /dev/null
+++ b/webapp/src/components/icons/settings-icon.tsx
@@ -0,0 +1,23 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Settings icon — a cog, drawn in the same pixel-art style as the rail's icons.
+ */
+
+export function SettingsIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/share-icon.tsx b/webapp/src/components/icons/share-icon.tsx
new file mode 100644
index 000000000..8a4487b67
--- /dev/null
+++ b/webapp/src/components/icons/share-icon.tsx
@@ -0,0 +1,23 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * Share icon — three nodes joined by lines, drawn in the same pixel-art style as
+ * the rail's icons.
+ */
+
+export function ShareIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/icons/types.ts b/webapp/src/components/icons/types.ts
new file mode 100644
index 000000000..124b7fad1
--- /dev/null
+++ b/webapp/src/components/icons/types.ts
@@ -0,0 +1,16 @@
+/*
+ * Single declaration of the contract for every icon in this directory.
+ *
+ * An icon takes nothing but a class: it draws with `currentColor` and at the
+ * size its caller gives it, so state colour and dimensions belong to the
+ * control around it rather than to the glyph.
+ *
+ * Named for where the set comes from: nearly all of these are exported verbatim
+ * from the Code Carbon Figma file. A few were not in the Figma and were
+ * designed apart from the rest, but in the same style, and they share this same
+ * contract. Seeing `FigmaIconProps` in a module is the quiet signal that the
+ * icon was added during the dashboard redesign.
+ */
+export type FigmaIconProps = {
+ className?: string;
+};
diff --git a/webapp/src/components/icons/user-single-aim-icon.tsx b/webapp/src/components/icons/user-single-aim-icon.tsx
new file mode 100644
index 000000000..89ccf1a04
--- /dev/null
+++ b/webapp/src/components/icons/user-single-aim-icon.tsx
@@ -0,0 +1,52 @@
+import { FigmaIconProps } from "./types";
+
+/*
+ * User single aim icon — one figure inside a targeting reticle, drawn in
+ * pixel-art style.
+ */
+
+export function UserSingleAimIcon({ className }: FigmaIconProps) {
+ return (
+
+ );
+}
diff --git a/webapp/src/components/member-row.tsx b/webapp/src/components/member-row.tsx
new file mode 100644
index 000000000..92a3eafb7
--- /dev/null
+++ b/webapp/src/components/member-row.tsx
@@ -0,0 +1,125 @@
+import { OrganizationUser } from "@/api/schemas";
+import { cn } from "@/helpers/utils";
+import { MoreVertIcon } from "./icons/more-vert-icon";
+import { UserSingleAimIcon } from "./icons/user-single-aim-icon";
+import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu";
+import { MenuItem, MenuPanel } from "./ui/menu";
+import { TableCell, TableRow } from "./ui/table";
+
+/*
+ * One member of the organization: an avatar, their name and email, their
+ * standing in it, and a menu of the actions that apply to them.
+ *
+ * Unlike a project row this is not a link — there is no member page to open —
+ * so nothing lights on hover and the overflow menu is its only control.
+ *
+ * The avatar is decorative: no API field can fill the design's photo circle, so
+ * it renders the design's member glyph and the name beside it is the identity.
+ */
+
+/*
+ * The trigger's padding, and the gap its menu keeps from the glyph. Radix
+ * anchors to the trigger's whole box, so both are cancelled to bring the panel
+ * back to the dots. Same values as a project row's, because it is the same
+ * control.
+ */
+const TRIGGER_INSET = 20;
+const MENU_GAP = 4;
+
+export default function MemberRow({
+ member,
+ onSettings,
+ onDelete,
+}: Readonly<{
+ member: OrganizationUser;
+ /** Undefined leaves the action in the menu but inert, as it is today. */
+ onSettings?: () => void;
+ onDelete?: () => void;
+}>) {
+ /* The API's name can come back empty; the email is the only field always
+ present, so it becomes the row's title when there is nothing above it. */
+ const name = member.name?.trim();
+
+ return (
+
+
+
+
+
+
+ {/* Wraps rather than overflows: an email address is long
+ and a narrow screen has to hold it. */}
+
+
+
+ {/*
+ * The design's status note. It reads "(Invited unnaccepted yet)",
+ * which nothing in the API can tell us — adding a member subscribes
+ * an existing account immediately, so there is no pending state. The
+ * slot instead carries the one thing the membership does record,
+ * which is whether they administer the organization.
+ */}
+
+ {member.is_admin && (
+
+ (Admin)
+
+ )}
+
+
+ {/* Only as wide as its trigger. */}
+
+
+
+
+
+ {/*
+ * The design fills this menu with "Resend invite", which has
+ * no endpoint behind it. It keeps the two actions the page
+ * has always offered on a member instead, and they stay
+ * disabled while they stay unimplemented — as they were
+ * before the redesign.
+ */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/webapp/src/components/mobile-header.tsx b/webapp/src/components/mobile-header.tsx
deleted file mode 100644
index ba7d3c91a..000000000
--- a/webapp/src/components/mobile-header.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { Button } from "@/components/ui/button";
-import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
-import { Organization } from "@/api/schemas";
-import { Menu } from "lucide-react";
-import { Link } from "react-router-dom";
-import { useState } from "react";
-import NavBar from "./navbar";
-
-export default function MobileHeader({
- orgs,
-}: {
- orgs: Organization[] | undefined;
-}) {
- const [isSheetOpen, setSheetOpened] = useState(false);
-
- return (
-
- {/* Drawer that shows only on small screens */}
-
- setSheetOpened(true)}>
-
-
-
-
- );
-}
diff --git a/webapp/src/components/project-actions.tsx b/webapp/src/components/project-actions.tsx
new file mode 100644
index 000000000..427eb30d3
--- /dev/null
+++ b/webapp/src/components/project-actions.tsx
@@ -0,0 +1,216 @@
+import { useState } from "react";
+import { toast } from "sonner";
+
+import {
+ getEmissionsTimeSeries,
+ getRunEmissionsByExperiment,
+} from "@/api/runs";
+import { ExperimentReport, Project } from "@/api/schemas";
+import { useModal } from "@/hooks/useModal";
+import { cn } from "@/helpers/utils";
+import { exportToJson } from "@/utils/export";
+import ProjectSettingsModal from "./project-settings-modal";
+import { DownloadIcon } from "./icons/download-icon";
+import { RefreshIcon } from "./icons/refresh-icon";
+import { SettingsIcon } from "./icons/settings-icon";
+import ShareProjectButton from "./share-project-button";
+import { IconButton } from "./ui/icon-button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "./ui/tooltip";
+
+/*
+ * The actions that apply to a project as a whole: refresh, share, export, and
+ * settings.
+ *
+ * Its own component so it can sit in the page's heading, beside the project's
+ * name, rather than inside the panels below — the dashboard's data flows down,
+ * but these controls belong to the project, not to any panel. It owns the state
+ * only it uses (the refresh and export spinners, the settings dialog).
+ *
+ * The controls are square outlined icon buttons, so they carry the same radius
+ * and the same hover as every other control in the app.
+ */
+export default function ProjectActions({
+ project,
+ experimentsReportData,
+ runData,
+ onRefresh,
+ onProjectUpdated,
+ className,
+}: Readonly<{
+ project: Project;
+ experimentsReportData: ExperimentReport[];
+ runData: { experimentId: string; startDate: string; endDate: string };
+ /** Refetches the dashboard, behind the refresh control. */
+ onRefresh: () => void | Promise;
+ /** Runs after the settings dialog saves; defaults to a full refresh. */
+ onProjectUpdated?: () => void | Promise;
+ className?: string;
+}>) {
+ const settingsModal = useModal();
+ const [isExporting, setIsExporting] = useState(false);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ const handleRefresh = async () => {
+ setIsRefreshing(true);
+ try {
+ await onRefresh();
+ } finally {
+ setIsRefreshing(false);
+ }
+ };
+
+ const handleJsonExport = () => {
+ if (isExporting) return;
+
+ setIsExporting(true);
+
+ toast.promise(
+ (async () => {
+ // Prepare the experiments data with runs for each experiment
+ const experimentsWithRuns = await Promise.all(
+ experimentsReportData.map(async (exp) => {
+ // Fetch runs for each experiment
+ const runs = await getRunEmissionsByExperiment(
+ exp.experiment_id,
+ runData.startDate,
+ runData.endDate,
+ );
+
+ // Fetch metadata and emissions for each run
+ const runsWithDetails = await Promise.all(
+ runs.map(async (run) => {
+ // Get emissions time series data (includes metadata)
+ const emissionsData =
+ await getEmissionsTimeSeries(run.runId);
+
+ // Return run with metadata and emissions
+ return {
+ ...run,
+ emissions_value: run.emissions,
+ emissions:
+ emissionsData?.emissions || undefined,
+ metadata:
+ emissionsData.metadata || undefined,
+ };
+ }),
+ );
+
+ // Return experiment data with its enhanced runs
+ return {
+ experiment_id: exp.experiment_id,
+ name: exp.name,
+ emissions: exp.emissions,
+ energy_consumed: exp.energy_consumed,
+ duration: exp.duration,
+ runs: runsWithDetails,
+ };
+ }),
+ );
+
+ // Format the project data according to the requested structure
+ const formattedData = {
+ projects: [
+ {
+ // Include all project properties
+ id: project.id,
+ name: project.name,
+ description: project.description,
+ public: project.public,
+ organizationId: project.organizationId,
+ experiments: experimentsWithRuns,
+
+ // Add extra metadata
+ date_range: {
+ startDate: runData.startDate,
+ endDate: runData.endDate,
+ },
+ },
+ ],
+ };
+
+ exportToJson(formattedData);
+ // Small delay to make the loading state visible
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ setIsExporting(false);
+ })(),
+ {
+ loading: "Exporting JSON data...",
+ success: "JSON data exported successfully",
+ error: "Failed to export JSON data",
+ },
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
Refresh data
+
+
+
+
+
+
+
+
+
+
+
+
+
Download JSON export
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/webapp/src/components/project-dashboard-base.tsx b/webapp/src/components/project-dashboard-base.tsx
index d26e19272..0a35b269b 100644
--- a/webapp/src/components/project-dashboard-base.tsx
+++ b/webapp/src/components/project-dashboard-base.tsx
@@ -1,5 +1,4 @@
import { DateRangePicker } from "@/components/date-range-picker";
-import { Separator } from "@/components/ui/separator";
import { getDefaultDateRange } from "@/helpers/date-utils";
import {
ExperimentReport,
@@ -10,10 +9,13 @@ import {
} from "@/api/schemas";
import { lazy, ReactNode, Suspense, useState } from "react";
import { DateRange } from "react-day-picker";
+import ChartRow from "./chart-row";
import ChartSkeleton from "./chart-skeleton";
-import CreateExperimentModal from "./createExperimentModal";
-import { Button } from "./ui/button";
-import { Card, CardContent } from "./ui/card";
+import ConsumedEnergyGauges from "./consumed-energy-gauges";
+import { PlusIcon } from "./icons/plus-icon";
+import { PrimaryButton } from "./ui/primary-button";
+import EquivalenceList, { equivalences } from "./equivalence-list";
+import CreateExperimentModal from "./create-experiment-modal";
import {
Select,
SelectContent,
@@ -29,7 +31,6 @@ import { toast } from "sonner";
// experiment dropdown. Radix Select forbids an empty string as an item value.
const ALL_EXPERIMENTS = "__all__";
-const RadialChart = lazy(() => import("@/components/radial-chart"));
const ExperimentsBarChart = lazy(
() => import("@/components/experiment-bar-chart"),
);
@@ -107,20 +108,17 @@ export default function ProjectDashboardBase({
};
return (
-
-
- {headerContent ? (
- headerContent
- ) : (
-
-
{project.name}
-
- {project.description}
-
-
- )}
-
+ /*
+ * No gap on this column: the charts below are separated by rules that have
+ * to meet, and a gap here would push the horizontal one away from the
+ * vertical ones. Each block carries its own space instead.
+ */
+
- {projectExperiments.length === 0
- ? isPublicView
- ? "No experiment data in the selected date range" // This is because for public projects we show only the experiments that have runs, but for private projects we show in this list as well the projects created but without runs yet
- : "No experiments have been created yet."
- : "Set of experiments included in this project"}
+
+
+ Experiments
+
+
+ {projectExperiments.length === 0 && (
+
+ {isPublicView
+ ? // Public projects list only experiments that have
+ // runs; private ones also list those created without
+ // any runs yet.
+ "No experiment data in the selected date range"
+ : "No experiments have been created yet."}
);
diff --git a/webapp/src/components/project-dashboard.tsx b/webapp/src/components/project-dashboard.tsx
index c5714f3b7..1efee919e 100644
--- a/webapp/src/components/project-dashboard.tsx
+++ b/webapp/src/components/project-dashboard.tsx
@@ -1,31 +1,15 @@
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import {
- getEmissionsTimeSeries,
- getRunEmissionsByExperiment,
-} from "@/api/runs";
import { ProjectDashboardProps } from "@/api/schemas";
-import { exportToJson } from "@/utils/export";
-import {
- Download,
- LockIcon,
- RefreshCw,
- SettingsIcon,
- Share2Icon,
-} from "lucide-react";
-import { useState } from "react";
-import { toast } from "sonner";
import ProjectDashboardBase from "./project-dashboard-base";
-import ProjectSettingsModal from "./project-settings-modal";
-import ShareProjectButton from "./share-project-button";
-import { useModal } from "@/hooks/useModal";
+/*
+ * The private project dashboard: the shared panels, wired to the authenticated
+ * data.
+ *
+ * The project's own controls — refresh, share, export, settings — used to live
+ * here as a header row passed down to the base. They now sit in the page's
+ * heading beside the project's name, as `ProjectActions`, so nothing about the
+ * project's identity is rendered from inside its panels.
+ */
export default function ProjectDashboard({
project,
date,
@@ -39,229 +23,26 @@ export default function ProjectDashboard({
selectedRunId,
onExperimentClick,
onRunClick,
- onSettingsClick,
onRefresh,
isLoading,
}: ProjectDashboardProps) {
- const settingsModal = useModal();
- const [isExporting, setIsExporting] = useState(false);
- const [isRefreshing, setIsRefreshing] = useState(false);
-
- const handleRefresh = async () => {
- setIsRefreshing(true);
- try {
- await onRefresh();
- } finally {
- setIsRefreshing(false);
- }
- };
-
- const handleJsonExport = () => {
- if (isExporting) return;
-
- setIsExporting(true);
-
- toast.promise(
- (async () => {
- // Prepare the experiments data with runs for each experiment
- const experimentsWithRuns = await Promise.all(
- experimentsReportData.map(async (exp) => {
- // Fetch runs for each experiment
- const runs = await getRunEmissionsByExperiment(
- exp.experiment_id,
- runData.startDate,
- runData.endDate,
- );
-
- // Fetch metadata and emissions for each run
- const runsWithDetails = await Promise.all(
- runs.map(async (run) => {
- // Get emissions time series data (includes metadata)
- const emissionsData =
- await getEmissionsTimeSeries(run.runId);
-
- // Return run with metadata and emissions
- return {
- ...run,
- emissions_value: run.emissions,
- emissions:
- emissionsData?.emissions || undefined,
- metadata:
- emissionsData.metadata || undefined,
- };
- }),
- );
-
- // Return experiment data with its enhanced runs
- return {
- experiment_id: exp.experiment_id,
- name: exp.name,
- emissions: exp.emissions,
- energy_consumed: exp.energy_consumed,
- duration: exp.duration,
- runs: runsWithDetails,
- };
- }),
- );
-
- // Format the project data according to the requested structure
- const formattedData = {
- projects: [
- {
- // Include all project properties
- id: project.id,
- name: project.name,
- description: project.description,
- public: project.public,
- organizationId: project.organizationId,
- experiments: experimentsWithRuns,
-
- // Add extra metadata
- date_range: {
- startDate: runData.startDate,
- endDate: runData.endDate,
- },
- },
- ],
- };
-
- exportToJson(formattedData);
- // Small delay to make the loading state visible
- await new Promise((resolve) => setTimeout(resolve, 500));
- setIsExporting(false);
- })(),
- {
- loading: "Exporting JSON data...",
- success: "JSON data exported successfully",
- error: "Failed to export JSON data",
- },
- );
- };
-
- const headerContent = (
-
-
-
- Project {project.name}
-
- {project.public !== undefined && (
-
-
-
- )}
-
-
-
-
-
-
-
-
-
Refresh data
-
-
-
-
-
-
-
-
-
-
-
Download JSON export
-
-
-
-
-
-
- );
-
return (
-
-
-
- {
- // Call the original onSettingsClick to refresh the data
- onSettingsClick();
- }}
- />
-
- );
-}
-
-export function ProjectVisibilityBadge({ isPublic }: { isPublic: boolean }) {
- return isPublic ? (
-
-
- Public
-
- ) : (
-
-
- Private
-
+
);
}
diff --git a/webapp/src/components/project-row.tsx b/webapp/src/components/project-row.tsx
new file mode 100644
index 000000000..0d025e923
--- /dev/null
+++ b/webapp/src/components/project-row.tsx
@@ -0,0 +1,99 @@
+import { Link } from "react-router-dom";
+
+import { Project } from "@/api/schemas";
+import { cn } from "@/helpers/utils";
+import { MoreVertIcon } from "./icons/more-vert-icon";
+import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu";
+import { MenuItem, MenuPanel } from "./ui/menu";
+import { TableCell, TableRow } from "./ui/table";
+
+/*
+ * One project in the list: its name, its secondary text, and a menu of the
+ * actions that apply to it.
+ *
+ * Both texts are links filling their cells, so the whole band is a click
+ * target, and they light together on hover as one item. The actions cell is
+ * excluded from that: its trigger is a small glyph, and if the cell lit with it
+ * there would be no way to tell the button from merely being near it.
+ */
+
+/** Lets the row's hover exclude the actions cell. */
+const ACTIONS_CELL = "project-row-actions";
+
+/*
+ * The trigger's padding, and the gap its menu keeps from the glyph. Radix anchors
+ * a menu to the trigger's box — the whole hit area — so cancelling that padding on
+ * both axes anchors the panel to the dots themselves, and enlarging the hit area
+ * no longer pushes the menu away from what opened it.
+ */
+const TRIGGER_INSET = 20;
+const MENU_GAP = 4;
+
+const CELL_LINK =
+ "type-mono-medium block break-words py-5 text-cc-white outline-none transition-colors " +
+ "group-[:hover:not(:has(.project-row-actions:hover))]:text-cc-button-hover " +
+ "focus-visible:ring-2 focus-visible:ring-cc-lime lg:py-6 motion-reduce:transition-none";
+
+export default function ProjectRow({
+ project,
+ href,
+ onSettings,
+ onDelete,
+}: Readonly<{
+ project: Project;
+ href: string;
+ onSettings: () => void;
+ onDelete: () => void;
+}>) {
+ return (
+
+ {/* The name takes half the table, which is what starts the secondary
+ text at its own column. */}
+
+
+ {project.name}
+
+
+
+
+ {project.description && (
+
+ {project.description}
+
+ )}
+
+
+ {/* Only as wide as its trigger. */}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/webapp/src/components/project-settings-modal.tsx b/webapp/src/components/project-settings-modal.tsx
index 0befd0ba9..d7025e24f 100644
--- a/webapp/src/components/project-settings-modal.tsx
+++ b/webapp/src/components/project-settings-modal.tsx
@@ -1,22 +1,29 @@
-import { useState, useEffect } from "react";
+import { useEffect, useState } from "react";
+import { Loader2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { updateProject } from "@/api/projects";
import { Project } from "@/api/schemas";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Switch } from "@/components/ui/switch";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
-} from "@/components/ui/dialog";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ProjectTokensTable } from "./projectTokens/projectTokenTable";
-import { updateProject } from "@/api/projects";
-import { toast } from "sonner";
-import { Loader2 } from "lucide-react";
+import ShareProjectButton from "./share-project-button";
+import { Dialog, DialogContent } from "./ui/dialog";
+import ModalHeader from "./ui/modal-header";
+import { FormField } from "./ui/form-field";
+import { PrimaryButton } from "./ui/primary-button";
+import { Switch } from "./ui/switch";
+import { TabNavList, TabNavTrigger } from "./ui/tab-nav";
+import { Tabs, TabsContent } from "./ui/tabs";
+
+/*
+ * Project settings: the Create-project modal's panel, fields and button, wider
+ * because it also holds the API-tokens table. The design has no frame for this
+ * dialog, so it is the redesign's vocabulary applied to the controls it had.
+ *
+ * The fields save on submit; the public toggle saves the moment it is flipped,
+ * which is what lets the sharing link it controls appear and disappear with it.
+ * Only a failed submit keeps the dialog open, so edits that did not save are
+ * still there to retry.
+ */
interface ProjectSettingsModalProps {
open: boolean;
@@ -37,12 +44,51 @@ export default function ProjectSettingsModal({
const [isSaving, setIsSaving] = useState(false);
const [activeTab, setActiveTab] = useState("general");
- // Update form when project changes
+ /*
+ * The dialog stays mounted between openings, so the tab it was left on would
+ * otherwise still be showing the next time it opens. Settings starts on
+ * General; the tokens tab is somewhere you go, not somewhere you resume.
+ */
+ useEffect(() => {
+ if (open) setActiveTab("general");
+ }, [open]);
+
+ /*
+ * Reset the form when the dialog moves to a *different* project, keyed on the
+ * id rather than the object. The toggle below saves as it is flipped, which
+ * refreshes the project and hands this component a new object; keying on the
+ * object would make that refresh overwrite whatever the user had typed.
+ */
useEffect(() => {
setName(project.name || "");
setDescription(project.description || "");
setIsPublic(project.public || false);
- }, [project]);
+ }, [project.id, project.name, project.description, project.public]);
+
+ /*
+ * The public toggle saves on its own, so the sharing link it controls appears
+ * and disappears with it rather than waiting for the form to be submitted.
+ *
+ * It writes only the flag: the name and description it sends are the *saved*
+ * ones, not what is currently in the fields, so flipping the switch never
+ * quietly commits half-typed text. The switch moves first and rolls back if
+ * the write fails, so it always shows what is actually stored.
+ */
+ const handlePublicChange = async (next: boolean) => {
+ setIsPublic(next);
+ try {
+ await updateProject(project.id, {
+ name: project.name,
+ description: project.description,
+ public: next,
+ });
+ onProjectUpdated();
+ } catch (error) {
+ console.error("Error updating project visibility:", error);
+ setIsPublic(!next);
+ toast.error("Failed to change project visibility");
+ }
+ };
const handleSave = async () => {
setIsSaving(true);
@@ -54,95 +100,107 @@ export default function ProjectSettingsModal({
});
toast.success("Project settings updated successfully");
onProjectUpdated();
+ onOpenChange(false);
} catch (error) {
+ // Left open on failure, so the edits that failed to save are still
+ // there to retry rather than being discarded.
console.error("Error updating project:", error);
toast.error("Failed to update project settings");
} finally {
setIsSaving(false);
- onOpenChange(false);
}
};
return (